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
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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user