diff --git a/src/Client/Features/Quizzes/TakeQuiz/State.fs b/src/Client/Features/Quizzes/TakeQuiz/State.fs index 1f329c2..582027b 100644 --- a/src/Client/Features/Quizzes/TakeQuiz/State.fs +++ b/src/Client/Features/Quizzes/TakeQuiz/State.fs @@ -53,6 +53,9 @@ let rec update (token: string option) (msg: Msg) (model: Model) : Model * Cmd { model with Error = None }, 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 -> let updated = { model with RemainingSeconds = remainingSeconds model.Data DateTimeOffset.UtcNow } // The server is the actual authority (it now rejects any answer diff --git a/src/Client/Features/Quizzes/TakeQuiz/Types.fs b/src/Client/Features/Quizzes/TakeQuiz/Types.fs index 55b4e74..23462e8 100644 --- a/src/Client/Features/Quizzes/TakeQuiz/Types.fs +++ b/src/Client/Features/Quizzes/TakeQuiz/Types.fs @@ -18,6 +18,10 @@ let remainingSeconds (data: QuizForAttempt) (now: DateTimeOffset) : int option = type Model = { Data: QuizForAttempt Answers: Map + /// 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 IsFinishing: bool RemainingSeconds: int option @@ -30,6 +34,7 @@ type Model = let init (data: QuizForAttempt) : Model = { Data = data Answers = Map.empty + CurrentIndex = 0 Error = None IsFinishing = false RemainingSeconds = remainingSeconds data DateTimeOffset.UtcNow @@ -39,6 +44,7 @@ type Msg = | AnswerChanged of QuestionId * StudentResponse | AnswerSaved of QuestionId | AnswerSaveFailed of QuestionId * string + | GoToQuestion of int | Tick | FocusLost | FocusRegained diff --git a/src/Client/Features/Quizzes/TakeQuiz/View.fs b/src/Client/Features/Quizzes/TakeQuiz/View.fs index 2e9d083..2d2c2e5 100644 --- a/src/Client/Features/Quizzes/TakeQuiz/View.fs +++ b/src/Client/Features/Quizzes/TakeQuiz/View.fs @@ -5,7 +5,7 @@ open Domain open Domain.Contracts open Client.Features.Quizzes.TakeQuiz.Types -let private questionView (answers: Map) dispatch (index: int) (question: QuestionView) = +let private questionBody (answers: Map) dispatch (question: QuestionView) = let groupName = string question.Id let body = @@ -111,18 +111,33 @@ let private questionView (answers: Map) dispatch (i dispatch (AnswerChanged(question.Id, NumericResponse parsed))) ] + body + +let private questionCard (model: Model) dispatch (question: QuestionView) = + let total = List.length model.Data.Questions + Html.div [ prop.key (string question.Id) - prop.className "question" + prop.className "question-card" prop.children [ - Html.span [ prop.className "question-number"; prop.text (sprintf "№ %02d" (index + 1)) ] - Html.div [ - prop.className "question-body" - prop.children [ Html.p [ prop.className "question-text"; prop.text question.Text ]; body ] + Html.span [ + prop.className "question-number" + prop.text (sprintf "Вопрос %d из %d" (model.CurrentIndex + 1) total) ] + 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) = Html.span [ 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 total = List.length model.Data.Questions + Html.div [ prop.className "taking-quiz-page" prop.children [ @@ -138,7 +155,16 @@ let view (model: Model) (dispatch: Msg -> unit) = | Some err -> Html.p [ prop.className "error"; prop.text err ] | None -> Html.none 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 [ prop.className "quiz-actions" diff --git a/src/Client/style.css b/src/Client/style.css index 1a3529f..e47d8eb 100644 --- a/src/Client/style.css +++ b/src/Client/style.css @@ -297,28 +297,27 @@ a { /* ============================================================ Taking a quiz ============================================================ */ -.question { +.question-nav { display: flex; - gap: 1rem; - border-top: 1px solid var(--line); - padding: 1.25rem 0; + align-items: center; + gap: 0.75rem; } -.question:first-of-type { - border-top: none; +.question-card { + flex: 1; + min-width: 0; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.5rem; } .question-number { + display: block; font-family: var(--font-mono); font-size: 0.85rem; color: var(--brass); - flex-shrink: 0; - padding-top: 0.15rem; -} - -.question-body { - flex: 1; - min-width: 0; + margin-bottom: 0.6rem; } .question-text { @@ -326,6 +325,23 @@ a { 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 { display: flex; align-items: center; @@ -730,6 +746,24 @@ a { 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-backdrop {