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:
12
src/Client/Shared/Format.fs
Normal file
12
src/Client/Shared/Format.fs
Normal 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
|
||||
71
src/Client/Shared/JsonWire.fs
Normal file
71
src/Client/Shared/JsonWire.fs
Normal 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)
|
||||
46
src/Client/Shared/SessionStorage.fs
Normal file
46
src/Client/Shared/SessionStorage.fs
Normal 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
|
||||
Reference in New Issue
Block a user