Initial commit: standalone quiz-testing system

Full-stack F# (Domain/Server/Client via Fable+Elmish+Feliz), PostgreSQL
persistence via Dapper, Docker Compose deployment. Student quiz-taking flow
with time-limit enforcement and focus-loss tracking, Teacher question bank
and quiz builder with results analytics, Admin user management.
This commit is contained in:
danamir
2026-08-06 12:36:16 +03:00
commit 942dfc9c1a
134 changed files with 10712 additions and 0 deletions

View File

@@ -0,0 +1,143 @@
module Client.Features.Quizzes.Browse.View
open Feliz
open Domain.Contracts
open Client.Shared
open Client.Features.Quizzes.Browse.Types
let private attemptRow (a: MyAttemptSummary) =
Html.li [
prop.key (string a.AttemptId)
prop.className "attempt-row"
prop.children [
Html.span [
prop.className "tag-mono"
prop.text (a.StartedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm"))
]
Html.span [
prop.className "score-value"
prop.text (sprintf "%s / %s" (Format.points a.Score) (Format.points a.MaxScore))
]
match a.Passed with
| Some true -> Html.span [ prop.className "grade-stamp passed"; prop.text "Пройден" ]
| Some false -> Html.span [ prop.className "grade-stamp failed"; prop.text "Не пройден" ]
| None -> Html.none
]
]
let private resultsPanel (model: Model) =
Html.div [
prop.className "results-panel"
prop.children [
match model.ResultsError with
| Some err -> Html.p [ prop.className "error"; prop.text err ]
| None -> Html.none
if model.ResultsLoading then
Html.p "Загрузка…"
elif model.Results.IsEmpty then
Html.p "Нет завершённых попыток"
else
Html.ul [ prop.children [ for a in model.Results -> attemptRow a ] ]
]
]
let view (model: Model) (dispatch: Msg -> unit) =
Html.div [
prop.className "quiz-list-page"
prop.children [
Html.h2 "Доступные тесты"
match model.Error with
| Some err -> Html.p [ prop.className "error"; prop.text err ]
| None -> Html.none
if model.Loading then
Html.p "Загрузка…"
elif model.Quizzes.IsEmpty then
Html.p "Тесты недоступны"
else
Html.ul [
prop.children [
for quiz in model.Quizzes ->
let remainingAttempts =
quiz.MaxAttempts |> Option.map (fun m -> System.Math.Max(0, m - quiz.AttemptsCount))
Html.li [
prop.key (string quiz.Id)
prop.className "quiz-card"
prop.children [
Html.h3 quiz.Title
Html.p quiz.Description
Html.div [
prop.className "meta-row"
prop.children [
Html.div [
prop.className "meta-item"
prop.children [
Html.span [ prop.className "meta-label"; prop.text "Баллов" ]
Html.span [
prop.className "meta-value"
prop.text (Format.points quiz.TotalPoints)
]
]
]
match quiz.TimeLimitMinutes with
| Some minutes ->
Html.div [
prop.className "meta-item"
prop.children [
Html.span [ prop.className "meta-label"; prop.text "Лимит времени" ]
Html.span [
prop.className "meta-value"
prop.text (sprintf "%d мин" minutes)
]
]
]
| None -> Html.none
match quiz.MaxAttempts, remainingAttempts with
| Some maxAttempts, Some remaining ->
Html.div [
prop.className "meta-item"
prop.children [
Html.span [ prop.className "meta-label"; prop.text "Осталось попыток" ]
Html.span [
prop.className "meta-value"
prop.text (sprintf "%d из %d" remaining maxAttempts)
]
]
]
| _ -> Html.none
]
]
if quiz.AlreadyPassed then
Html.span [ prop.className "status-badge active"; prop.text "Тест пройден успешно" ]
elif remainingAttempts = Some 0 then
Html.p [ prop.className "hint"; prop.text "Попытки исчерпаны" ]
else
Html.button [
prop.disabled (model.StartingQuizId = Some quiz.Id)
prop.onClick (fun _ -> dispatch (Start quiz.Id))
prop.text (
if model.StartingQuizId = Some quiz.Id then
"Запуск…"
else
"Начать тест"
)
]
if quiz.AttemptsCount > 0 then
Html.button [
prop.type'.button
prop.onClick (fun _ -> dispatch (ToggleResults quiz.Id))
prop.text (
if model.ExpandedResultsFor = Some quiz.Id then
"Скрыть результаты"
else
sprintf "Посмотреть результаты (%d)" quiz.AttemptsCount
)
]
if model.ExpandedResultsFor = Some quiz.Id then
resultsPanel model
]
]
]
]
]
]