diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml index e7be4a7..3c7fdc8 100644 --- a/.gitea/workflows/ci-cd.yml +++ b/.gitea/workflows/ci-cd.yml @@ -10,13 +10,33 @@ on: # checkout path — otherwise `docker compose` would derive the project name # from the checkout directory, potentially spinning up a second stack and # losing the `pgdata` volume instead of updating the running one. +# +# JWT_SECRET/POSTGRES_PASSWORD come from Gitea's own Actions secrets store +# rather than the `.env` file DEPLOY.md has the human create in +# `/opt/ruvdstests` — the job checks out into the runner's own workspace, not +# that directory, so there's no `.env` for `docker compose` to read here. +# Must match the values already in that `.env` file: on `Deploy` this +# `docker compose up -d` targets the same running project (via +# COMPOSE_PROJECT_NAME above), and a different POSTGRES_PASSWORD than what +# the live `pgdata` volume was initialized with breaks the DB connection. env: COMPOSE_PROJECT_NAME: ruvdstests + JWT_SECRET: ${{ secrets.JWT_SECRET }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} jobs: build-test-deploy: runs-on: host steps: + # `runs-on: host` runs every step directly on the bare runner + # container instead of spinning up a fresh container per job, and + # `actions/checkout` below is a JS action — it needs a `node` binary + # on PATH to run at all, which this image doesn't ship. Has to come + # before Checkout, since Checkout itself is what fails without it. + - name: Install Node.js + shell: bash + run: apk add --no-cache nodejs + - name: Checkout uses: https://github.com/actions/checkout@v4 diff --git a/docs/CI-CD.md b/docs/CI-CD.md index d21064c..4a93d63 100644 --- a/docs/CI-CD.md +++ b/docs/CI-CD.md @@ -37,13 +37,25 @@ docker run -d --name gitea-ci-runner --restart unless-stopped \ `host:host` — раннер выполняет джобы напрямую в своём собственном контейнере (никаких вложенных контейнеров на каждый джоб), у него смонтирован docker.sock хоста — поэтому `docker compose` -внутри джоба управляет реальными контейнерами на этой машине. Джоб сам ставит себе `docker-cli` и -.NET SDK через `apk`/`dotnet-install.sh` (см. workflow) — образ раннера намеренно голый, чтобы не -поддерживать отдельный кастомный образ. +внутри джоба управляет реальными контейнерами на этой машине. Джоб сам ставит себе `node`, +`docker-cli` и .NET SDK через `apk`/`dotnet-install.sh` (см. workflow) — образ раннера намеренно +голый, чтобы не поддерживать отдельный кастомный образ. `node` ставится первым шагом, до +`actions/checkout` — этот шаг сам является JS-экшеном и без `node` в PATH падает с `Cannot find: +node in PATH` раньше, чем успевает выполниться что-либо ещё. Проверить, что раннер подключился: Site Administration → Actions → Runners — должен появиться `ruvdstest-prod` со статусом Idle/Online. +## Секреты + +Джоб чекаутит репозиторий во временную директорию раннера, а не в `/opt/ruvdstests` — своего +`.env` там нет, поэтому `JWT_SECRET`/`POSTGRES_PASSWORD` для `docker compose build`/`up` берутся не +из файла, а из хранилища секретов самой Gitea Actions: Settings репозитория → Actions → Secrets → +**Add Secret**. Завести `JWT_SECRET` и `POSTGRES_PASSWORD` со **значениями, совпадающими с тем, что +уже лежит в `/opt/ruvdstests/.env`** на сервере (`cat /opt/ruvdstests/.env` на сервере, скопировать +оттуда) — деплой-шаг пересоздаёт тот же самый запущенный проект (`COMPOSE_PROJECT_NAME` выше), и +рассинхронизация паролей с уже инициализированным `pgdata`-volume сломает подключение к БД. + ## Проверка Сделайте любой коммит и запушьте в `master` — во вкладке **Actions** репозитория должен появиться 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/Features/Teacher/Tests/State.fs b/src/Client/Features/Teacher/Tests/State.fs index 81d69f0..06039f3 100644 --- a/src/Client/Features/Teacher/Tests/State.fs +++ b/src/Client/Features/Teacher/Tests/State.fs @@ -175,34 +175,74 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd = { model with QuizForm = { model.QuizForm with ShuffleQuestions = value } }, Cmd.none | SetQuizShuffleAnswers value -> { model with QuizForm = { model.QuizForm with ShuffleAnswers = value } }, Cmd.none - | SelectPickerTopic topicId -> - let cmd = - Cmd.OfAsync.either - (Api.listQuestionsInTopic token) - topicId - (function - | Ok questions -> PickerQuestionsLoaded questions - | Error err -> PickerQuestionsLoadFailed err) - (fun ex -> PickerQuestionsLoadFailed ex.Message) + | ToggleTopicInPicker topicId -> + let form = model.QuizForm - let existingPoolCount = - model.QuizForm.Sources - |> List.tryPick (function - | PoolDraft(tid, count) when tid = topicId -> Some count - | _ -> None) + if form.PickerTopicIds.Contains topicId then + // Deselecting a topic drops it from the quiz entirely — its + // fixed picks and pool rule no longer apply. + let belongsToTopic qid = + form.PickerQuestions + |> List.tryFind (fun q -> q.Id = qid) + |> Option.map (fun q -> q.TopicId = topicId) + |> Option.defaultValue false + let sourcesWithoutTopic = + form.Sources + |> List.filter (function + | PoolDraft(tid, _) -> tid <> topicId + | FixedDraft qid -> not (belongsToTopic qid)) + + { model with + QuizForm = + { form with + PickerTopicIds = form.PickerTopicIds.Remove topicId + PickerQuestions = form.PickerQuestions |> List.filter (fun q -> q.TopicId <> topicId) + PoolCountTexts = form.PoolCountTexts.Remove topicId + Sources = sourcesWithoutTopic } }, + Cmd.none + else + let cmd = + Cmd.OfAsync.either + (Api.listQuestionsInTopic token) + topicId + (function + | Ok questions -> PickerQuestionsLoaded(topicId, questions) + | Error err -> PickerQuestionsLoadFailed(topicId, err)) + (fun ex -> PickerQuestionsLoadFailed(topicId, ex.Message)) + + let existingPoolCount = + form.Sources + |> List.tryPick (function + | PoolDraft(tid, count) when tid = topicId -> Some count + | _ -> None) + + let poolCountTexts = + match existingPoolCount with + | Some count -> form.PoolCountTexts |> Map.add topicId (string count) + | None -> form.PoolCountTexts + + { model with + QuizForm = + { form with + PickerTopicIds = form.PickerTopicIds.Add topicId + PickerLoadingTopics = form.PickerLoadingTopics.Add topicId + PoolCountTexts = poolCountTexts } }, + cmd + | PickerQuestionsLoaded(topicId, questions) -> { model with QuizForm = { model.QuizForm with - PickerTopicId = Some topicId - PickerLoading = true - PickerQuestions = [] - PoolCountText = existingPoolCount |> Option.map string |> Option.defaultValue "" } }, - cmd - | PickerQuestionsLoaded questions -> - { model with QuizForm = { model.QuizForm with PickerQuestions = questions; PickerLoading = false } }, Cmd.none - | PickerQuestionsLoadFailed err -> - { model with QuizForm = { model.QuizForm with PickerLoading = false; Error = Some err } }, Cmd.none + PickerQuestions = model.QuizForm.PickerQuestions @ questions + PickerLoadingTopics = model.QuizForm.PickerLoadingTopics.Remove topicId } }, + Cmd.none + | PickerQuestionsLoadFailed(topicId, err) -> + { model with + QuizForm = + { model.QuizForm with + PickerLoadingTopics = model.QuizForm.PickerLoadingTopics.Remove topicId + Error = Some err } }, + Cmd.none | ToggleQuestionPick questionId -> let form = model.QuizForm @@ -216,7 +256,7 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd = form.Sources @ [ FixedDraft questionId ] { model with QuizForm = { form with Sources = next } }, Cmd.none - | SelectAllInTopic -> + | SelectAllInTopic topicId -> let form = model.QuizForm let alreadySelected = @@ -228,31 +268,32 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd = let toAdd = form.PickerQuestions + |> List.filter (fun q -> q.TopicId = topicId) |> List.map (fun q -> q.Id) |> List.filter (alreadySelected.Contains >> not) |> List.map FixedDraft { model with QuizForm = { form with Sources = form.Sources @ toAdd } }, Cmd.none - | SetPoolCountText text -> { model with QuizForm = { model.QuizForm with PoolCountText = text } }, Cmd.none - | AddRandomPool -> + | SetPoolCountText(topicId, text) -> + { model with QuizForm = { model.QuizForm with PoolCountTexts = model.QuizForm.PoolCountTexts |> Map.add topicId text } }, + Cmd.none + | AddRandomPool topicId -> let form = model.QuizForm + let text = form.PoolCountTexts |> Map.tryFind topicId |> Option.defaultValue "" - match form.PickerTopicId with - | None -> model, Cmd.none - | Some topicId -> - match System.Int32.TryParse form.PoolCountText with - | true, count when count > 0 -> - let withoutOldPool = - form.Sources - |> List.filter (function - | PoolDraft(tid, _) -> tid <> topicId - | _ -> true) + match System.Int32.TryParse text with + | true, count when count > 0 -> + let withoutOldPool = + form.Sources + |> List.filter (function + | PoolDraft(tid, _) -> tid <> topicId + | _ -> true) - { model with QuizForm = { form with Sources = withoutOldPool @ [ PoolDraft(topicId, count) ]; Error = None } }, - Cmd.none - | _ -> - { model with QuizForm = { form with Error = Some "Количество случайных вопросов должно быть положительным числом" } }, - Cmd.none + { model with QuizForm = { form with Sources = withoutOldPool @ [ PoolDraft(topicId, count) ]; Error = None } }, + Cmd.none + | _ -> + { model with QuizForm = { form with Error = Some "Количество случайных вопросов должно быть положительным числом" } }, + Cmd.none | RemoveRandomPool topicId -> let form = model.QuizForm @@ -262,9 +303,7 @@ let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd = | PoolDraft(tid, _) -> tid <> topicId | _ -> true) - let poolCountText = if form.PickerTopicId = Some topicId then "" else form.PoolCountText - - { model with QuizForm = { form with Sources = next; PoolCountText = poolCountText } }, Cmd.none + { model with QuizForm = { form with Sources = next; PoolCountTexts = form.PoolCountTexts.Remove topicId } }, Cmd.none | SubmitQuizForm -> match buildQuizFields model.QuizForm with | Error err -> { model with QuizForm = { model.QuizForm with Error = Some err } }, Cmd.none diff --git a/src/Client/Features/Teacher/Tests/Types.fs b/src/Client/Features/Teacher/Tests/Types.fs index da0d57d..757eba6 100644 --- a/src/Client/Features/Teacher/Tests/Types.fs +++ b/src/Client/Features/Teacher/Tests/Types.fs @@ -21,16 +21,19 @@ type QuizForm = PassingScoreText: string // empty = None ShuffleQuestions: bool ShuffleAnswers: bool - /// Accumulates across topic switches in the picker below — membership - /// alone decides the checkbox state, regardless of which topic's + /// Accumulates across topics selected in the picker below — membership + /// alone decides the checkbox state, regardless of which topics' /// questions are currently displayed. Sources: QuizSourceDraft list - PickerTopicId: TopicId option + /// Topics currently expanded in the picker — a test can draw questions + /// from any number of them at once. + PickerTopicIds: Set + /// Union of questions loaded for every topic in `PickerTopicIds`. PickerQuestions: QuestionSummary list - PickerLoading: bool - /// Draft text for "N случайных вопросов" of the topic currently open - /// in the picker — reset whenever the picker's topic changes. - PoolCountText: string + /// Topics whose questions are still being fetched. + PickerLoadingTopics: Set + /// Draft text for "N случайных вопросов", one per expanded topic. + PoolCountTexts: Map Error: string option IsSubmitting: bool } @@ -43,34 +46,31 @@ let emptyQuizForm = ShuffleQuestions = false ShuffleAnswers = false Sources = [] - PickerTopicId = None + PickerTopicIds = Set.empty PickerQuestions = [] - PickerLoading = false - PoolCountText = "" + PickerLoadingTopics = Set.empty + PoolCountTexts = Map.empty Error = None IsSubmitting = false } -/// Ids of `form.PickerQuestions` (the topic currently open in the picker) -/// that are individually fixed-selected — used to enforce "fixed selection -/// XOR random pool" per topic, one topic at a time. -let fixedIdsInCurrentTopic (form: QuizForm) : QuestionId list = - let topicIds = form.PickerQuestions |> List.map (fun q -> q.Id) |> Set.ofList +/// Loaded questions belonging to one topic. +let questionsInTopic (form: QuizForm) (topicId: TopicId) : QuestionSummary list = + form.PickerQuestions |> List.filter (fun q -> q.TopicId = topicId) +/// Whether a question is individually fixed-selected — used to enforce +/// "fixed selection XOR random pool" per topic. +let isFixedSelected (form: QuizForm) (questionId: QuestionId) : bool = form.Sources - |> List.choose (function - | FixedDraft qid when topicIds.Contains qid -> Some qid - | _ -> None) + |> List.exists (function + | FixedDraft qid -> qid = questionId + | _ -> false) -/// The random-pool count configured for the topic currently open in the -/// picker, if any. -let poolCountForCurrentTopic (form: QuizForm) : int option = - match form.PickerTopicId with - | None -> None - | Some topicId -> - form.Sources - |> List.tryPick (function - | PoolDraft(tid, count) when tid = topicId -> Some count - | _ -> None) +/// The random-pool count configured for a given topic, if any. +let poolCountForTopic (form: QuizForm) (topicId: TopicId) : int option = + form.Sources + |> List.tryPick (function + | PoolDraft(tid, count) when tid = topicId -> Some count + | _ -> None) /// Total number of questions the quiz will actually have: one per fixed /// selection, plus each pool rule's `Count`. @@ -162,13 +162,13 @@ type Msg = | SetQuizPassingScoreText of string | SetQuizShuffleQuestions of bool | SetQuizShuffleAnswers of bool - | SelectPickerTopic of TopicId - | PickerQuestionsLoaded of QuestionSummary list - | PickerQuestionsLoadFailed of string + | ToggleTopicInPicker of TopicId + | PickerQuestionsLoaded of TopicId * QuestionSummary list + | PickerQuestionsLoadFailed of TopicId * string | ToggleQuestionPick of QuestionId - | SelectAllInTopic - | SetPoolCountText of string - | AddRandomPool + | SelectAllInTopic of TopicId + | SetPoolCountText of TopicId * string + | AddRandomPool of TopicId | RemoveRandomPool of TopicId | SubmitQuizForm | QuizSaved of QuizAdminSummary diff --git a/src/Client/Features/Teacher/Tests/View.fs b/src/Client/Features/Teacher/Tests/View.fs index d578f22..c5ff23c 100644 --- a/src/Client/Features/Teacher/Tests/View.fs +++ b/src/Client/Features/Teacher/Tests/View.fs @@ -203,16 +203,16 @@ let private resultsView (model: Model) dispatch = ] ] -/// Summary of every configured random-pool rule, across all topics — needed -/// because the picker below only shows one topic's questions at a time, so -/// without this list, switching the topic dropdown away would make an -/// already-configured pool rule invisible. +/// Summary of configured random-pool rules whose topic isn't currently +/// expanded in the picker below (e.g. right after opening an existing quiz +/// for editing) — without this, such a pool rule would be invisible until +/// its topic checkbox is ticked again. let private poolRulesSummary (model: Model) dispatch = let pools = model.QuizForm.Sources |> List.choose (function - | PoolDraft(topicId, count) -> Some(topicId, count) - | FixedDraft _ -> None) + | PoolDraft(topicId, count) when not (model.QuizForm.PickerTopicIds.Contains topicId) -> Some(topicId, count) + | _ -> None) if pools.IsEmpty then Html.none @@ -241,33 +241,33 @@ let private poolRulesSummary (model: Model) dispatch = ] ] -let private questionPicker (model: Model) dispatch = +/// One expanded topic's slice of the picker: either its fixed-question +/// checkboxes, or its random-pool config, plus the "N нет вопросов"/loading +/// states. +let private topicPickerSection (model: Model) dispatch topicId = let form = model.QuizForm - let poolForTopic = poolCountForCurrentTopic form - let fixedInTopic = fixedIdsInCurrentTopic form + let topicName = + model.Topics + |> List.tryFind (fun t -> t.Id = topicId) + |> Option.map (fun t -> t.Name) + |> Option.defaultValue "?" + + let topicQuestions = questionsInTopic form topicId + let poolCount = poolCountForTopic form topicId + let fixedInTopic = topicQuestions |> List.map (fun q -> q.Id) |> List.filter (isFixedSelected form) + let poolCountText = form.PoolCountTexts |> Map.tryFind topicId |> Option.defaultValue "" Html.div [ - prop.className "question-picker" + prop.key (string topicId) + prop.className "topic-picker-section" prop.children [ - Html.label [ prop.text "Тема" ] - Html.select [ - prop.value (form.PickerTopicId |> Option.map string |> Option.defaultValue "") - prop.onChange (fun (v: string) -> - model.Topics - |> List.tryFind (fun t -> string t.Id = v) - |> Option.iter (fun t -> dispatch (SelectPickerTopic t.Id))) - prop.children [ - Html.option [ prop.value ""; prop.text "— выберите тему —" ] - for topic in model.Topics do - Html.option [ prop.key (string topic.Id); prop.value (string topic.Id); prop.text topic.Name ] - ] - ] - if form.PickerLoading then + Html.h4 topicName + if form.PickerLoadingTopics.Contains topicId then Html.p "Загрузка вопросов…" - elif form.PickerTopicId.IsSome && form.PickerQuestions.IsEmpty then + elif topicQuestions.IsEmpty then Html.p "В этой теме нет вопросов" - elif form.PickerTopicId.IsSome then - match poolForTopic with + else + match poolCount with | Some count -> Html.div [ prop.className "pool-picker" @@ -280,18 +280,17 @@ let private questionPicker (model: Model) dispatch = Html.input [ prop.type'.text prop.className "pool-count-input" - prop.value form.PoolCountText - prop.onChange (SetPoolCountText >> dispatch) + prop.value poolCountText + prop.onChange (fun v -> dispatch (SetPoolCountText(topicId, v))) ] Html.button [ prop.type'.button - prop.onClick (fun _ -> dispatch AddRandomPool) + prop.onClick (fun _ -> dispatch (AddRandomPool topicId)) prop.text "Обновить количество" ] Html.button [ prop.type'.button - prop.onClick (fun _ -> - form.PickerTopicId |> Option.iter (RemoveRandomPool >> dispatch)) + prop.onClick (fun _ -> dispatch (RemoveRandomPool topicId)) prop.text "Убрать случайный набор, выбирать вручную" ] ] @@ -301,7 +300,7 @@ let private questionPicker (model: Model) dispatch = prop.children [ Html.div [ prop.children [ - for q in form.PickerQuestions -> + for q in topicQuestions -> Html.label [ prop.key (string q.Id) prop.className "student-row" @@ -318,7 +317,7 @@ let private questionPicker (model: Model) dispatch = ] Html.button [ prop.type'.button - prop.onClick (fun _ -> dispatch SelectAllInTopic) + prop.onClick (fun _ -> dispatch (SelectAllInTopic topicId)) prop.text "Выбрать все вопросы темы" ] Html.p "или задать случайный набор вместо ручного выбора:" @@ -326,13 +325,13 @@ let private questionPicker (model: Model) dispatch = prop.type'.text prop.className "pool-count-input" prop.placeholder "Число вопросов" - prop.value form.PoolCountText - prop.onChange (SetPoolCountText >> dispatch) + prop.value poolCountText + prop.onChange (fun v -> dispatch (SetPoolCountText(topicId, v))) ] Html.button [ prop.type'.button prop.disabled (not fixedInTopic.IsEmpty) - prop.onClick (fun _ -> dispatch AddRandomPool) + prop.onClick (fun _ -> dispatch (AddRandomPool topicId)) prop.text "Добавить случайный набор из этой темы" ] if not fixedInTopic.IsEmpty then @@ -342,8 +341,42 @@ let private questionPicker (model: Model) dispatch = ] ] ] + ] + ] + +let private questionPicker (model: Model) dispatch = + let form = model.QuizForm + + Html.div [ + prop.className "question-picker" + prop.children [ + Html.label [ prop.text "Темы (можно выбрать несколько)" ] + Html.div [ + prop.className "topic-checkbox-list" + prop.children [ + for topic in model.Topics -> + Html.label [ + prop.key (string topic.Id) + prop.className "student-row" + prop.children [ + Html.input [ + prop.type'.checkbox + prop.isChecked (form.PickerTopicIds.Contains topic.Id) + prop.onChange (fun (_: bool) -> dispatch (ToggleTopicInPicker topic.Id)) + ] + Html.text topic.Name + ] + ] + ] + ] + if form.PickerTopicIds.IsEmpty then + Html.p [ + prop.className "hint" + prop.text "Выберите одну или несколько тем, из которых будут вопросы теста" + ] else - Html.none + for topicId in form.PickerTopicIds |> Set.toList do + topicPickerSection model dispatch topicId poolRulesSummary model dispatch Html.span [ prop.className "tag-mono" 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 {