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,12 @@
module Client.Shared.Format
/// Whole-numbered points render without a decimal point ("3"); anything with
/// a fractional part renders with one decimal place ("2.5") points are
/// usually whole in this app, but `AverageAttempt` grading or a partial-point
/// question config can produce a fractional value that a blanket "%.0f"
/// would otherwise silently round away.
let points (value: float) : string =
if value = System.Math.Round value then
sprintf "%.0f" value
else
sprintf "%.1f" value

View File

@@ -0,0 +1,71 @@
module Client.Shared.JsonWire
// Fable.Remoting.Client pulls in Fable.Remoting.MsgPack, whose `inline`
// helpers reference private functions in a way the current Fable compiler
// rejects at build time. Until that's fixed upstream, each feature's `Api.fs`
// talks to its own plain Giraffe route by hand, using the helpers below to
// POST/GET JSON and decode the {"Ok": ...} / {"Error": ...} shape
// Fable.Remoting.Json already produces on the server (see `Server/Json.fs`).
open Fable.Core
open Fable.Core.JsInterop
open Domain
let private serverUrl = "http://localhost:5144"
[<Emit("Object.prototype.hasOwnProperty.call($0, $1)")>]
let hasKey (_o: obj) (_key: string) : bool = jsNative
[<Emit("fetch($0, $1).then(r => r.json())")>]
let private fetchJson (_url: string) (_init: obj) : JS.Promise<obj> = jsNative
/// `body = None` for GET-style calls with no request payload.
let callApi (token: string option) (httpMethod: string) (path: string) (body: obj option) : Async<obj> =
async {
let headers =
match token with
| Some t -> createObj [ "Content-Type" ==> "application/json"; "Authorization" ==> ("Bearer " + t) ]
| None -> createObj [ "Content-Type" ==> "application/json" ]
let baseFields = [ "method" ==> httpMethod; "headers" ==> headers ]
let fields =
match body with
| Some b -> baseFields @ [ "body" ==> JS.JSON.stringify b ]
| None -> baseFields
return! fetchJson (serverUrl + path) (createObj fields) |> Async.AwaitPromise
}
// ---- Id encode/decode: wire shape is {"CaseName": "<guid>"} ----
let encQuizId (QuizId g) : obj = createObj [ "QuizId" ==> string g ]
let encTopicId (TopicId g) : obj = createObj [ "TopicId" ==> string g ]
let encUserId (UserId g) : obj = createObj [ "UserId" ==> string g ]
let encAttemptId (AttemptId g) : obj = createObj [ "AttemptId" ==> string g ]
let encQuestionId (QuestionId g) : obj = createObj [ "QuestionId" ==> string g ]
let encOptionId (OptionId g) : obj = createObj [ "OptionId" ==> string g ]
let decUserId (o: obj) : UserId = UserId(System.Guid.Parse(o?UserId: string))
let decTopicId (o: obj) : TopicId = TopicId(System.Guid.Parse(o?TopicId: string))
let decQuizId (o: obj) : QuizId = QuizId(System.Guid.Parse(o?QuizId: string))
let decQuestionId (o: obj) : QuestionId = QuestionId(System.Guid.Parse(o?QuestionId: string))
let decOptionId (o: obj) : OptionId = OptionId(System.Guid.Parse(o?OptionId: string))
let decAttemptId (o: obj) : AttemptId = AttemptId(System.Guid.Parse(o?AttemptId: string))
let optToJs (mapper: 'a -> obj) (opt: 'a option) : obj =
match opt with
| Some x -> mapper x
| None -> null
let optDec (mapper: obj -> 'a) (raw: obj) : 'a option =
if isNullOrUndefined raw then None else Some(mapper raw)
let decodeRole (raw: obj) : Role =
match unbox<string> raw with
| "Admin" -> Admin
| "Teacher" -> Teacher
| "Student" -> Student
| other -> failwithf "Неизвестная роль: %s" other
let decodeResult (decodeOk: obj -> 'a) (raw: obj) : Result<'a, string> =
if hasKey raw "Ok" then Ok(decodeOk raw?Ok) else Error(unbox<string> raw?Error)

View File

@@ -0,0 +1,46 @@
module Client.Shared.SessionStorage
// Without this, the whole session lives only in the in-memory Elmish Model
// any page refresh (F5, or reopening the tab) resets `Model.Session` to
// `None` and drops the user back to the login screen, no matter which page
// they were on. Persisting the JWT to localStorage and restoring it on
// `App.State.init` fixes that for every page, not just one.
open Fable.Core
open Fable.Core.JsInterop
open Browser.WebStorage
open Domain.Contracts
open Client.Shared.JsonWire
let private storageKey = "quizsystem.session"
let save (session: LoginResponse) : unit =
let raw =
createObj [
"Token" ==> session.Token
"UserId" ==> encUserId session.UserId
"Name" ==> session.Name
"Role" ==> box (string session.Role)
]
localStorage.setItem (storageKey, JS.JSON.stringify raw)
let clear () : unit = localStorage.removeItem storageKey
/// `None` on a first visit, a cleared/missing entry, or anything that fails
/// to parse (e.g. a stale shape from a previous version of this app) any
/// of those should just fall through to the login page, not crash the app
/// on load.
let tryLoad () : LoginResponse option =
match localStorage.getItem storageKey with
| null -> None
| json ->
try
let raw = JS.JSON.parse json
Some
{ Token = raw?Token
UserId = decUserId raw?UserId
Name = raw?Name
Role = decodeRole raw?Role }
with _ ->
None