Show one question per card in the take-quiz screen, with prev/next arrows
Some checks failed
CI/CD / build-test-deploy (pull_request) Failing after 2s

Replaces the single long scrollable list of every question with one
card at a time plus arrow navigation, so a student's focus stays on
the current question instead of the whole quiz at once.
This commit is contained in:
danamir
2026-08-09 00:55:01 +03:00
parent 63bdd7dd74
commit ef119dc0dc
4 changed files with 89 additions and 20 deletions

View File

@@ -53,6 +53,9 @@ let rec update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Ms
updated, cmd updated, cmd
| AnswerSaved _ -> { model with Error = None }, Cmd.none | AnswerSaved _ -> { model with Error = None }, Cmd.none
| AnswerSaveFailed(_, err) -> { model with Error = Some err }, Cmd.none | AnswerSaveFailed(_, err) -> { model with Error = Some err }, Cmd.none
| GoToQuestion index ->
let clamped = index |> max 0 |> min (List.length model.Data.Questions - 1)
{ model with CurrentIndex = clamped }, Cmd.none
| Tick -> | Tick ->
let updated = { model with RemainingSeconds = remainingSeconds model.Data DateTimeOffset.UtcNow } let updated = { model with RemainingSeconds = remainingSeconds model.Data DateTimeOffset.UtcNow }
// The server is the actual authority (it now rejects any answer // The server is the actual authority (it now rejects any answer

View File

@@ -18,6 +18,10 @@ let remainingSeconds (data: QuizForAttempt) (now: DateTimeOffset) : int option =
type Model = type Model =
{ Data: QuizForAttempt { Data: QuizForAttempt
Answers: Map<QuestionId, StudentResponse> Answers: Map<QuestionId, StudentResponse>
/// Index into `Data.Questions` of the question currently shown on its
/// own card questions are navigated one at a time via prev/next
/// arrows rather than shown all at once.
CurrentIndex: int
Error: string option Error: string option
IsFinishing: bool IsFinishing: bool
RemainingSeconds: int option RemainingSeconds: int option
@@ -30,6 +34,7 @@ type Model =
let init (data: QuizForAttempt) : Model = let init (data: QuizForAttempt) : Model =
{ Data = data { Data = data
Answers = Map.empty Answers = Map.empty
CurrentIndex = 0
Error = None Error = None
IsFinishing = false IsFinishing = false
RemainingSeconds = remainingSeconds data DateTimeOffset.UtcNow RemainingSeconds = remainingSeconds data DateTimeOffset.UtcNow
@@ -39,6 +44,7 @@ type Msg =
| AnswerChanged of QuestionId * StudentResponse | AnswerChanged of QuestionId * StudentResponse
| AnswerSaved of QuestionId | AnswerSaved of QuestionId
| AnswerSaveFailed of QuestionId * string | AnswerSaveFailed of QuestionId * string
| GoToQuestion of int
| Tick | Tick
| FocusLost | FocusLost
| FocusRegained | FocusRegained

View File

@@ -5,7 +5,7 @@ open Domain
open Domain.Contracts open Domain.Contracts
open Client.Features.Quizzes.TakeQuiz.Types open Client.Features.Quizzes.TakeQuiz.Types
let private questionView (answers: Map<QuestionId, StudentResponse>) dispatch (index: int) (question: QuestionView) = let private questionBody (answers: Map<QuestionId, StudentResponse>) dispatch (question: QuestionView) =
let groupName = string question.Id let groupName = string question.Id
let body = let body =
@@ -111,18 +111,33 @@ let private questionView (answers: Map<QuestionId, StudentResponse>) dispatch (i
dispatch (AnswerChanged(question.Id, NumericResponse parsed))) dispatch (AnswerChanged(question.Id, NumericResponse parsed)))
] ]
body
let private questionCard (model: Model) dispatch (question: QuestionView) =
let total = List.length model.Data.Questions
Html.div [ Html.div [
prop.key (string question.Id) prop.key (string question.Id)
prop.className "question" prop.className "question-card"
prop.children [ prop.children [
Html.span [ prop.className "question-number"; prop.text (sprintf "№ %02d" (index + 1)) ] Html.span [
Html.div [ prop.className "question-number"
prop.className "question-body" prop.text (sprintf "Вопрос %d из %d" (model.CurrentIndex + 1) total)
prop.children [ Html.p [ prop.className "question-text"; prop.text question.Text ]; body ]
] ]
Html.p [ prop.className "question-text"; prop.text question.Text ]
questionBody model.Answers dispatch question
] ]
] ]
let private navArrow (className: string) (label: string) (enabled: bool) (onClick: unit -> unit) =
Html.button [
prop.type'.button
prop.className (sprintf "nav-arrow %s" className)
prop.disabled (not enabled)
prop.onClick (fun _ -> onClick ())
prop.text label
]
let private timeRemainingBadge (seconds: int) = let private timeRemainingBadge (seconds: int) =
Html.span [ Html.span [
prop.className (if seconds <= 60 then "time-remaining low" else "time-remaining") prop.className (if seconds <= 60 then "time-remaining low" else "time-remaining")
@@ -130,6 +145,8 @@ let private timeRemainingBadge (seconds: int) =
] ]
let view (model: Model) (dispatch: Msg -> unit) = let view (model: Model) (dispatch: Msg -> unit) =
let total = List.length model.Data.Questions
Html.div [ Html.div [
prop.className "taking-quiz-page" prop.className "taking-quiz-page"
prop.children [ prop.children [
@@ -138,7 +155,16 @@ let view (model: Model) (dispatch: Msg -> unit) =
| Some err -> Html.p [ prop.className "error"; prop.text err ] | Some err -> Html.p [ prop.className "error"; prop.text err ]
| None -> Html.none | None -> Html.none
Html.div [ Html.div [
prop.children (model.Data.Questions |> List.mapi (questionView model.Answers dispatch)) prop.className "question-nav"
prop.children [
navArrow "nav-arrow-prev" "" (model.CurrentIndex > 0) (fun () ->
dispatch (GoToQuestion(model.CurrentIndex - 1)))
match model.Data.Questions |> List.tryItem model.CurrentIndex with
| Some question -> questionCard model dispatch question
| None -> Html.none
navArrow "nav-arrow-next" "" (model.CurrentIndex < total - 1) (fun () ->
dispatch (GoToQuestion(model.CurrentIndex + 1)))
]
] ]
Html.div [ Html.div [
prop.className "quiz-actions" prop.className "quiz-actions"

View File

@@ -297,28 +297,27 @@ a {
/* ============================================================ /* ============================================================
Taking a quiz Taking a quiz
============================================================ */ ============================================================ */
.question { .question-nav {
display: flex; display: flex;
gap: 1rem; align-items: center;
border-top: 1px solid var(--line); gap: 0.75rem;
padding: 1.25rem 0;
} }
.question:first-of-type { .question-card {
border-top: none; flex: 1;
min-width: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.5rem;
} }
.question-number { .question-number {
display: block;
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.85rem; font-size: 0.85rem;
color: var(--brass); color: var(--brass);
flex-shrink: 0; margin-bottom: 0.6rem;
padding-top: 0.15rem;
}
.question-body {
flex: 1;
min-width: 0;
} }
.question-text { .question-text {
@@ -326,6 +325,23 @@ a {
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
} }
.nav-arrow {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
padding: 0;
font-size: 1.3rem;
line-height: 1;
border-radius: 50%;
background: var(--surface);
color: var(--ink);
border: 1px solid var(--line);
}
.nav-arrow:hover:not(:disabled) {
background: var(--brass-soft);
}
.option { .option {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -730,6 +746,24 @@ a {
padding: 0.3rem 0; padding: 0.3rem 0;
} }
.topic-checkbox-list {
display: flex;
flex-wrap: wrap;
gap: 0 1rem;
margin-bottom: 0.5rem;
}
.topic-picker-section {
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.topic-picker-section h4 {
margin: 0 0 0.5rem;
}
/* ---- Modal overlay (quiz create/edit form) ---- */ /* ---- Modal overlay (quiz create/edit form) ---- */
.modal-backdrop { .modal-backdrop {