first version
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
DATABASE_URL=sqlite:///chemin/absolu/vers/quiz.db
|
||||||
|
SESSION_PASSWORD=changeme
|
||||||
|
ADMIN_PASSWORD=admin
|
||||||
|
LEPTOS_TAILWIND_VERSION=v4.1.13
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
debug/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||||
|
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
.env
|
||||||
|
quiz.db
|
||||||
|
*.db
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
[package]
|
||||||
|
name = "enuxia-quiz"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "rlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
leptos = { version = "0.8.0" }
|
||||||
|
leptos_router = { version = "0.8.0" }
|
||||||
|
axum = { version = "0.8.0", optional = true }
|
||||||
|
console_error_panic_hook = { version = "0.1", optional = true }
|
||||||
|
leptos_axum = { version = "0.8.0", optional = true }
|
||||||
|
leptos_meta = { version = "0.8.0" }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }
|
||||||
|
wasm-bindgen = { version = "0.2.106", optional = true }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = { version = "1", optional = true }
|
||||||
|
anyhow = { version = "1", optional = true }
|
||||||
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono"], optional = true }
|
||||||
|
axum-extra = { version = "0.12.5", features = ["cookie", "cookie-signed"], optional = true }
|
||||||
|
time = { version = "0.3", optional = true }
|
||||||
|
web-sys = { version = "0.3", features = ["Window", "HtmlInputElement"], optional = true }
|
||||||
|
dotenvy = { version = "0.15", optional = true }
|
||||||
|
gloo-timers = { version = "0.4.0", features = ["futures"], optional = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
hydrate = [
|
||||||
|
"leptos/hydrate",
|
||||||
|
"dep:console_error_panic_hook",
|
||||||
|
"dep:wasm-bindgen",
|
||||||
|
"dep:gloo-timers",
|
||||||
|
]
|
||||||
|
ssr = [
|
||||||
|
"dep:axum",
|
||||||
|
"dep:tokio",
|
||||||
|
"dep:leptos_axum",
|
||||||
|
"leptos/ssr",
|
||||||
|
"leptos_meta/ssr",
|
||||||
|
"leptos_router/ssr",
|
||||||
|
"dep:sqlx",
|
||||||
|
"dep:anyhow",
|
||||||
|
"dep:axum-extra",
|
||||||
|
"dep:time",
|
||||||
|
"dep:web-sys",
|
||||||
|
"dep:dotenvy",
|
||||||
|
"dep:serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Defines a size-optimized profile for the WASM bundle in release mode
|
||||||
|
[profile.wasm-release]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = 'z'
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
[package.metadata.leptos]
|
||||||
|
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||||
|
output-name = "enuxia-quiz"
|
||||||
|
|
||||||
|
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||||
|
site-root = "target/site"
|
||||||
|
|
||||||
|
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||||
|
# Defaults to pkg
|
||||||
|
site-pkg-dir = "pkg"
|
||||||
|
|
||||||
|
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/enuxia-quiz.css
|
||||||
|
style-file = "style/main.css"
|
||||||
|
# Assets source dir. All files found here will be copied and synchronized to site-root.
|
||||||
|
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
|
||||||
|
#
|
||||||
|
# Optional. Env: LEPTOS_ASSETS_DIR.
|
||||||
|
tailwind-input-file = "style/main.css"
|
||||||
|
tailwind-config-file = "tailwind.config.js"
|
||||||
|
assets-dir = "public"
|
||||||
|
|
||||||
|
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||||
|
site-addr = "127.0.0.1:3000"
|
||||||
|
|
||||||
|
# The port to use for automatic reload monitoring
|
||||||
|
reload-port = 3001
|
||||||
|
|
||||||
|
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||||
|
# [Windows] for non-WSL use "npx.cmd playwright test"
|
||||||
|
# This binary name can be checked in Powershell with Get-Command npx
|
||||||
|
end2end-cmd = "npx playwright test"
|
||||||
|
end2end-dir = "end2end"
|
||||||
|
|
||||||
|
# The browserlist query used for optimizing the CSS.
|
||||||
|
browserquery = "defaults"
|
||||||
|
|
||||||
|
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||||
|
env = "DEV"
|
||||||
|
|
||||||
|
# The features to use when compiling the bin target
|
||||||
|
#
|
||||||
|
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||||
|
bin-features = ["ssr"]
|
||||||
|
|
||||||
|
# If the --no-default-features flag should be used when compiling the bin target
|
||||||
|
#
|
||||||
|
# Optional. Defaults to false.
|
||||||
|
bin-default-features = false
|
||||||
|
|
||||||
|
# The features to use when compiling the lib target
|
||||||
|
#
|
||||||
|
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||||
|
lib-features = ["hydrate"]
|
||||||
|
|
||||||
|
# If the --no-default-features flag should be used when compiling the lib target
|
||||||
|
#
|
||||||
|
# Optional. Defaults to false.
|
||||||
|
lib-default-features = false
|
||||||
|
|
||||||
|
# The profile to use for the lib target when compiling for release
|
||||||
|
#
|
||||||
|
# Optional. Defaults to "release".
|
||||||
|
lib-profile-release = "wasm-release"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# Enuxia Quiz
|
||||||
|
|
||||||
|
Application de quiz pédagogique **souveraine et auto-hébergée**, construite en Rust full-stack.
|
||||||
|
|
||||||
|
Conçue pour un usage en présentiel (cours, examens): les étudiants passent le quiz depuis leur navigateur, le professeur consulte les résultats et les statistiques depuis une interface d'administration protégée.
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Composant | Technologie |
|
||||||
|
|-----------|-------------|
|
||||||
|
| Frontend + Backend | Leptos 0.8 SSR + Axum |
|
||||||
|
| Base de données | SQLite via SQLx |
|
||||||
|
| CSS | Tailwind v4 |
|
||||||
|
| Compilation WASM | wasm-bindgen |
|
||||||
|
| Déploiement cible | Raspberry Pi 5 (ARM64) |
|
||||||
|
| Reverse proxy | Traefik |
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
### Côté étudiant
|
||||||
|
- Accès protégé par un code de session distribué le jour J
|
||||||
|
- Sélection du quiz, saisie du prénom et nom
|
||||||
|
- Navigation avant/arrière entre les questions
|
||||||
|
- Minuteur optionnel avec soumission automatique
|
||||||
|
- Correction détaillée avec les bonnes réponses après soumission
|
||||||
|
|
||||||
|
### Côté administration
|
||||||
|
- Création et édition de quiz (questions, réponses, options)
|
||||||
|
- Ordre aléatoire des questions et/ou des réponses par quiz
|
||||||
|
- Blocage anti-doublon — un étudiant ne peut pas repasser sans autorisation
|
||||||
|
- Reset individuel — le professeur autorise une nouvelle tentative
|
||||||
|
- Statistiques — moyenne, distribution des scores, taux de réussite par question
|
||||||
|
- Export CSV des résultats
|
||||||
|
- Configuration des codes d'accès en live sans recompilation
|
||||||
|
|
||||||
|
### Sécurité
|
||||||
|
- Interface admin accessible uniquement via VPN (conseillé)
|
||||||
|
- Cookies de session httponly
|
||||||
|
- Normalisation des noms (lowercase + trim) pour l'anti-doublon
|
||||||
|
|
||||||
|
## Architecture de déploiement optimale
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Traefik Gateway (VM OVH)
|
||||||
|
├── quiz.enuxia.fr → étudiants (public)
|
||||||
|
└── admin-quiz.enuxia.fr → professeur (VPN uniquement)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Raspberry Pi 5
|
||||||
|
└── enuxia-quiz (binaire Rust)
|
||||||
|
└── quiz.db (SQLite local)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prérequis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
cargo install cargo-leptos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Développement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.enuxia.fr/Enuxia-Public/enuxia-quiz
|
||||||
|
cd enuxia-quiz
|
||||||
|
cp .env.example .env
|
||||||
|
# Éditez .env avec vos valeurs
|
||||||
|
touch quiz.db
|
||||||
|
cargo leptos serve
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production (binaire)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo leptos build --release
|
||||||
|
# Le binaire est dans target/release/enuxia-quiz
|
||||||
|
# Les assets sont dans target/site/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Créez un fichier `.env` à la racine :
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL=sqlite:///chemin/absolu/vers/quiz.db
|
||||||
|
SESSION_PASSWORD=code_distribué_aux_étudiants
|
||||||
|
ADMIN_PASSWORD=mot_de_passe_admin
|
||||||
|
LEPTOS_TAILWIND_VERSION=v4.1.13
|
||||||
|
```
|
||||||
|
|
||||||
|
Les mots de passe sont également modifiables en live depuis `/admin/config` sans redémarrer l'application.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT — libre d'utilisation, de modification et de déploiement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Construit par [Enuxia](https://enuxia.fr) — systèmes intelligents souverains sur mesure.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
playwright-report
|
||||||
|
test-results
|
||||||
Generated
+167
@@ -0,0 +1,167 @@
|
|||||||
|
{
|
||||||
|
"name": "end2end",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 2,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "end2end",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.44.1",
|
||||||
|
"@types/node": "^20.12.12",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.44.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
|
||||||
|
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.44.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.12.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
|
||||||
|
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.44.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
|
||||||
|
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.44.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.44.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
|
||||||
|
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||||
|
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@playwright/test": {
|
||||||
|
"version": "1.44.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
|
||||||
|
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"playwright": "1.44.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"version": "20.12.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
|
||||||
|
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"undici-types": "~5.26.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"playwright": {
|
||||||
|
"version": "1.44.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
|
||||||
|
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"fsevents": "2.3.2",
|
||||||
|
"playwright-core": "1.44.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"playwright-core": {
|
||||||
|
"version": "1.44.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
|
||||||
|
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"version": "5.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||||
|
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"undici-types": {
|
||||||
|
"version": "5.26.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||||
|
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "end2end",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
|
"typescript": "^6.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { PlaywrightTestConfig } from "@playwright/test";
|
||||||
|
import { devices, defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// require('dotenv').config();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests",
|
||||||
|
/* Maximum time one test can run for. */
|
||||||
|
timeout: 30 * 1000,
|
||||||
|
expect: {
|
||||||
|
/**
|
||||||
|
* Maximum time expect() should wait for the condition to be met.
|
||||||
|
* For example in `await expect(locator).toHaveText();`
|
||||||
|
*/
|
||||||
|
timeout: 5000,
|
||||||
|
},
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: "html",
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||||
|
actionTimeout: 0,
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: "on-first-retry",
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "firefox",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Firefox"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: "webkit",
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Safari"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: {
|
||||||
|
// ...devices['Pixel 5'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: {
|
||||||
|
// ...devices['iPhone 12'],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: {
|
||||||
|
// channel: 'msedge',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: {
|
||||||
|
// channel: 'chrome',
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||||
|
// outputDir: 'test-results/',
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// port: 3000,
|
||||||
|
// },
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test("homepage has title and heading text", async ({ page }) => {
|
||||||
|
await page.goto("http://localhost:3000/");
|
||||||
|
|
||||||
|
await expect(page).toHaveTitle("Welcome to Leptos");
|
||||||
|
|
||||||
|
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
|
||||||
|
});
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||||
|
|
||||||
|
/* Projects */
|
||||||
|
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||||
|
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||||
|
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||||
|
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||||
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
|
|
||||||
|
/* Language and Environment */
|
||||||
|
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||||
|
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
|
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
|
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||||
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||||
|
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||||
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||||
|
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||||
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
|
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||||
|
|
||||||
|
/* Modules */
|
||||||
|
"module": "commonjs", /* Specify what module code is generated. */
|
||||||
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
|
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
|
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
|
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||||
|
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||||
|
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||||
|
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||||
|
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||||
|
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||||
|
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||||
|
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||||
|
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
|
||||||
|
/* JavaScript Support */
|
||||||
|
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||||
|
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||||
|
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||||
|
|
||||||
|
/* Emit */
|
||||||
|
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||||
|
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||||
|
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||||
|
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||||
|
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||||
|
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||||
|
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||||
|
// "removeComments": true, /* Disable emitting comments. */
|
||||||
|
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||||
|
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||||
|
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||||
|
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||||
|
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
|
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||||
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||||
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||||
|
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||||
|
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||||
|
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||||
|
|
||||||
|
/* Interop Constraints */
|
||||||
|
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||||
|
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||||
|
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||||
|
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||||
|
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||||
|
|
||||||
|
/* Type Checking */
|
||||||
|
"strict": true, /* Enable all strict type-checking options. */
|
||||||
|
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||||
|
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||||
|
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||||
|
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||||
|
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||||
|
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||||
|
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||||
|
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||||
|
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||||
|
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||||
|
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||||
|
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||||
|
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||||
|
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||||
|
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||||
|
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||||
|
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||||
|
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||||
|
|
||||||
|
/* Completeness */
|
||||||
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?><svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 75.83 102.78"><defs><style>.cls-1{fill:url(#Nouvelle_nuance_de_dégradé_1_2);}.cls-2{fill:#fff;}.cls-3{fill:url(#Dégradé_sans_nom_10);}</style><linearGradient id="Nouvelle_nuance_de_dégradé_1_2" x1="2.13" y1="44.89" x2="70.39" y2="44.89" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1e90ff"/><stop offset=".88" stop-color="#8b5cf6"/></linearGradient><linearGradient id="Dégradé_sans_nom_10" x1="7.43" y1="69.85" x2="64.02" y2="69.85" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1e90ff"/><stop offset=".88" stop-color="#8b5cf6"/></linearGradient></defs><path class="cls-1" d="M68.17,0c-.76,4.39-2.46,9.91-6.23,15.24-4.74,6.7-10.65,10.14-14.29,12.21-2.09,1.18-5.07,2.43-11.04,4.93-7.83,3.28-9.14,3.42-13.36,5.58-4.22,2.17-7.3,3.75-10.54,6.75-5.99,5.56-8.3,12.07-8.96,14.16-1.23,3.86-3.24,12.62.65,21.95,1.5,3.61,4.15,7.28,5.58,8.96-1.05-2.27-3.6-8.09-3.12-14.29.6-7.74,4.45-12.82,5.19-13.77,2.89-3.7,6.13-5.56,9.09-7.27,2.91-1.68,4.53-2.14,11.56-4.68,8.02-2.9,12.03-4.35,14.41-5.47,5.32-2.51,9.5-4.48,13.64-8.68,5.66-5.75,7.72-12.12,8.31-14.16,2.9-9.98.23-18.42-.91-21.47"/><path class="cls-3" d="M64.02,40.04s-3.72,5.03-11.17,9.74c-3.18,2.01-6,3.04-8.7,4.03-5.18,1.89-6.27,1.47-13.38,3.51-5.43,1.56-8.14,2.34-11.3,4.16-2.8,1.62-6.44,3.77-9.09,8.18-.55.91-2.51,4.34-2.86,9.22-.15,2.2-.49,7.04,2.47,10.91.45.59,1.25,1.3,2.86,2.73.98.87,3.45,3,7.14,5.32.53.33,3.41,1.82,3.41,1.82,0,0-5.77-3.21-8.21-9.22-.45-1.11-1.64-4.16-.78-7.79,1.17-4.9,5.28-7.47,6.62-8.31,1.73-1.08,3.17-1.52,5.97-2.34,4.79-1.4,7.81-1.68,10.26-2.13,1.93-.36,9.29-1.8,15.84-7.09,1.12-.9,5.79-4.8,8.83-11.6,2.05-4.59,2.08-11.12,2.08-11.12Z"/><path class="cls-2" d="M64.02,72.12s3.06-.36,6.92-.26c2.07.05,3.77.17,4.9.26-1.02,2.72-3.85,9.14-10.65,14.03-1.38.99-5.84,4.02-12.47,5.19-2.38.42-9.48,1.95-17.79-2.08-6.17-2.99-10.46-7.17-10.26-7.45.15-.22,2.77,2.18,7.27,3.69,6.57,2.2,12.46.93,14.29.52,3.39-.77,7.59-2.46,11.11-5.25,3.98-3.15,6.68-8.64,6.68-8.64Z"/><path class="cls-2" d="M18.43,79.91s2.96,4.55,9.09,9.35c4.85,3.8,9.83,5.05,12.03,5.58,1.89.46,6.77,1.59,12.38.65,11.31-1.89,18.45-10.13,18.45-10.13,0,0-3.86,5.8-10.79,10.47-2.99,2.01-8.08,5.37-15.58,6.5-2.58.39-7.99,1.14-14.29-1-3.1-1.05-6.59-2.75-9.74-6.1-6.23-6.62-1.56-15.32-1.56-15.32Z"/><path class="cls-2" d="M46.57,24.43s-6.81-1.21-11.87-.39c-4.61.74-10.03,1.61-16.04,5.35-6.72,4.17-10.23,9.46-11.87,12-1.71,2.64-4.35,7.37-5.74,13.83-.67,3.12-1.97,9.48,0,17.22.92,3.64,3.36,8.38,3.36,8.38-1.44-3.73-3.49-10.64-2.28-19.08.34-2.38,1.09-7.23,4.26-12.52,2.78-4.64,6.06-7.33,8.35-9.18,5.41-4.36,10.86-6.51,14.74-8.04,2.96-1.17-.52.2,8.48-3.13,5.17-1.92,8.61-4.43,8.61-4.43Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
+57
@@ -0,0 +1,57 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
|
||||||
|
use leptos_router::{
|
||||||
|
components::{Route, Router, Routes},
|
||||||
|
path,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||||
|
view! {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png"/>
|
||||||
|
<AutoReload options=options.clone() />
|
||||||
|
<HydrationScripts options/>
|
||||||
|
<MetaTags/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<App/>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn App() -> impl IntoView {
|
||||||
|
provide_meta_context();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Stylesheet id="leptos" href="/pkg/enuxia-quiz.css"/>
|
||||||
|
<Title text="Enuxia Quiz"/>
|
||||||
|
<Router>
|
||||||
|
<main>
|
||||||
|
<Routes fallback=|| "Page introuvable.".into_view()>
|
||||||
|
// ── Auth ─────────────────────────────────────────
|
||||||
|
<Route path=path!("/login") view=crate::pages::login::LoginPage/>
|
||||||
|
<Route path=path!("/admin/login") view=crate::pages::login::AdminLoginPage/>
|
||||||
|
<Route path=path!("/admin/config") view=crate::pages::admin::config::ConfigPage/>
|
||||||
|
// ── Étudiant ──────────────────────────────────────
|
||||||
|
<Route path=path!("/") view=crate::pages::student::home::HomePage/>
|
||||||
|
<Route path=path!("/quiz/:quiz_id") view=crate::pages::student::quiz::QuizPage/>
|
||||||
|
<Route path=path!("/result/:submission_id") view=crate::pages::student::result::ResultPage/>
|
||||||
|
|
||||||
|
// ── Admin ─────────────────────────────────────────
|
||||||
|
<Route path=path!("/admin") view=crate::pages::admin::dashboard::DashboardPage/>
|
||||||
|
<Route path=path!("/admin/quiz/new") view=crate::pages::admin::quiz_edit::QuizEditPage/>
|
||||||
|
<Route path=path!("/admin/quiz/:quiz_id/edit") view=crate::pages::admin::quiz_edit::QuizEditPage/>
|
||||||
|
<Route path=path!("/admin/quiz/:quiz_id/results") view=crate::pages::admin::quiz_results::QuizResultsPage/>
|
||||||
|
<Route path=path!("/admin/quiz/:quiz_id/stats") view=crate::pages::admin::quiz_stats::QuizStatsPage/>
|
||||||
|
<Route path=path!("/admin/submission/:submission_id") view=crate::pages::admin::student_detail::StudentDetailPage/>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</Router>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub async fn init_db(database_url: &str) -> Result<SqlitePool> {
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(5)
|
||||||
|
.connect(database_url)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Tables de base
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS quizzes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
active BOOLEAN NOT NULL DEFAULT 1,
|
||||||
|
shuffle_questions BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
shuffle_answers BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
time_limit_seconds INTEGER DEFAULT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
quiz_id INTEGER NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
section TEXT NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS answers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
correct BOOLEAN NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS submissions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
quiz_id INTEGER NOT NULL REFERENCES quizzes(id),
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total INTEGER NOT NULL DEFAULT 0,
|
||||||
|
submitted_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS student_answers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
submission_id INTEGER NOT NULL REFERENCES submissions(id) ON DELETE CASCADE,
|
||||||
|
question_id INTEGER NOT NULL,
|
||||||
|
answer_id INTEGER NOT NULL,
|
||||||
|
correct BOOLEAN NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS resets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
quiz_id INTEGER NOT NULL,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
used BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
INSERT OR IGNORE INTO config (key, value) VALUES ('session_password', 'changeme');
|
||||||
|
INSERT OR IGNORE INTO config (key, value) VALUES ('admin_password', 'admin');
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Migrations pour base existante
|
||||||
|
for migration in [
|
||||||
|
"ALTER TABLE quizzes ADD COLUMN shuffle_questions BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE quizzes ADD COLUMN shuffle_answers BOOLEAN NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE quizzes ADD COLUMN time_limit_seconds INTEGER DEFAULT NULL",
|
||||||
|
] {
|
||||||
|
sqlx::query(migration).execute(&pool).await.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod models;
|
||||||
|
pub mod pages;
|
||||||
|
pub mod server;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod db;
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub mod middleware;
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||||
|
pub fn hydrate() {
|
||||||
|
use crate::app::*;
|
||||||
|
console_error_panic_hook::set_once();
|
||||||
|
leptos::mount::hydrate_body(App);
|
||||||
|
}
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
use axum::{Router, Extension};
|
||||||
|
use leptos::logging::log;
|
||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_axum::{generate_route_list, LeptosRoutes};
|
||||||
|
use enuxia_quiz::app::*;
|
||||||
|
use enuxia_quiz::db::init_db;
|
||||||
|
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
|
let database_url = std::env::var("DATABASE_URL")
|
||||||
|
.unwrap_or_else(|_| "sqlite://quiz.db".to_string());
|
||||||
|
|
||||||
|
let pool: sqlx::SqlitePool = init_db(&database_url).await
|
||||||
|
.expect("Impossible d'initialiser la base de données");
|
||||||
|
|
||||||
|
log!("Base de données initialisée : {}", database_url);
|
||||||
|
|
||||||
|
let conf = get_configuration(None).unwrap();
|
||||||
|
let addr = conf.leptos_options.site_addr;
|
||||||
|
let leptos_options = conf.leptos_options;
|
||||||
|
|
||||||
|
let routes = generate_route_list(App);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.leptos_routes(&leptos_options, routes, {
|
||||||
|
let leptos_options = leptos_options.clone();
|
||||||
|
move || shell(leptos_options.clone())
|
||||||
|
})
|
||||||
|
.fallback(leptos_axum::file_and_error_handler(shell))
|
||||||
|
.layer(Extension(pool))
|
||||||
|
.with_state(leptos_options);
|
||||||
|
|
||||||
|
log!("listening on http://{}", &addr);
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
|
axum::serve(listener, app.into_make_service())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "ssr"))]
|
||||||
|
pub fn main() {}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::Request,
|
||||||
|
middleware::Next,
|
||||||
|
response::{IntoResponse, Redirect, Response},
|
||||||
|
};
|
||||||
|
use axum_extra::extract::CookieJar;
|
||||||
|
|
||||||
|
pub const SESSION_COOKIE: &str = "eq_session";
|
||||||
|
pub const ADMIN_COOKIE: &str = "eq_admin";
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub async fn check_session() -> bool {
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use axum::Extension;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Ok(jar): Result<CookieJar, _> = extract().await else { return false };
|
||||||
|
let Ok(Extension(pool)): Result<Extension<SqlitePool>, _> = extract().await else { return false };
|
||||||
|
|
||||||
|
let expected = crate::server::get_config_value(&pool, "session_password", "changeme").await;
|
||||||
|
|
||||||
|
jar.get(SESSION_COOKIE)
|
||||||
|
.map(|c| c.value() == expected)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub async fn check_admin() -> bool {
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use axum::Extension;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Ok(jar): Result<CookieJar, _> = extract().await else { return false };
|
||||||
|
let Ok(Extension(pool)): Result<Extension<SqlitePool>, _> = extract().await else { return false };
|
||||||
|
|
||||||
|
let expected = crate::server::get_config_value(&pool, "admin_password", "admin").await;
|
||||||
|
|
||||||
|
jar.get(ADMIN_COOKIE)
|
||||||
|
.map(|c| c.value() == expected)
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
|
pub struct Quiz {
|
||||||
|
pub id: i64,
|
||||||
|
pub title: String,
|
||||||
|
pub description: String,
|
||||||
|
pub active: bool,
|
||||||
|
pub shuffle_questions: bool,
|
||||||
|
pub shuffle_answers: bool,
|
||||||
|
pub time_limit_seconds: Option<i64>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
|
pub struct Question {
|
||||||
|
pub id: i64,
|
||||||
|
pub quiz_id: i64,
|
||||||
|
pub text: String,
|
||||||
|
pub section: String,
|
||||||
|
pub position: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
|
pub struct Answer {
|
||||||
|
pub id: i64,
|
||||||
|
pub question_id: i64,
|
||||||
|
pub label: String,
|
||||||
|
pub text: String,
|
||||||
|
pub correct: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuestionWithAnswers {
|
||||||
|
pub question: Question,
|
||||||
|
pub answers: Vec<Answer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
|
pub struct QuizSubmission {
|
||||||
|
pub id: i64,
|
||||||
|
pub quiz_id: i64,
|
||||||
|
pub first_name: String,
|
||||||
|
pub last_name: String,
|
||||||
|
pub score: i64,
|
||||||
|
pub total: i64,
|
||||||
|
pub submitted_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
|
pub struct SubmissionAnswer {
|
||||||
|
pub id: i64,
|
||||||
|
pub submission_id: i64,
|
||||||
|
pub question_id: i64,
|
||||||
|
pub answer_id: i64,
|
||||||
|
pub correct: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ResultDetail {
|
||||||
|
pub question_text: String,
|
||||||
|
pub section: String,
|
||||||
|
pub chosen_label: String,
|
||||||
|
pub chosen_text: String,
|
||||||
|
pub correct_label: String,
|
||||||
|
pub correct_text: String,
|
||||||
|
pub is_correct: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))]
|
||||||
|
pub struct Reset {
|
||||||
|
pub id: i64,
|
||||||
|
pub quiz_id: i64,
|
||||||
|
pub first_name: String,
|
||||||
|
pub last_name: String,
|
||||||
|
pub used: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuizStats {
|
||||||
|
pub total_submissions: i64,
|
||||||
|
pub average_score: f64,
|
||||||
|
pub average_pct: f64,
|
||||||
|
pub score_distribution: Vec<(i64, i64)>,
|
||||||
|
pub question_stats: Vec<QuestionStat>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuestionStat {
|
||||||
|
pub question_id: i64,
|
||||||
|
pub question_text: String,
|
||||||
|
pub section: String,
|
||||||
|
pub correct_count: i64,
|
||||||
|
pub total_count: i64,
|
||||||
|
pub success_rate: f64,
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use crate::server::{check_admin_valid, get_config, set_config};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ConfigPage() -> impl IntoView {
|
||||||
|
let session = Resource::new(|| (), |_| check_admin_valid());
|
||||||
|
|
||||||
|
let session_pwd = RwSignal::new(String::new());
|
||||||
|
let admin_pwd = RwSignal::new(String::new());
|
||||||
|
let saved = RwSignal::new(false);
|
||||||
|
|
||||||
|
// Charge les valeurs actuelles
|
||||||
|
let current_session = Resource::new(|| (), |_| get_config("session_password".to_string()));
|
||||||
|
let current_admin = Resource::new(|| (), |_| get_config("admin_password".to_string()));
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(v)) = current_session.get() {
|
||||||
|
session_pwd.set(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(v)) = current_admin.get() {
|
||||||
|
admin_pwd.set(v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let save_action = Action::new(move |_: &()| {
|
||||||
|
let sp = session_pwd.get();
|
||||||
|
let ap = admin_pwd.get();
|
||||||
|
async move {
|
||||||
|
let _ = set_config("session_password".to_string(), sp).await;
|
||||||
|
let _ = set_config("admin_password".to_string(), ap).await;
|
||||||
|
saved.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Suspense fallback=|| view! { <div class="admin-page"><p>"Vérification..."</p></div> }>
|
||||||
|
{move || session.get().map(|result| {
|
||||||
|
match result {
|
||||||
|
Ok(true) => view! {
|
||||||
|
<div class="admin-page">
|
||||||
|
<div class="admin-topbar">
|
||||||
|
<div>
|
||||||
|
<h1 style="
|
||||||
|
background:linear-gradient(135deg,#4F8EFF,#8B5CF6);
|
||||||
|
-webkit-background-clip:text;
|
||||||
|
-webkit-text-fill-color:transparent;
|
||||||
|
background-clip:text;
|
||||||
|
">"Configuration"</h1>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);margin-top:2px;">
|
||||||
|
"Modifiez les codes d'accès sans recompiler l'application."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
{move || saved.get().then(|| view! {
|
||||||
|
<span style="font-size:13px;font-weight:600;color:var(--color-enuxia-green);">
|
||||||
|
"✓ Sauvegardé"
|
||||||
|
</span>
|
||||||
|
})}
|
||||||
|
<a href="/admin" class="btn-ghost">"← Dashboard"</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="quiz-meta-card">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<label class="field-label">"Code de session étudiant"</label>
|
||||||
|
<p style="font-size:12px;color:var(--color-enuxia-muted);">
|
||||||
|
"Les étudiants entrent ce code pour accéder aux quiz."
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Code de session"
|
||||||
|
prop:value=move || session_pwd.get()
|
||||||
|
on:input=move |e| {
|
||||||
|
session_pwd.set(event_target_value(&e));
|
||||||
|
saved.set(false);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="height:1px;background:var(--color-enuxia-border);"/>
|
||||||
|
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<label class="field-label">"Mot de passe administrateur"</label>
|
||||||
|
<p style="font-size:12px;color:var(--color-enuxia-muted);">
|
||||||
|
"Votre mot de passe pour accéder à l'interface d'administration."
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Mot de passe admin"
|
||||||
|
prop:value=move || admin_pwd.get()
|
||||||
|
on:input=move |e| {
|
||||||
|
admin_pwd.set(event_target_value(&e));
|
||||||
|
saved.set(false);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p style="font-size:12px;color:#f87171;">
|
||||||
|
"⚠ Attention : après modification, vous devrez vous reconnecter."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="save-row">
|
||||||
|
<span/>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
on:click=move |_| { save_action.dispatch(()); }
|
||||||
|
>
|
||||||
|
"Sauvegarder"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! {
|
||||||
|
<script>"window.location.href='/admin/login';"</script>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::web_sys;
|
||||||
|
use crate::server::{check_admin_valid, admin::{get_all_quizzes, delete_quiz}};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn DashboardPage() -> impl IntoView {
|
||||||
|
let session = Resource::new(|| (), |_| check_admin_valid());
|
||||||
|
let refresh = RwSignal::new(0u32);
|
||||||
|
|
||||||
|
let delete_action = Action::new(move |id: &i64| {
|
||||||
|
let id = *id;
|
||||||
|
async move {
|
||||||
|
let _ = delete_quiz(id).await;
|
||||||
|
refresh.update(|r| *r += 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let quizzes = Resource::new(
|
||||||
|
move || refresh.get(),
|
||||||
|
|_| get_all_quizzes()
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<div class="admin-page">
|
||||||
|
<p style="color:var(--color-enuxia-muted);">"Vérification..."</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{move || session.get().map(|result| {
|
||||||
|
match result {
|
||||||
|
Ok(true) => view! {
|
||||||
|
<div class="admin-page">
|
||||||
|
|
||||||
|
// ── Topbar ───────────────────────────────────────
|
||||||
|
<div class="admin-topbar">
|
||||||
|
<div>
|
||||||
|
<h1 style="
|
||||||
|
background:linear-gradient(135deg,#4F8EFF,#8B5CF6);
|
||||||
|
-webkit-background-clip:text;
|
||||||
|
-webkit-text-fill-color:transparent;
|
||||||
|
background-clip:text;
|
||||||
|
">
|
||||||
|
"Quiz"
|
||||||
|
</h1>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);margin-top:2px;">
|
||||||
|
"Gérez vos quiz et consultez les résultats."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<a href="/admin/config" class="btn-ghost">"⚙ Config"</a>
|
||||||
|
<a href="/admin/quiz/new" class="btn-green">
|
||||||
|
"+ Nouveau quiz"
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Table ────────────────────────────────────────
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<p style="color:var(--color-enuxia-muted);font-size:14px;">
|
||||||
|
"Chargement..."
|
||||||
|
</p>
|
||||||
|
}>
|
||||||
|
{move || quizzes.get().map(|result| {
|
||||||
|
match result {
|
||||||
|
Ok(list) if list.is_empty() => view! {
|
||||||
|
<div style="
|
||||||
|
text-align:center; padding:60px 24px;
|
||||||
|
border-radius:16px;
|
||||||
|
background:var(--color-enuxia-dark-3);
|
||||||
|
border:1px solid var(--color-enuxia-border);
|
||||||
|
">
|
||||||
|
<p style="font-size:32px;margin-bottom:12px;">"📋"</p>
|
||||||
|
<p style="font-size:16px;font-weight:600;color:var(--color-enuxia-text);">
|
||||||
|
"Aucun quiz pour l'instant"
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);margin-top:4px;">
|
||||||
|
"Créez votre premier quiz pour commencer."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
Ok(list) => view! {
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Titre"</th>
|
||||||
|
<th>"Description"</th>
|
||||||
|
<th>"Statut"</th>
|
||||||
|
<th>"Actions"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.into_iter().map(|quiz| {
|
||||||
|
let qid = quiz.id;
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">
|
||||||
|
{quiz.title.clone()}
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--color-enuxia-muted);">
|
||||||
|
{quiz.description.clone()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{if quiz.active {
|
||||||
|
view! {
|
||||||
|
<span class="badge-on">"Actif"</span>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! {
|
||||||
|
<span class="badge-off">"Inactif"</span>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="table-actions">
|
||||||
|
<a href=format!("/admin/quiz/{}/edit", qid)
|
||||||
|
class="table-link">
|
||||||
|
"Éditer"
|
||||||
|
</a>
|
||||||
|
<a href=format!("/admin/quiz/{}/results", qid)
|
||||||
|
class="table-link">
|
||||||
|
"Résultats"
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
on:click=move |_| {
|
||||||
|
if web_sys::window()
|
||||||
|
.unwrap()
|
||||||
|
.confirm_with_message("Supprimer ce quiz et toutes ses données ?")
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
delete_action.dispatch(qid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Supprimer"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}.into_any(),
|
||||||
|
Err(_) => view! {
|
||||||
|
<div class="field-error">"Erreur de chargement."</div>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! {
|
||||||
|
<script>"window.location.href='/admin/login';"</script>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod dashboard;
|
||||||
|
pub mod quiz_edit;
|
||||||
|
pub mod quiz_results;
|
||||||
|
pub mod quiz_stats;
|
||||||
|
pub mod student_detail;
|
||||||
|
pub mod config;
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos::web_sys;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
use crate::server::admin::*;
|
||||||
|
use crate::server::student::get_quiz_with_questions;
|
||||||
|
use crate::models::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn QuizEditPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let quiz_id = move || params.get().get("quiz_id").and_then(|s| s.parse::<i64>().ok());
|
||||||
|
let is_new = move || quiz_id().is_none();
|
||||||
|
let title = RwSignal::new(String::new());
|
||||||
|
let description = RwSignal::new(String::new());
|
||||||
|
let active = RwSignal::new(true);
|
||||||
|
let shuffle_questions = RwSignal::new(false);
|
||||||
|
let shuffle_answers = RwSignal::new(false);
|
||||||
|
let time_limit = RwSignal::new(Option::<i64>::None);
|
||||||
|
let saved_quiz_id = RwSignal::new(Option::<i64>::None);
|
||||||
|
let questions = RwSignal::new(Vec::<QuestionWithAnswers>::new());
|
||||||
|
let saved_notice = RwSignal::new(false);
|
||||||
|
|
||||||
|
let existing = Resource::new(quiz_id, |id| async move {
|
||||||
|
match id {
|
||||||
|
Some(id) => get_quiz_with_questions(id).await,
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Effect::new(move |_| {
|
||||||
|
if let Some(Ok(Some((quiz, qs)))) = existing.get() {
|
||||||
|
title.set(quiz.title.clone());
|
||||||
|
description.set(quiz.description.clone());
|
||||||
|
active.set(quiz.active);
|
||||||
|
shuffle_questions.set(quiz.shuffle_questions);
|
||||||
|
shuffle_answers.set(quiz.shuffle_answers);
|
||||||
|
time_limit.set(quiz.time_limit_seconds);
|
||||||
|
saved_quiz_id.set(Some(quiz.id));
|
||||||
|
questions.set(qs);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let save_quiz = Action::new(move |_: &()| {
|
||||||
|
let t = title.get();
|
||||||
|
let d = description.get();
|
||||||
|
let a = active.get();
|
||||||
|
let sq = shuffle_questions.get();
|
||||||
|
let sa = shuffle_answers.get();
|
||||||
|
let tl = time_limit.get();
|
||||||
|
async move {
|
||||||
|
match saved_quiz_id.get() {
|
||||||
|
Some(id) => { let _ = update_quiz(id, t, d, a, sq, sa, tl).await; }
|
||||||
|
None => {
|
||||||
|
if let Ok(id) = create_quiz(t, d).await {
|
||||||
|
saved_quiz_id.set(Some(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saved_notice.set(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let add_question_action = Action::new(move |_: &()| {
|
||||||
|
let qid = saved_quiz_id.get();
|
||||||
|
async move {
|
||||||
|
let Some(quiz_id) = qid else { return };
|
||||||
|
let pos = questions.get().len() as i64;
|
||||||
|
if let Ok(id) = add_question(quiz_id, String::new(), String::new(), pos).await {
|
||||||
|
for label in ["A", "B", "C", "D"] {
|
||||||
|
let _ = add_answer(id, label.to_string(), String::new(), false).await;
|
||||||
|
}
|
||||||
|
if let Ok(Some((_, qs))) = get_quiz_with_questions(quiz_id).await {
|
||||||
|
questions.set(qs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="admin-page">
|
||||||
|
|
||||||
|
// ── Topbar ───────────────────────────────────────────────────────
|
||||||
|
<div class="admin-topbar">
|
||||||
|
<div>
|
||||||
|
<h1 style="
|
||||||
|
background:linear-gradient(135deg,#4F8EFF,#8B5CF6);
|
||||||
|
-webkit-background-clip:text;
|
||||||
|
-webkit-text-fill-color:transparent;
|
||||||
|
background-clip:text;
|
||||||
|
">
|
||||||
|
{move || if is_new() { "Nouveau quiz" } else { "Éditer le quiz" }}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
{move || saved_notice.get().then(|| view! {
|
||||||
|
<span style="font-size:13px;font-weight:600;color:var(--color-enuxia-green);">
|
||||||
|
"✓ Sauvegardé"
|
||||||
|
</span>
|
||||||
|
})}
|
||||||
|
<a href="/admin" class="btn-ghost">"← Dashboard"</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Infos du quiz ────────────────────────────────────────────────
|
||||||
|
<div class="quiz-meta-card">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Titre du quiz"
|
||||||
|
prop:value=move || title.get()
|
||||||
|
on:input=move |e| title.set(event_target_value(&e))
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
placeholder="Description (optionnelle)"
|
||||||
|
prop:value=move || description.get()
|
||||||
|
on:input=move |e| description.set(event_target_value(&e))
|
||||||
|
/>
|
||||||
|
|
||||||
|
// ── Options avancées ─────────────────────────────────────────
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px;padding-top:8px;border-top:1px solid var(--color-enuxia-border);">
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
prop:checked=move || active.get()
|
||||||
|
on:change=move |e| {
|
||||||
|
let target = event_target::<web_sys::HtmlInputElement>(&e);
|
||||||
|
active.set(target.checked());
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
"Quiz actif — visible par les étudiants"
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
prop:checked=move || shuffle_questions.get()
|
||||||
|
on:change=move |e| {
|
||||||
|
let target = event_target::<web_sys::HtmlInputElement>(&e);
|
||||||
|
shuffle_questions.set(target.checked());
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
"Ordre des questions aléatoire"
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
prop:checked=move || shuffle_answers.get()
|
||||||
|
on:change=move |e| {
|
||||||
|
let target = event_target::<web_sys::HtmlInputElement>(&e);
|
||||||
|
shuffle_answers.set(target.checked());
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
"Ordre des réponses aléatoire"
|
||||||
|
</label>
|
||||||
|
|
||||||
|
// ── Minuteur ─────────────────────────────────────────────
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;">
|
||||||
|
<label class="checkbox-row" style="white-space:nowrap;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
prop:checked=move || time_limit.get().is_some()
|
||||||
|
on:change=move |e| {
|
||||||
|
let target = event_target::<web_sys::HtmlInputElement>(&e);
|
||||||
|
if target.checked() {
|
||||||
|
time_limit.set(Some(600));
|
||||||
|
} else {
|
||||||
|
time_limit.set(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
"Activer un minuteur"
|
||||||
|
</label>
|
||||||
|
{move || time_limit.get().map(|_secs| view! {
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="Secondes"
|
||||||
|
style="width:100px;"
|
||||||
|
prop:value=move || time_limit.get().unwrap_or(600).to_string()
|
||||||
|
on:input=move |e| {
|
||||||
|
let v = event_target_value(&e)
|
||||||
|
.parse::<i64>()
|
||||||
|
.unwrap_or(600);
|
||||||
|
time_limit.set(Some(v));
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span style="font-size:13px;color:var(--color-enuxia-muted);">
|
||||||
|
{move || {
|
||||||
|
let s = time_limit.get().unwrap_or(0);
|
||||||
|
format!("= {}min {}s", s / 60, s % 60)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="save-row">
|
||||||
|
<span/>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
on:click=move |_| { save_quiz.dispatch(()); }
|
||||||
|
>
|
||||||
|
{move || if is_new() { "Créer le quiz" } else { "Sauvegarder" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Questions ────────────────────────────────────────────────────
|
||||||
|
{move || saved_quiz_id.get().map(|_| view! {
|
||||||
|
<div class="quiz-editor">
|
||||||
|
<div class="questions-header">
|
||||||
|
<h2>"Questions"</h2>
|
||||||
|
<button
|
||||||
|
class="btn-green"
|
||||||
|
on:click=move |_| { add_question_action.dispatch(()); }
|
||||||
|
>
|
||||||
|
"+ Ajouter une question"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{move || questions.get().into_iter().enumerate().map(|(idx, qwa)| {
|
||||||
|
let q_id = qwa.question.id;
|
||||||
|
let q_text = RwSignal::new(qwa.question.text.clone());
|
||||||
|
let q_section = RwSignal::new(qwa.question.section.clone());
|
||||||
|
let answers = StoredValue::new(qwa.answers.clone());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="question-card">
|
||||||
|
<div class="qcard-header">
|
||||||
|
<span class="q-badge">{format!("Q{}", idx + 1)}</span>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
on:click=move |_| {
|
||||||
|
let qid_saved = saved_quiz_id.get();
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
let _ = delete_question(q_id).await;
|
||||||
|
if let Some(qzid) = qid_saved {
|
||||||
|
if let Ok(Some((_, qs))) = get_quiz_with_questions(qzid).await {
|
||||||
|
questions.set(qs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>"✕ Supprimer"</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Section (ex: Cours 1 — Interopérabilité)"
|
||||||
|
prop:value=move || q_section.get()
|
||||||
|
on:input=move |e| q_section.set(event_target_value(&e))
|
||||||
|
on:blur=move |_| {
|
||||||
|
let t = q_text.get();
|
||||||
|
let s = q_section.get();
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
let _ = update_question(q_id, t, s, idx as i64).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Texte de la question"
|
||||||
|
prop:value=move || q_text.get()
|
||||||
|
on:input=move |e| q_text.set(event_target_value(&e))
|
||||||
|
on:blur=move |_| {
|
||||||
|
let t = q_text.get();
|
||||||
|
let s = q_section.get();
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
let _ = update_question(q_id, t, s, idx as i64).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="answers-editor">
|
||||||
|
{answers.get_value().into_iter().map(|answer| {
|
||||||
|
let a_id = answer.id;
|
||||||
|
let a_text = RwSignal::new(answer.text.clone());
|
||||||
|
let a_correct = RwSignal::new(answer.correct);
|
||||||
|
let a_label = StoredValue::new(answer.label.clone());
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="answer-editor-row">
|
||||||
|
<span class="answer-letter">{answer.label.clone()}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder=format!("Réponse {}", answer.label.clone())
|
||||||
|
prop:value=move || a_text.get()
|
||||||
|
on:input=move |e| a_text.set(event_target_value(&e))
|
||||||
|
on:blur=move |_| {
|
||||||
|
let t = a_text.get();
|
||||||
|
let c = a_correct.get();
|
||||||
|
let lbl = a_label.get_value();
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
let _ = update_answer(a_id, lbl, t, c).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label class="radio-correct">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name=format!("correct-{}", q_id)
|
||||||
|
prop:checked=move || a_correct.get()
|
||||||
|
on:change=move |_| {
|
||||||
|
let t = a_text.get();
|
||||||
|
let lbl = a_label.get_value();
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
let _ = update_answer(a_id, lbl, t, true).await;
|
||||||
|
});
|
||||||
|
a_correct.set(true);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
"Correcte"
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
use crate::server::admin::{get_quiz_submissions, export_submissions_csv};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn QuizResultsPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let quiz_id = move || params.get().get("quiz_id").and_then(|s| s.parse::<i64>().ok()).unwrap_or(0);
|
||||||
|
let submissions = Resource::new(quiz_id, |id| get_quiz_submissions(id));
|
||||||
|
let csv_data = RwSignal::new(Option::<String>::None);
|
||||||
|
|
||||||
|
let export_action = Action::new(move |_: &()| {
|
||||||
|
let id = quiz_id();
|
||||||
|
async move {
|
||||||
|
if let Ok(csv) = export_submissions_csv(id).await {
|
||||||
|
csv_data.set(Some(csv));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="admin-page">
|
||||||
|
<div class="admin-topbar">
|
||||||
|
<div>
|
||||||
|
<h1 style="
|
||||||
|
background:linear-gradient(135deg,#4F8EFF,#8B5CF6);
|
||||||
|
-webkit-background-clip:text;
|
||||||
|
-webkit-text-fill-color:transparent;
|
||||||
|
background-clip:text;
|
||||||
|
">"Résultats"</h1>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);margin-top:2px;">
|
||||||
|
"Liste des soumissions pour ce quiz."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button
|
||||||
|
class="btn-purple"
|
||||||
|
on:click=move |_| { export_action.dispatch(()); }
|
||||||
|
>
|
||||||
|
"↓ Exporter CSV"
|
||||||
|
</button>
|
||||||
|
<a href=move || format!("/admin/quiz/{}/stats", quiz_id())
|
||||||
|
class="btn-ghost">"📊 Stats"</a>
|
||||||
|
<a href="/admin" class="btn-ghost">"← Dashboard"</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// CSV export
|
||||||
|
{move || csv_data.get().map(|csv| view! {
|
||||||
|
<div class="csv-box">
|
||||||
|
<p>"✓ CSV généré — copiez le contenu ci-dessous"</p>
|
||||||
|
<textarea readonly rows="8">{csv}</textarea>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<p style="color:var(--color-enuxia-muted);font-size:14px;">"Chargement..."</p>
|
||||||
|
}>
|
||||||
|
{move || submissions.get().map(|result| {
|
||||||
|
match result {
|
||||||
|
Ok(list) if list.is_empty() => view! {
|
||||||
|
<div style="
|
||||||
|
text-align:center; padding:60px 24px;
|
||||||
|
border-radius:16px;
|
||||||
|
background:var(--color-enuxia-dark-3);
|
||||||
|
border:1px solid var(--color-enuxia-border);
|
||||||
|
">
|
||||||
|
<p style="font-size:32px;margin-bottom:12px;">"📭"</p>
|
||||||
|
<p style="font-size:16px;font-weight:600;color:var(--color-enuxia-text);">
|
||||||
|
"Aucune soumission"
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);margin-top:4px;">
|
||||||
|
"Les résultats apparaîtront ici quand des étudiants auront passé le quiz."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
Ok(list) => view! {
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>"Nom"</th>
|
||||||
|
<th>"Prénom"</th>
|
||||||
|
<th>"Score"</th>
|
||||||
|
<th>"%"</th>
|
||||||
|
<th>"Date"</th>
|
||||||
|
<th>"Détail"</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.into_iter().map(|s| {
|
||||||
|
let pct = if s.total > 0 {
|
||||||
|
(s.score * 100) / s.total
|
||||||
|
} else { 0 };
|
||||||
|
let sid = s.id;
|
||||||
|
|
||||||
|
let pct_color = if pct >= 80 { "#22C55E" }
|
||||||
|
else if pct >= 50 { "#4F8EFF" }
|
||||||
|
else { "#f87171" };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<tr>
|
||||||
|
<td style="font-weight:600;">{s.last_name.clone()}</td>
|
||||||
|
<td>{s.first_name.clone()}</td>
|
||||||
|
<td>{format!("{} / {}", s.score, s.total)}</td>
|
||||||
|
<td>
|
||||||
|
<span style=format!(
|
||||||
|
"font-weight:700;color:{};", pct_color
|
||||||
|
)>
|
||||||
|
{format!("{}%", pct)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="color:var(--color-enuxia-muted);font-size:13px;">
|
||||||
|
{s.submitted_at.format("%d/%m/%Y %H:%M").to_string()}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href=format!("/admin/submission/{}", sid)
|
||||||
|
class="table-link">
|
||||||
|
"Voir →"
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}.into_any(),
|
||||||
|
Err(_) => view! {
|
||||||
|
<div class="field-error">"Erreur de chargement."</div>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
use crate::server::admin::{get_quiz_stats, reset_student, get_quiz_submissions};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn QuizStatsPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let quiz_id = move || {
|
||||||
|
params.get().get("quiz_id")
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let stats = Resource::new(quiz_id, |id| get_quiz_stats(id));
|
||||||
|
let submissions = Resource::new(quiz_id, |id| get_quiz_submissions(id));
|
||||||
|
let refresh = RwSignal::new(0u32);
|
||||||
|
|
||||||
|
let reset_fn = RwSignal::new(String::new());
|
||||||
|
let reset_ln = RwSignal::new(String::new());
|
||||||
|
let reset_msg = RwSignal::new(Option::<String>::None);
|
||||||
|
|
||||||
|
let reset_action = Action::new(move |_: &()| {
|
||||||
|
let fn_ = reset_fn.get();
|
||||||
|
let ln = reset_ln.get();
|
||||||
|
let qid = quiz_id();
|
||||||
|
async move {
|
||||||
|
match reset_student(qid, fn_.clone(), ln.clone()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
reset_msg.set(Some(format!("✓ {} {} peut repasser le quiz.", fn_, ln)));
|
||||||
|
reset_fn.set(String::new());
|
||||||
|
reset_ln.set(String::new());
|
||||||
|
refresh.update(|r| *r += 1);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
reset_msg.set(Some(format!("Erreur : {}", e)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Recharge les soumissions quand refresh change
|
||||||
|
let submissions = Resource::new(
|
||||||
|
move || refresh.get(),
|
||||||
|
move |_| get_quiz_submissions(quiz_id())
|
||||||
|
);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="admin-page">
|
||||||
|
<div class="admin-topbar">
|
||||||
|
<div>
|
||||||
|
<h1 style="
|
||||||
|
background:linear-gradient(135deg,#4F8EFF,#8B5CF6);
|
||||||
|
-webkit-background-clip:text;
|
||||||
|
-webkit-text-fill-color:transparent;
|
||||||
|
background-clip:text;
|
||||||
|
">"Statistiques"</h1>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);margin-top:2px;">
|
||||||
|
"Analyse des résultats et gestion des étudiants."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<a href=move || format!("/admin/quiz/{}/results", quiz_id())
|
||||||
|
class="btn-ghost">"← Résultats"</a>
|
||||||
|
<a href="/admin" class="btn-ghost">"Dashboard"</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<p style="color:var(--color-enuxia-muted);">"Chargement..."</p>
|
||||||
|
}>
|
||||||
|
{move || stats.get().map(|result| {
|
||||||
|
match result {
|
||||||
|
Ok(s) if s.total_submissions == 0 => view! {
|
||||||
|
<div style="
|
||||||
|
text-align:center;padding:60px;
|
||||||
|
border-radius:16px;
|
||||||
|
background:var(--color-enuxia-dark-3);
|
||||||
|
border:1px solid var(--color-enuxia-border);
|
||||||
|
">
|
||||||
|
<p style="font-size:32px;margin-bottom:8px;">"📊"</p>
|
||||||
|
<p style="font-weight:600;color:var(--color-enuxia-text);">
|
||||||
|
"Aucune soumission pour l'instant."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
Ok(s) => view! {
|
||||||
|
<div style="display:flex;flex-direction:column;gap:24px;">
|
||||||
|
|
||||||
|
// ── Chiffres clés ─────────────────────────────
|
||||||
|
<div style="
|
||||||
|
display:grid;
|
||||||
|
grid-template-columns:repeat(3,1fr);
|
||||||
|
gap:16px;
|
||||||
|
">
|
||||||
|
<div class="quiz-meta-card" style="text-align:center;">
|
||||||
|
<p style="font-size:11px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--color-enuxia-muted);">
|
||||||
|
"Soumissions"
|
||||||
|
</p>
|
||||||
|
<p style="font-size:40px;font-weight:800;font-family:'Space Grotesk',sans-serif;color:var(--color-enuxia-blue);">
|
||||||
|
{s.total_submissions}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="quiz-meta-card" style="text-align:center;">
|
||||||
|
<p style="font-size:11px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--color-enuxia-muted);">
|
||||||
|
"Score moyen"
|
||||||
|
</p>
|
||||||
|
<p style="font-size:40px;font-weight:800;font-family:'Space Grotesk',sans-serif;color:var(--color-enuxia-purple);">
|
||||||
|
{format!("{:.1}", s.average_score)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="quiz-meta-card" style="text-align:center;">
|
||||||
|
<p style="font-size:11px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--color-enuxia-muted);">
|
||||||
|
"Moyenne"
|
||||||
|
</p>
|
||||||
|
<p style=format!(
|
||||||
|
"font-size:40px;font-weight:800;font-family:'Space Grotesk',sans-serif;color:{};",
|
||||||
|
if s.average_pct >= 80.0 { "#22C55E" }
|
||||||
|
else if s.average_pct >= 50.0 { "#4F8EFF" }
|
||||||
|
else { "#f87171" }
|
||||||
|
)>
|
||||||
|
{format!("{:.0}%", s.average_pct)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Distribution des scores ───────────────────
|
||||||
|
{if !s.score_distribution.is_empty() {
|
||||||
|
let max_count = s.score_distribution.iter().map(|(_, c)| *c).max().unwrap_or(1);
|
||||||
|
view! {
|
||||||
|
<div class="quiz-meta-card">
|
||||||
|
<h2 style="font-size:16px;font-weight:700;margin-bottom:16px;">
|
||||||
|
"Distribution des scores"
|
||||||
|
</h2>
|
||||||
|
<div style="display:flex;align-items:flex-end;gap:8px;height:80px;">
|
||||||
|
{s.score_distribution.into_iter().map(|(score, count)| {
|
||||||
|
let pct = (count as f64 / max_count as f64 * 100.0) as i64;
|
||||||
|
view! {
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;flex:1;">
|
||||||
|
<span style="font-size:11px;color:var(--color-enuxia-muted);">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
<div style=format!(
|
||||||
|
"width:100%;background:var(--color-enuxia-blue);
|
||||||
|
border-radius:4px 4px 0 0;height:{}px;
|
||||||
|
opacity:0.8;min-height:4px;",
|
||||||
|
(pct * 60) / 100
|
||||||
|
)/>
|
||||||
|
<span style="font-size:11px;font-weight:600;color:var(--color-enuxia-text);">
|
||||||
|
{score}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span/> }.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// ── Stats par question ────────────────────────
|
||||||
|
<div class="quiz-meta-card">
|
||||||
|
<h2 style="font-size:16px;font-weight:700;margin-bottom:16px;">
|
||||||
|
"Taux de réussite par question"
|
||||||
|
</h2>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:12px;">
|
||||||
|
{s.question_stats.into_iter().enumerate().map(|(idx, qs)| {
|
||||||
|
let color = if qs.success_rate >= 80.0 { "#22C55E" }
|
||||||
|
else if qs.success_rate >= 50.0 { "#4F8EFF" }
|
||||||
|
else { "#f87171" };
|
||||||
|
view! {
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;min-width:0;">
|
||||||
|
<span style="
|
||||||
|
font-size:10px;font-weight:700;
|
||||||
|
padding:2px 8px;border-radius:4px;
|
||||||
|
background:rgba(139,92,246,0.1);
|
||||||
|
color:#8B5CF6;flex-shrink:0;
|
||||||
|
">
|
||||||
|
{format!("Q{}", idx + 1)}
|
||||||
|
</span>
|
||||||
|
<span style="
|
||||||
|
font-size:13px;color:var(--color-enuxia-text);
|
||||||
|
overflow:hidden;text-overflow:ellipsis;
|
||||||
|
white-space:nowrap;
|
||||||
|
">
|
||||||
|
{qs.question_text.clone()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style=format!(
|
||||||
|
"font-size:13px;font-weight:700;color:{};flex-shrink:0;",
|
||||||
|
color
|
||||||
|
)>
|
||||||
|
{format!("{:.0}% ({}/{})", qs.success_rate, qs.correct_count, qs.total_count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
// Barre de progression
|
||||||
|
<div style="height:4px;border-radius:99px;background:var(--color-enuxia-border);">
|
||||||
|
<div style=format!(
|
||||||
|
"height:100%;border-radius:99px;width:{:.0}%;background:{};transition:width 0.4s;",
|
||||||
|
qs.success_rate, color
|
||||||
|
)/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
Err(e) => view! {
|
||||||
|
<div class="field-error">{e.to_string()}</div>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
// ── Reset étudiant ────────────────────────────────────────────────
|
||||||
|
<div class="quiz-meta-card">
|
||||||
|
<h2 style="font-size:16px;font-weight:700;">"Réinitialiser un étudiant"</h2>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);">
|
||||||
|
"Permet à un étudiant de repasser le quiz. Sa soumission existante sera supprimée."
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:10px;flex-wrap:wrap;">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Prénom"
|
||||||
|
style="flex:1;min-width:140px;"
|
||||||
|
prop:value=move || reset_fn.get()
|
||||||
|
on:input=move |e| reset_fn.set(event_target_value(&e))
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nom"
|
||||||
|
style="flex:1;min-width:140px;"
|
||||||
|
prop:value=move || reset_ln.get()
|
||||||
|
on:input=move |e| reset_ln.set(event_target_value(&e))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
style="padding:10px 20px;font-size:14px;"
|
||||||
|
on:click=move |_| {
|
||||||
|
if !reset_fn.get().is_empty() && !reset_ln.get().is_empty() {
|
||||||
|
reset_action.dispatch(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Réinitialiser"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{move || reset_msg.get().map(|msg| {
|
||||||
|
let style_class = if msg.starts_with("✓") {
|
||||||
|
"background:rgba(34,197,94,0.1);color:#22C55E;border:1px solid rgba(34,197,94,0.2);"
|
||||||
|
} else {
|
||||||
|
"background:rgba(239,68,68,0.1);color:#f87171;border:1px solid rgba(239,68,68,0.2);"
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<p style=format!(
|
||||||
|
"font-size:13px;font-weight:600;padding:10px 14px;border-radius:8px;{}",
|
||||||
|
style_class
|
||||||
|
)>
|
||||||
|
{msg}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
|
||||||
|
// Liste des soumissions pour faciliter le reset
|
||||||
|
<Suspense fallback=|| view! { <span/> }>
|
||||||
|
{move || submissions.get().map(|result| {
|
||||||
|
match result {
|
||||||
|
Ok(list) if !list.is_empty() => view! {
|
||||||
|
<div style="margin-top:8px;">
|
||||||
|
<p style="font-size:12px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase;color:var(--color-enuxia-muted);margin-bottom:8px;">
|
||||||
|
"Soumissions existantes"
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
{list.into_iter().map(|s| {
|
||||||
|
let fn_ = s.first_name.clone();
|
||||||
|
let ln = s.last_name.clone();
|
||||||
|
let fn2 = fn_.clone();
|
||||||
|
let ln2 = ln.clone();
|
||||||
|
let pct = if s.total > 0 { (s.score * 100) / s.total } else { 0 };
|
||||||
|
view! {
|
||||||
|
<div style="
|
||||||
|
display:flex;align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
padding:8px 12px;border-radius:8px;
|
||||||
|
background:var(--color-enuxia-dark);
|
||||||
|
border:1px solid var(--color-enuxia-border);
|
||||||
|
">
|
||||||
|
<span style="font-size:13px;font-weight:500;">
|
||||||
|
{format!("{} {} — {}/{} ({}%)", fn_.clone(), ln.clone(), s.score, s.total, pct)}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="btn-danger"
|
||||||
|
style="padding:4px 12px;font-size:12px;"
|
||||||
|
on:click=move |_| {
|
||||||
|
reset_fn.set(fn2.clone());
|
||||||
|
reset_ln.set(ln2.clone());
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Sélectionner"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! { <span/> }.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
use crate::server::student::get_result_details;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn StudentDetailPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
|
||||||
|
let submission_id = move || {
|
||||||
|
params.get().get("submission_id")
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = Resource::new(submission_id, |id| get_result_details(id));
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<div class="admin-page">
|
||||||
|
<p style="color:var(--color-enuxia-muted);">"Chargement..."</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{move || result.get().map(|data| {
|
||||||
|
match data {
|
||||||
|
Ok(Some((submission, details))) => {
|
||||||
|
let pct = if submission.total > 0 {
|
||||||
|
(submission.score * 100) / submission.total
|
||||||
|
} else { 0 };
|
||||||
|
|
||||||
|
let pct_color = if pct >= 80 { "#22C55E" }
|
||||||
|
else if pct >= 50 { "#4F8EFF" }
|
||||||
|
else { "#f87171" };
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="admin-page">
|
||||||
|
<div class="admin-topbar">
|
||||||
|
<div>
|
||||||
|
<h1 style="
|
||||||
|
background:linear-gradient(135deg,#4F8EFF,#8B5CF6);
|
||||||
|
-webkit-background-clip:text;
|
||||||
|
-webkit-text-fill-color:transparent;
|
||||||
|
background-clip:text;
|
||||||
|
">
|
||||||
|
{format!("{} {}", submission.first_name, submission.last_name)}
|
||||||
|
</h1>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);margin-top:2px;">
|
||||||
|
{submission.submitted_at.format("Soumis le %d/%m/%Y à %H:%M").to_string()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href="javascript:history.back()" class="btn-ghost">"← Retour"</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Score bar
|
||||||
|
<div class="score-summary-bar">
|
||||||
|
<div style="display:flex;align-items:center;gap:16px;">
|
||||||
|
<span class="score-val">
|
||||||
|
{format!("{} / {}", submission.score, submission.total)}
|
||||||
|
</span>
|
||||||
|
<span style=format!(
|
||||||
|
"font-size:20px;font-weight:700;color:{};",
|
||||||
|
pct_color
|
||||||
|
)>
|
||||||
|
{format!("{}%", pct)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span style="font-size:13px;color:var(--color-enuxia-muted);">
|
||||||
|
{if pct >= 80 { "🎉 Excellent" }
|
||||||
|
else if pct >= 50 { "👍 Satisfaisant" }
|
||||||
|
else { "📚 À améliorer" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Correction
|
||||||
|
<div class="correction-section">
|
||||||
|
<h2>"Détail des réponses"</h2>
|
||||||
|
{details.into_iter().enumerate().map(|(idx, d)| {
|
||||||
|
let item_class = if d.is_correct {
|
||||||
|
"correction-item is-correct"
|
||||||
|
} else {
|
||||||
|
"correction-item is-wrong"
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<div class=item_class>
|
||||||
|
<div class="corr-meta">
|
||||||
|
<span class="corr-qnum">
|
||||||
|
{format!("Q{}", idx + 1)}
|
||||||
|
</span>
|
||||||
|
<span class="corr-section">
|
||||||
|
{d.section.clone()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="corr-question">{d.question_text.clone()}</p>
|
||||||
|
<p class="corr-chosen">
|
||||||
|
{if d.is_correct { "✅ " } else { "❌ " }}
|
||||||
|
{format!("{}) {}", d.chosen_label, d.chosen_text)}
|
||||||
|
</p>
|
||||||
|
{if !d.is_correct {
|
||||||
|
view! {
|
||||||
|
<p class="corr-right">
|
||||||
|
{format!("✔ Bonne réponse : {}) {}",
|
||||||
|
d.correct_label, d.correct_text)}
|
||||||
|
</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span/> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
},
|
||||||
|
_ => view! {
|
||||||
|
<div class="admin-page">
|
||||||
|
<div class="field-error">"Résultats introuvables."</div>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_query_map;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LoginPage() -> impl IntoView {
|
||||||
|
let password = RwSignal::new(String::new());
|
||||||
|
let error = RwSignal::new(false);
|
||||||
|
let loading = RwSignal::new(false);
|
||||||
|
|
||||||
|
let login_action = Action::new(move |pwd: &String| {
|
||||||
|
let pwd = pwd.clone();
|
||||||
|
async move {
|
||||||
|
loading.set(true);
|
||||||
|
error.set(false);
|
||||||
|
match crate::server::login(pwd, false).await {
|
||||||
|
Ok(true) => {
|
||||||
|
let query = use_query_map().get();
|
||||||
|
let next = query.get("next").unwrap_or_else(|| "/".to_string());
|
||||||
|
leptos_router::hooks::use_navigate()(&next, Default::default());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error.set(true);
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-bg-glow"/>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<img src="/favicon.png" width="40" height="40" style="border-radius:10px;" alt="Enuxia"/>
|
||||||
|
<div>
|
||||||
|
<h1 class="login-title">"Enuxia Quiz"</h1>
|
||||||
|
<p class="login-sub">"Plateforme d'évaluation sécurisée"</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-divider"/>
|
||||||
|
|
||||||
|
<div class="login-form">
|
||||||
|
<label class="field-label">"Code de session"</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="pwd-input"
|
||||||
|
placeholder="Entrez le code fourni par votre professeur"
|
||||||
|
prop:value=move || password.get()
|
||||||
|
on:input=move |e| {
|
||||||
|
password.set(event_target_value(&e));
|
||||||
|
error.set(false);
|
||||||
|
}
|
||||||
|
on:keydown=move |e| {
|
||||||
|
if e.key() == "Enter" && !loading.get() {
|
||||||
|
login_action.dispatch(password.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{move || error.get().then(|| view! {
|
||||||
|
<div class="field-error">
|
||||||
|
<span>"⚠"</span>
|
||||||
|
" Code incorrect. Vérifiez auprès de votre professeur."
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class=move || if loading.get() { "btn-submit loading" } else { "btn-submit" }
|
||||||
|
on:click=move |_| {
|
||||||
|
if !loading.get() && !password.get().is_empty() {
|
||||||
|
login_action.dispatch(password.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{move || if loading.get() {
|
||||||
|
view! { <span class="spinner"/> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span>"Accéder au quiz →"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="login-footer">
|
||||||
|
"Professeur ? "
|
||||||
|
<a href="/admin/login">"Accès administration →"</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn AdminLoginPage() -> impl IntoView {
|
||||||
|
let password = RwSignal::new(String::new());
|
||||||
|
let error = RwSignal::new(false);
|
||||||
|
let loading = RwSignal::new(false);
|
||||||
|
|
||||||
|
let login_action = Action::new(move |pwd: &String| {
|
||||||
|
let pwd = pwd.clone();
|
||||||
|
async move {
|
||||||
|
loading.set(true);
|
||||||
|
error.set(false);
|
||||||
|
match crate::server::login(pwd, true).await {
|
||||||
|
Ok(true) => {
|
||||||
|
leptos_router::hooks::use_navigate()("/admin", Default::default());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
error.set(true);
|
||||||
|
loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-bg-glow admin-glow"/>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-header">
|
||||||
|
<img src="/favicon.png" width="40" height="40" style="border-radius:10px;" alt="Enuxia"/>
|
||||||
|
<div>
|
||||||
|
<h1 class="login-title">"Administration"</h1>
|
||||||
|
<p class="login-sub">"Accès réservé au professeur"</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-divider"/>
|
||||||
|
|
||||||
|
<div class="login-form">
|
||||||
|
<label class="field-label">"Mot de passe"</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
class="pwd-input"
|
||||||
|
placeholder="Mot de passe administrateur"
|
||||||
|
prop:value=move || password.get()
|
||||||
|
on:input=move |e| {
|
||||||
|
password.set(event_target_value(&e));
|
||||||
|
error.set(false);
|
||||||
|
}
|
||||||
|
on:keydown=move |e| {
|
||||||
|
if e.key() == "Enter" && !loading.get() {
|
||||||
|
login_action.dispatch(password.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{move || error.get().then(|| view! {
|
||||||
|
<div class="field-error">
|
||||||
|
<span>"⚠"</span>
|
||||||
|
" Mot de passe incorrect."
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
class=move || if loading.get() { "btn-submit loading" } else { "btn-submit" }
|
||||||
|
on:click=move |_| {
|
||||||
|
if !loading.get() && !password.get().is_empty() {
|
||||||
|
login_action.dispatch(password.get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{move || if loading.get() {
|
||||||
|
view! { <span class="spinner"/> }.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span>"Se connecter →"</span> }.into_any()
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="login-footer">
|
||||||
|
<a href="/login">"← Retour à l'accueil étudiant"</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod admin;
|
||||||
|
pub mod student;
|
||||||
|
pub mod login;
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_navigate;
|
||||||
|
use crate::server::{check_session_valid, student::{get_active_quizzes, check_already_done}};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn HomePage() -> impl IntoView {
|
||||||
|
let session = Resource::new(|| (), |_| check_session_valid());
|
||||||
|
let quizzes = Resource::new(|| (), |_| get_active_quizzes());
|
||||||
|
let first_name = RwSignal::new(String::new());
|
||||||
|
let last_name = RwSignal::new(String::new());
|
||||||
|
let selected_quiz = RwSignal::new(Option::<i64>::None);
|
||||||
|
let already_done = RwSignal::new(false);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<div class="home-page">
|
||||||
|
<p style="text-align:center;color:var(--color-enuxia-muted);">"Chargement..."</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{move || session.get().map(|result| {
|
||||||
|
match result {
|
||||||
|
Ok(true) => view! {
|
||||||
|
<div class="home-page">
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;text-align:center;">
|
||||||
|
<span style="
|
||||||
|
display:inline-flex;align-items:center;gap:6px;
|
||||||
|
padding:5px 14px;border-radius:20px;
|
||||||
|
background:rgba(79,142,255,0.08);
|
||||||
|
border:1px solid rgba(79,142,255,0.18);
|
||||||
|
font-size:11px;font-weight:700;
|
||||||
|
color:#4F8EFF;letter-spacing:0.08em;
|
||||||
|
text-transform:uppercase;
|
||||||
|
">
|
||||||
|
<span style="width:6px;height:6px;border-radius:50%;background:#4F8EFF;display:inline-block;"/>
|
||||||
|
"Session active"
|
||||||
|
</span>
|
||||||
|
<h1 style="
|
||||||
|
font-family:'Space Grotesk',sans-serif;
|
||||||
|
font-size:34px;font-weight:800;
|
||||||
|
letter-spacing:-0.02em;margin:0;
|
||||||
|
background:linear-gradient(135deg,#4F8EFF,#8B5CF6);
|
||||||
|
-webkit-background-clip:text;
|
||||||
|
-webkit-text-fill-color:transparent;
|
||||||
|
background-clip:text;
|
||||||
|
">
|
||||||
|
"Choisissez votre quiz"
|
||||||
|
</h1>
|
||||||
|
<p style="font-size:14px;color:var(--color-enuxia-muted);max-width:400px;line-height:1.5;">
|
||||||
|
"Sélectionnez un quiz ci-dessous, renseignez vos informations et commencez."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<p style="text-align:center;color:var(--color-enuxia-muted);font-size:14px;">
|
||||||
|
"Chargement des quiz..."
|
||||||
|
</p>
|
||||||
|
}>
|
||||||
|
{move || quizzes.get().map(|result| {
|
||||||
|
match result {
|
||||||
|
Ok(list) if list.is_empty() => view! {
|
||||||
|
<div style="
|
||||||
|
text-align:center;padding:48px 24px;
|
||||||
|
border-radius:16px;
|
||||||
|
background:var(--color-enuxia-dark-3);
|
||||||
|
border:1px solid var(--color-enuxia-border);
|
||||||
|
">
|
||||||
|
<p style="font-size:24px;margin-bottom:8px;">"📭"</p>
|
||||||
|
<p style="font-size:15px;font-weight:600;color:var(--color-enuxia-text);">
|
||||||
|
"Aucun quiz disponible"
|
||||||
|
</p>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);margin-top:4px;">
|
||||||
|
"Votre professeur n'a pas encore publié de quiz."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
Ok(list) => view! {
|
||||||
|
<div class="quiz-grid">
|
||||||
|
{list.into_iter().map(|quiz| {
|
||||||
|
let quiz_id = quiz.id;
|
||||||
|
let title = quiz.title.clone();
|
||||||
|
let desc = quiz.description.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div
|
||||||
|
class=move || {
|
||||||
|
if selected_quiz.get() == Some(quiz_id) {
|
||||||
|
"quiz-item selected"
|
||||||
|
} else {
|
||||||
|
"quiz-item"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:click=move |_| {
|
||||||
|
selected_quiz.set(Some(quiz_id));
|
||||||
|
already_done.set(false);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style="flex:1;">
|
||||||
|
<div class="quiz-title">{title.clone()}</div>
|
||||||
|
<div class="quiz-desc">{desc.clone()}</div>
|
||||||
|
</div>
|
||||||
|
<span class="arrow">"→"</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
Err(_) => view! {
|
||||||
|
<div class="field-error">"Erreur lors du chargement des quiz."</div>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{move || selected_quiz.get().map(|quiz_id| {
|
||||||
|
let navigate = use_navigate();
|
||||||
|
view! {
|
||||||
|
<div class="identity-form">
|
||||||
|
<p class="form-title">"Vos informations"</p>
|
||||||
|
<p style="font-size:13px;color:var(--color-enuxia-muted);margin-top:-4px;">
|
||||||
|
"Ces informations seront visibles par votre professeur."
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Prénom"
|
||||||
|
prop:value=move || first_name.get()
|
||||||
|
on:input=move |e| {
|
||||||
|
first_name.set(event_target_value(&e));
|
||||||
|
already_done.set(false);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nom de famille"
|
||||||
|
prop:value=move || last_name.get()
|
||||||
|
on:input=move |e| {
|
||||||
|
last_name.set(event_target_value(&e));
|
||||||
|
already_done.set(false);
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{move || already_done.get().then(|| view! {
|
||||||
|
<div style="
|
||||||
|
padding:12px 16px;border-radius:10px;
|
||||||
|
background:rgba(239,68,68,0.08);
|
||||||
|
border:1px solid rgba(239,68,68,0.2);
|
||||||
|
font-size:13px;color:#f87171;
|
||||||
|
display:flex;flex-direction:column;gap:4px;
|
||||||
|
">
|
||||||
|
<span style="font-weight:600;">"🔒 Quiz déjà passé"</span>
|
||||||
|
<span>"Contactez votre professeur pour une réinitialisation."</span>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
|
||||||
|
{move || (!already_done.get()).then(|| {
|
||||||
|
let navigate = navigate.clone();
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class="btn-start"
|
||||||
|
on:click=move |_| {
|
||||||
|
let fn_ = first_name.get();
|
||||||
|
let ln = last_name.get();
|
||||||
|
if fn_.is_empty() || ln.is_empty() { return; }
|
||||||
|
let navigate = navigate.clone();
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
match check_already_done(quiz_id, fn_.clone(), ln.clone()).await {
|
||||||
|
Ok(true) => {
|
||||||
|
already_done.set(true);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
navigate(
|
||||||
|
&format!("/quiz/{}?first_name={}&last_name={}", quiz_id, fn_, ln),
|
||||||
|
Default::default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Commencer le quiz →"
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! {
|
||||||
|
<script>"window.location.href='/login';"</script>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod home;
|
||||||
|
pub mod quiz;
|
||||||
|
pub mod result;
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
|
||||||
|
use crate::server::student::{get_quiz_with_questions, submit_quiz};
|
||||||
|
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
use gloo_timers::future::TimeoutFuture;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn QuizPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
let query = use_query_map();
|
||||||
|
|
||||||
|
let quiz_id = move || {
|
||||||
|
params.get().get("quiz_id")
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let first_name = move || query.get().get("first_name").unwrap_or_default();
|
||||||
|
let last_name = move || query.get().get("last_name").unwrap_or_default();
|
||||||
|
|
||||||
|
let quiz_data = Resource::new(quiz_id, |id| get_quiz_with_questions(id));
|
||||||
|
let answers = RwSignal::new(Vec::<(i64, i64)>::new());
|
||||||
|
let current = RwSignal::new(0usize);
|
||||||
|
let navigate = StoredValue::new(use_navigate());
|
||||||
|
let time_left = RwSignal::new(Option::<i64>::None);
|
||||||
|
let submitted = RwSignal::new(false);
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<div class="quiz-page">
|
||||||
|
<p style="text-align:center;color:var(--color-enuxia-muted);">"Chargement du quiz..."</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{move || quiz_data.get().map(|result| {
|
||||||
|
match result {
|
||||||
|
Ok(Some((quiz, questions))) => {
|
||||||
|
let total = questions.len();
|
||||||
|
let questions = StoredValue::new(questions);
|
||||||
|
let quiz_title = quiz.title.clone();
|
||||||
|
|
||||||
|
// Minuteur
|
||||||
|
if let Some(secs) = quiz.time_limit_seconds {
|
||||||
|
if time_left.get().is_none() {
|
||||||
|
time_left.set(Some(secs));
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
loop {
|
||||||
|
#[cfg(feature = "hydrate")]
|
||||||
|
TimeoutFuture::new(1000).await;
|
||||||
|
#[cfg(not(feature = "hydrate"))]
|
||||||
|
break;
|
||||||
|
|
||||||
|
if submitted.get() { break; }
|
||||||
|
let t = time_left.get().unwrap_or(0);
|
||||||
|
if t <= 1 {
|
||||||
|
time_left.set(Some(0));
|
||||||
|
if !submitted.get() {
|
||||||
|
submitted.set(true);
|
||||||
|
let fn_ = first_name();
|
||||||
|
let ln = last_name();
|
||||||
|
let qid = quiz_id();
|
||||||
|
let ans = answers.get();
|
||||||
|
let nav = navigate.get_value();
|
||||||
|
if let Ok(sid) = submit_quiz(qid, fn_, ln, ans).await {
|
||||||
|
nav(&format!("/result/{}", sid), Default::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
time_left.set(Some(t - 1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="quiz-page">
|
||||||
|
|
||||||
|
// ── Topbar ────────────────────────────────────
|
||||||
|
<div class="quiz-topbar">
|
||||||
|
<span class="quiz-name">{quiz_title.clone()}</span>
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;">
|
||||||
|
{move || time_left.get().map(|secs| {
|
||||||
|
let mins = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
let color = if secs <= 30 { "#f87171" }
|
||||||
|
else if secs <= 60 { "#fb923c" }
|
||||||
|
else { "var(--color-enuxia-muted)" };
|
||||||
|
view! {
|
||||||
|
<span style=format!(
|
||||||
|
"font-size:13px;font-weight:700;color:{};
|
||||||
|
padding:4px 12px;border-radius:20px;
|
||||||
|
background:rgba(0,0,0,0.2);
|
||||||
|
border:1px solid currentColor;
|
||||||
|
font-variant-numeric:tabular-nums;",
|
||||||
|
color
|
||||||
|
)>
|
||||||
|
{format!("⏱ {:02}:{:02}", mins, s)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
<span class="question-badge">
|
||||||
|
{move || format!("{} / {}", current.get() + 1, total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Barre de progression ──────────────────────
|
||||||
|
<div class="progress-track">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
style=move || format!(
|
||||||
|
"width:{}%",
|
||||||
|
(current.get().min(total) * 100) / total.max(1)
|
||||||
|
)
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Question ──────────────────────────────────
|
||||||
|
{move || {
|
||||||
|
let qs = questions.get_value();
|
||||||
|
let idx = current.get();
|
||||||
|
|
||||||
|
if idx >= qs.len() {
|
||||||
|
return view! { <span/> }.into_any();
|
||||||
|
}
|
||||||
|
|
||||||
|
let qwa = qs[idx].clone();
|
||||||
|
let question = qwa.question.clone();
|
||||||
|
let ans_list = qwa.answers.clone();
|
||||||
|
let q_id = question.id;
|
||||||
|
|
||||||
|
// Réponse actuellement sélectionnée pour cette question
|
||||||
|
let selected = move || {
|
||||||
|
answers.get().iter()
|
||||||
|
.find(|(qid, _)| *qid == q_id)
|
||||||
|
.map(|(_, aid)| *aid)
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="question-area">
|
||||||
|
<span class="question-section-tag">
|
||||||
|
{question.section.clone()}
|
||||||
|
</span>
|
||||||
|
<p class="question-text">{question.text.clone()}</p>
|
||||||
|
|
||||||
|
<div class="answers-list">
|
||||||
|
{ans_list.into_iter().map(|answer| {
|
||||||
|
let aid = answer.id;
|
||||||
|
let lbl = answer.label.clone();
|
||||||
|
let txt = answer.text.clone();
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
class=move || {
|
||||||
|
if selected() == Some(aid) {
|
||||||
|
"answer-option selected"
|
||||||
|
} else {
|
||||||
|
"answer-option"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
on:click=move |_| {
|
||||||
|
if submitted.get() { return; }
|
||||||
|
answers.update(|v| {
|
||||||
|
v.retain(|(q, _)| *q != q_id);
|
||||||
|
v.push((q_id, aid));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span class="opt-label">{lbl.clone()}</span>
|
||||||
|
{txt.clone()}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// ── Navigation ───────────────────
|
||||||
|
<div style="
|
||||||
|
display:flex;
|
||||||
|
align-items:center;
|
||||||
|
justify-content:space-between;
|
||||||
|
margin-top:8px;
|
||||||
|
gap:12px;
|
||||||
|
">
|
||||||
|
// Bouton précédent
|
||||||
|
{move || (current.get() > 0).then(|| view! {
|
||||||
|
<button
|
||||||
|
class="btn-ghost"
|
||||||
|
on:click=move |_| {
|
||||||
|
current.update(|c| *c -= 1);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"← Précédent"
|
||||||
|
</button>
|
||||||
|
})}
|
||||||
|
|
||||||
|
{move || (current.get() == 0).then(|| view! {
|
||||||
|
<span/>
|
||||||
|
})}
|
||||||
|
|
||||||
|
// Bouton suivant ou soumettre
|
||||||
|
{move || {
|
||||||
|
let idx = current.get();
|
||||||
|
let has_answer = selected().is_some();
|
||||||
|
|
||||||
|
if idx == total - 1 {
|
||||||
|
// Dernière question — bouton soumettre
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
style=move || format!(
|
||||||
|
"opacity:{};",
|
||||||
|
if has_answer { "1" } else { "0.4" }
|
||||||
|
)
|
||||||
|
class="btn-primary"
|
||||||
|
on:click=move |_| {
|
||||||
|
if !has_answer || submitted.get() { return; }
|
||||||
|
submitted.set(true);
|
||||||
|
let fn_ = first_name();
|
||||||
|
let ln = last_name();
|
||||||
|
let qid = quiz_id();
|
||||||
|
let ans = answers.get();
|
||||||
|
let nav = navigate.get_value();
|
||||||
|
leptos::task::spawn_local(async move {
|
||||||
|
if let Ok(sid) = submit_quiz(qid, fn_, ln, ans).await {
|
||||||
|
nav(&format!("/result/{}", sid), Default::default());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Soumettre le quiz →"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
// Question suivante
|
||||||
|
view! {
|
||||||
|
<button
|
||||||
|
style=move || format!(
|
||||||
|
"opacity:{};",
|
||||||
|
if has_answer { "1" } else { "0.4" }
|
||||||
|
)
|
||||||
|
class="btn-primary"
|
||||||
|
on:click=move |_| {
|
||||||
|
if !has_answer { return; }
|
||||||
|
current.update(|c| *c += 1);
|
||||||
|
}
|
||||||
|
>
|
||||||
|
"Suivant →"
|
||||||
|
</button>
|
||||||
|
}.into_any()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
}}
|
||||||
|
|
||||||
|
// ── Envoi en cours ────────────────────────────
|
||||||
|
{move || submitted.get().then(|| view! {
|
||||||
|
<div style="text-align:center;padding:24px 0;color:var(--color-enuxia-muted);">
|
||||||
|
<div class="spinner" style="margin:0 auto 12px;"/>
|
||||||
|
<p>"Envoi de vos réponses..."</p>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
},
|
||||||
|
_ => view! {
|
||||||
|
<div class="quiz-page">
|
||||||
|
<div class="field-error">"Quiz introuvable."</div>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_params_map;
|
||||||
|
use crate::server::student::get_result_details;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ResultPage() -> impl IntoView {
|
||||||
|
let params = use_params_map();
|
||||||
|
|
||||||
|
let submission_id = move || {
|
||||||
|
params.get().get("submission_id")
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0)
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = Resource::new(submission_id, |id| get_result_details(id));
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<Suspense fallback=|| view! {
|
||||||
|
<div class="result-page">
|
||||||
|
<p style="text-align:center;color:var(--color-enuxia-muted);">"Chargement..."</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
{move || result.get().map(|data| {
|
||||||
|
match data {
|
||||||
|
Ok(Some((submission, details))) => {
|
||||||
|
let pct = if submission.total > 0 {
|
||||||
|
(submission.score * 100) / submission.total
|
||||||
|
} else { 0 };
|
||||||
|
|
||||||
|
let color = if pct >= 80 { "#22C55E" }
|
||||||
|
else if pct >= 50 { "#4F8EFF" }
|
||||||
|
else { "#f87171" };
|
||||||
|
|
||||||
|
let tag_class = if pct >= 80 { "result-tag tag-great" }
|
||||||
|
else if pct >= 50 { "result-tag tag-ok" }
|
||||||
|
else { "result-tag tag-bad" };
|
||||||
|
|
||||||
|
let tag_text = if pct >= 80 { "🎉 Excellent travail !" }
|
||||||
|
else if pct >= 50 { "👍 Bon effort, continuez !" }
|
||||||
|
else { "📚 Révisez les points manqués." };
|
||||||
|
|
||||||
|
let r = 80.0_f64;
|
||||||
|
let circumference = 2.0 * std::f64::consts::PI * r;
|
||||||
|
let dash = (pct as f64 / 100.0) * circumference;
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<div class="result-page">
|
||||||
|
<div class="result-hero">
|
||||||
|
|
||||||
|
// Halo dynamique centré sur le cercle
|
||||||
|
<div style=format!(
|
||||||
|
"position:absolute;top:50%;left:50%;\
|
||||||
|
transform:translate(-50%,-50%);\
|
||||||
|
width:280px;height:280px;border-radius:50%;\
|
||||||
|
pointer-events:none;z-index:0;\
|
||||||
|
background:radial-gradient(ellipse, {}26 0%, transparent 65%);",
|
||||||
|
color
|
||||||
|
)/>
|
||||||
|
|
||||||
|
<p class="result-label" style="position:relative;z-index:1;">
|
||||||
|
"Résultats du quiz"
|
||||||
|
</p>
|
||||||
|
<p class="result-name" style="position:relative;z-index:1;">
|
||||||
|
{format!("{} {}", submission.first_name, submission.last_name)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
// Cercle SVG
|
||||||
|
<div class="score-ring-wrap" style="position:relative;z-index:1;">
|
||||||
|
<svg width="200" height="200" viewBox="0 0 200 200">
|
||||||
|
<circle
|
||||||
|
cx="100" cy="100" r="80"
|
||||||
|
fill="none"
|
||||||
|
stroke="#1A1D2E"
|
||||||
|
stroke-width="12"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="100" cy="100" r="80"
|
||||||
|
fill="none"
|
||||||
|
stroke=color
|
||||||
|
stroke-width="12"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-dasharray=format!("{} {}", dash, circumference)
|
||||||
|
stroke-dashoffset="0"
|
||||||
|
transform="rotate(-90 100 100)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="score-ring-inner">
|
||||||
|
<span class="score-ring-num"
|
||||||
|
style=format!("color:{};", color)>
|
||||||
|
{format!("{}/{}", submission.score, submission.total)}
|
||||||
|
</span>
|
||||||
|
<span class="score-ring-pct">{format!("{}%", pct)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class=tag_class style="position:relative;z-index:1;">
|
||||||
|
{tag_text}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="correction-section">
|
||||||
|
<h2>"Correction détaillée"</h2>
|
||||||
|
{details.into_iter().enumerate().map(|(idx, d)| {
|
||||||
|
let item_class = if d.is_correct {
|
||||||
|
"correction-item is-correct"
|
||||||
|
} else {
|
||||||
|
"correction-item is-wrong"
|
||||||
|
};
|
||||||
|
view! {
|
||||||
|
<div class=item_class>
|
||||||
|
<div class="corr-meta">
|
||||||
|
<span class="corr-qnum">
|
||||||
|
{format!("Q{}", idx + 1)}
|
||||||
|
</span>
|
||||||
|
<span class="corr-section">
|
||||||
|
{d.section.clone()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="corr-question">{d.question_text.clone()}</p>
|
||||||
|
<p class="corr-chosen">
|
||||||
|
{if d.is_correct { "✅ " } else { "❌ " }}
|
||||||
|
{format!("{}) {}", d.chosen_label, d.chosen_text)}
|
||||||
|
</p>
|
||||||
|
{if !d.is_correct {
|
||||||
|
view! {
|
||||||
|
<p class="corr-right">
|
||||||
|
{format!("✔ Bonne réponse : {}) {}",
|
||||||
|
d.correct_label, d.correct_text)}
|
||||||
|
</p>
|
||||||
|
}.into_any()
|
||||||
|
} else {
|
||||||
|
view! { <span/> }.into_any()
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}).collect_view()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/" class="btn-home-link">"← Retour à l'accueil"</a>
|
||||||
|
</div>
|
||||||
|
}.into_any()
|
||||||
|
},
|
||||||
|
_ => view! {
|
||||||
|
<div class="result-page">
|
||||||
|
<div class="field-error">"Résultats introuvables."</div>
|
||||||
|
</div>
|
||||||
|
}.into_any(),
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use crate::models::*;
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_all_quizzes() -> Result<Vec<Quiz>, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let quizzes = sqlx::query_as!(
|
||||||
|
Quiz,
|
||||||
|
r#"SELECT id, title, description,
|
||||||
|
active as "active: bool",
|
||||||
|
shuffle_questions as "shuffle_questions: bool",
|
||||||
|
shuffle_answers as "shuffle_answers: bool",
|
||||||
|
time_limit_seconds,
|
||||||
|
created_at as "created_at: chrono::DateTime<chrono::Utc>"
|
||||||
|
FROM quizzes ORDER BY created_at DESC"#
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(quizzes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn create_quiz(title: String, description: String) -> Result<i64, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let id = sqlx::query!(
|
||||||
|
"INSERT INTO quizzes (title, description) VALUES (?, ?)",
|
||||||
|
title, description
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn update_quiz(
|
||||||
|
id: i64,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
active: bool,
|
||||||
|
shuffle_questions: bool,
|
||||||
|
shuffle_answers: bool,
|
||||||
|
time_limit_seconds: Option<i64>,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"UPDATE quizzes
|
||||||
|
SET title = ?, description = ?, active = ?,
|
||||||
|
shuffle_questions = ?, shuffle_answers = ?,
|
||||||
|
time_limit_seconds = ?
|
||||||
|
WHERE id = ?"#,
|
||||||
|
title, description, active,
|
||||||
|
shuffle_questions, shuffle_answers,
|
||||||
|
time_limit_seconds, id
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn delete_quiz(id: i64) -> Result<(), ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
sqlx::query!("DELETE FROM quizzes WHERE id = ?", id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn add_question(
|
||||||
|
quiz_id: i64,
|
||||||
|
text: String,
|
||||||
|
section: String,
|
||||||
|
position: i64,
|
||||||
|
) -> Result<i64, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let id = sqlx::query!(
|
||||||
|
"INSERT INTO questions (quiz_id, text, section, position) VALUES (?, ?, ?, ?)",
|
||||||
|
quiz_id, text, section, position
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn update_question(
|
||||||
|
id: i64,
|
||||||
|
text: String,
|
||||||
|
section: String,
|
||||||
|
position: i64,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE questions SET text = ?, section = ?, position = ? WHERE id = ?",
|
||||||
|
text, section, position, id
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn delete_question(id: i64) -> Result<(), ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
sqlx::query!("DELETE FROM questions WHERE id = ?", id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn add_answer(
|
||||||
|
question_id: i64,
|
||||||
|
label: String,
|
||||||
|
text: String,
|
||||||
|
correct: bool,
|
||||||
|
) -> Result<i64, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let id = sqlx::query!(
|
||||||
|
"INSERT INTO answers (question_id, label, text, correct) VALUES (?, ?, ?, ?)",
|
||||||
|
question_id, label, text, correct
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn update_answer(
|
||||||
|
id: i64,
|
||||||
|
label: String,
|
||||||
|
text: String,
|
||||||
|
correct: bool,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE answers SET label = ?, text = ?, correct = ? WHERE id = ?",
|
||||||
|
label, text, correct, id
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn delete_answer(id: i64) -> Result<(), ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
sqlx::query!("DELETE FROM answers WHERE id = ?", id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_quiz_submissions(quiz_id: i64) -> Result<Vec<QuizSubmission>, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let submissions = sqlx::query_as!(
|
||||||
|
QuizSubmission,
|
||||||
|
r#"SELECT id, quiz_id, first_name, last_name, score, total,
|
||||||
|
submitted_at as "submitted_at: chrono::DateTime<chrono::Utc>"
|
||||||
|
FROM submissions WHERE quiz_id = ?
|
||||||
|
ORDER BY submitted_at DESC"#,
|
||||||
|
quiz_id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(submissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_submission_detail(
|
||||||
|
submission_id: i64,
|
||||||
|
) -> Result<Option<(QuizSubmission, Vec<(SubmissionAnswer, Question, Answer)>)>, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let submission = sqlx::query_as!(
|
||||||
|
QuizSubmission,
|
||||||
|
r#"SELECT id, quiz_id, first_name, last_name, score, total,
|
||||||
|
submitted_at as "submitted_at: chrono::DateTime<chrono::Utc>"
|
||||||
|
FROM submissions WHERE id = ?"#,
|
||||||
|
submission_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let Some(submission) = submission else { return Ok(None); };
|
||||||
|
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"SELECT
|
||||||
|
sa.id as sa_id,
|
||||||
|
sa.submission_id,
|
||||||
|
sa.question_id,
|
||||||
|
sa.answer_id,
|
||||||
|
sa.correct as "sa_correct: bool",
|
||||||
|
q.quiz_id,
|
||||||
|
q.text as q_text,
|
||||||
|
q.section,
|
||||||
|
q.position,
|
||||||
|
a.label,
|
||||||
|
a.text as a_text,
|
||||||
|
a.correct as "a_correct: bool"
|
||||||
|
FROM student_answers sa
|
||||||
|
JOIN questions q ON q.id = sa.question_id
|
||||||
|
JOIN answers a ON a.id = sa.answer_id
|
||||||
|
WHERE sa.submission_id = ?
|
||||||
|
ORDER BY q.position"#,
|
||||||
|
submission_id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let details = rows.into_iter().map(|row| {
|
||||||
|
let sa = SubmissionAnswer {
|
||||||
|
id: row.sa_id,
|
||||||
|
submission_id: row.submission_id,
|
||||||
|
question_id: row.question_id,
|
||||||
|
answer_id: row.answer_id,
|
||||||
|
correct: row.sa_correct,
|
||||||
|
};
|
||||||
|
let q = Question {
|
||||||
|
id: row.question_id,
|
||||||
|
quiz_id: row.quiz_id,
|
||||||
|
text: row.q_text,
|
||||||
|
section: row.section,
|
||||||
|
position: row.position,
|
||||||
|
};
|
||||||
|
let a = Answer {
|
||||||
|
id: row.answer_id,
|
||||||
|
question_id: row.question_id,
|
||||||
|
label: row.label,
|
||||||
|
text: row.a_text,
|
||||||
|
correct: row.a_correct,
|
||||||
|
};
|
||||||
|
(sa, q, a)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Some((submission, details)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn export_submissions_csv(quiz_id: i64) -> Result<String, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
// Récupère le titre du quiz pour le nom de fichier
|
||||||
|
let quiz_title = sqlx::query_scalar!(
|
||||||
|
"SELECT title FROM quizzes WHERE id = ?", quiz_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.unwrap_or_else(|| "quiz".to_string());
|
||||||
|
|
||||||
|
let submissions = sqlx::query_as!(
|
||||||
|
QuizSubmission,
|
||||||
|
r#"SELECT id, quiz_id, first_name, last_name, score, total,
|
||||||
|
submitted_at as "submitted_at: chrono::DateTime<chrono::Utc>"
|
||||||
|
FROM submissions WHERE quiz_id = ?
|
||||||
|
ORDER BY last_name, first_name"#,
|
||||||
|
quiz_id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
// En-tête avec le titre du quiz
|
||||||
|
let mut csv = format!("Quiz: {}\n", quiz_title);
|
||||||
|
csv.push_str("Nom,Prénom,Score,Total,Pourcentage,Date\n");
|
||||||
|
|
||||||
|
for s in submissions {
|
||||||
|
let pct = if s.total > 0 { (s.score * 100) / s.total } else { 0 };
|
||||||
|
csv.push_str(&format!(
|
||||||
|
"{},{},{},{},{}%,{}\n",
|
||||||
|
s.last_name, s.first_name,
|
||||||
|
s.score, s.total, pct,
|
||||||
|
s.submitted_at.format("%d/%m/%Y %H:%M")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(csv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reset étudiant ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn reset_student(
|
||||||
|
quiz_id: i64,
|
||||||
|
first_name: String,
|
||||||
|
last_name: String,
|
||||||
|
) -> Result<(), ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let fn_lower = first_name.trim().to_lowercase();
|
||||||
|
let ln_lower = last_name.trim().to_lowercase();
|
||||||
|
|
||||||
|
// Supprime toutes les soumissions de cet étudiant pour ce quiz
|
||||||
|
sqlx::query!(
|
||||||
|
r#"DELETE FROM submissions
|
||||||
|
WHERE quiz_id = ?
|
||||||
|
AND LOWER(TRIM(first_name)) = ?
|
||||||
|
AND LOWER(TRIM(last_name)) = ?"#,
|
||||||
|
quiz_id, fn_lower, ln_lower
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
// Supprime aussi les anciens resets (utilisés ou non) pour repartir propre
|
||||||
|
sqlx::query!(
|
||||||
|
r#"DELETE FROM resets
|
||||||
|
WHERE quiz_id = ?
|
||||||
|
AND LOWER(TRIM(first_name)) = ?
|
||||||
|
AND LOWER(TRIM(last_name)) = ?"#,
|
||||||
|
quiz_id, fn_lower, ln_lower
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
// Ajoute un nouveau reset token frais
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO resets (quiz_id, first_name, last_name) VALUES (?, ?, ?)",
|
||||||
|
quiz_id, fn_lower, ln_lower // ← lowercase, pas les originaux
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_resets(quiz_id: i64) -> Result<Vec<Reset>, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let resets = sqlx::query_as!(
|
||||||
|
Reset,
|
||||||
|
r#"SELECT id, quiz_id, first_name, last_name, used as "used: bool"
|
||||||
|
FROM resets WHERE quiz_id = ? ORDER BY created_at DESC"#,
|
||||||
|
quiz_id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(resets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stats ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_quiz_stats(quiz_id: i64) -> Result<QuizStats, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let submissions = sqlx::query_as!(
|
||||||
|
QuizSubmission,
|
||||||
|
r#"SELECT id, quiz_id, first_name, last_name, score, total,
|
||||||
|
submitted_at as "submitted_at: chrono::DateTime<chrono::Utc>"
|
||||||
|
FROM submissions WHERE quiz_id = ? ORDER BY score DESC"#,
|
||||||
|
quiz_id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let total_submissions = submissions.len() as i64;
|
||||||
|
|
||||||
|
if total_submissions == 0 {
|
||||||
|
return Ok(QuizStats {
|
||||||
|
total_submissions: 0,
|
||||||
|
average_score: 0.0,
|
||||||
|
average_pct: 0.0,
|
||||||
|
score_distribution: vec![],
|
||||||
|
question_stats: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let average_score = submissions.iter()
|
||||||
|
.map(|s| s.score as f64)
|
||||||
|
.sum::<f64>() / total_submissions as f64;
|
||||||
|
|
||||||
|
let average_pct = submissions.iter()
|
||||||
|
.map(|s| if s.total > 0 { (s.score as f64 / s.total as f64) * 100.0 } else { 0.0 })
|
||||||
|
.sum::<f64>() / total_submissions as f64;
|
||||||
|
|
||||||
|
// Distribution des scores
|
||||||
|
let mut dist_map = std::collections::HashMap::new();
|
||||||
|
for s in &submissions {
|
||||||
|
*dist_map.entry(s.score).or_insert(0i64) += 1;
|
||||||
|
}
|
||||||
|
let mut score_distribution: Vec<(i64, i64)> = dist_map.into_iter().collect();
|
||||||
|
score_distribution.sort_by_key(|(score, _)| *score);
|
||||||
|
|
||||||
|
// Stats par question
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"SELECT
|
||||||
|
q.id as question_id,
|
||||||
|
q.text as question_text,
|
||||||
|
q.section,
|
||||||
|
COUNT(sa.id) as "total_count: i64",
|
||||||
|
SUM(CASE WHEN sa.correct = 1 THEN 1 ELSE 0 END) as "correct_count: i64"
|
||||||
|
FROM questions q
|
||||||
|
LEFT JOIN student_answers sa ON sa.question_id = q.id
|
||||||
|
LEFT JOIN submissions s ON s.id = sa.submission_id AND s.quiz_id = ?
|
||||||
|
WHERE q.quiz_id = ?
|
||||||
|
GROUP BY q.id
|
||||||
|
ORDER BY q.position"#,
|
||||||
|
quiz_id, quiz_id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let question_stats = rows.into_iter().map(|row| {
|
||||||
|
let total = row.total_count;
|
||||||
|
let correct = row.correct_count;
|
||||||
|
let rate = if total > 0 { correct as f64 / total as f64 * 100.0 } else { 0.0 };
|
||||||
|
QuestionStat {
|
||||||
|
question_id: row.question_id,
|
||||||
|
question_text: row.question_text,
|
||||||
|
section: row.section,
|
||||||
|
correct_count: correct,
|
||||||
|
total_count: total,
|
||||||
|
success_rate: rate,
|
||||||
|
}
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(QuizStats {
|
||||||
|
total_submissions,
|
||||||
|
average_score,
|
||||||
|
average_pct,
|
||||||
|
score_distribution,
|
||||||
|
question_stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
pub mod student;
|
||||||
|
pub mod admin;
|
||||||
|
|
||||||
|
use leptos::prelude::*;
|
||||||
|
|
||||||
|
// ── Config helpers (SSR only) ─────────────────────────────────────────────────
|
||||||
|
#[cfg(feature = "ssr")]
|
||||||
|
pub async fn get_config_value(pool: &sqlx::SqlitePool, key: &str, default: &str) -> String {
|
||||||
|
sqlx::query_scalar!("SELECT value FROM config WHERE key = ?", key)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| default.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn login(password: String, is_admin: bool) -> Result<bool, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||||
|
use leptos_axum::ResponseOptions;
|
||||||
|
use axum::http::header;
|
||||||
|
use crate::middleware::{SESSION_COOKIE, ADMIN_COOKIE};
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let key = if is_admin { "admin_password" } else { "session_password" };
|
||||||
|
let default = if is_admin { "admin" } else { "changeme" };
|
||||||
|
let expected = get_config_value(&pool, key, default).await;
|
||||||
|
|
||||||
|
if password != expected {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cookie_name = if is_admin { ADMIN_COOKIE } else { SESSION_COOKIE };
|
||||||
|
let cookie = Cookie::build((cookie_name, password))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let response = expect_context::<ResponseOptions>();
|
||||||
|
response.append_header(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
cookie.to_string().parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn logout(is_admin: bool) -> Result<(), ServerFnError> {
|
||||||
|
use axum_extra::extract::cookie::{Cookie, SameSite};
|
||||||
|
use leptos_axum::ResponseOptions;
|
||||||
|
use axum::http::header;
|
||||||
|
use crate::middleware::{SESSION_COOKIE, ADMIN_COOKIE};
|
||||||
|
|
||||||
|
let cookie_name = if is_admin { ADMIN_COOKIE } else { SESSION_COOKIE };
|
||||||
|
let cookie = Cookie::build((cookie_name, ""))
|
||||||
|
.path("/")
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(time::Duration::seconds(0))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let response = expect_context::<ResponseOptions>();
|
||||||
|
response.append_header(
|
||||||
|
header::SET_COOKIE,
|
||||||
|
cookie.to_string().parse().unwrap(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn check_session_valid() -> Result<bool, ServerFnError> {
|
||||||
|
use crate::middleware::check_session;
|
||||||
|
Ok(check_session().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn check_admin_valid() -> Result<bool, ServerFnError> {
|
||||||
|
use crate::middleware::check_admin;
|
||||||
|
Ok(check_admin().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_config(key: String) -> Result<String, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
Ok(get_config_value(&pool, &key, "").await)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn set_config(key: String, value: String) -> Result<(), ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?",
|
||||||
|
key, value, value
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
use leptos::prelude::*;
|
||||||
|
use crate::models::*;
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_active_quizzes() -> Result<Vec<Quiz>, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let quizzes = sqlx::query_as!(
|
||||||
|
Quiz,
|
||||||
|
r#"SELECT id, title, description,
|
||||||
|
active as "active: bool",
|
||||||
|
shuffle_questions as "shuffle_questions: bool",
|
||||||
|
shuffle_answers as "shuffle_answers: bool",
|
||||||
|
time_limit_seconds,
|
||||||
|
created_at as "created_at: chrono::DateTime<chrono::Utc>"
|
||||||
|
FROM quizzes WHERE active = 1 ORDER BY created_at DESC"#
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(quizzes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_quiz_with_questions(quiz_id: i64) -> Result<Option<(Quiz, Vec<QuestionWithAnswers>)>, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let quiz = sqlx::query_as!(
|
||||||
|
Quiz,
|
||||||
|
r#"SELECT id, title, description,
|
||||||
|
active as "active: bool",
|
||||||
|
shuffle_questions as "shuffle_questions: bool",
|
||||||
|
shuffle_answers as "shuffle_answers: bool",
|
||||||
|
time_limit_seconds,
|
||||||
|
created_at as "created_at: chrono::DateTime<chrono::Utc>"
|
||||||
|
FROM quizzes WHERE id = ?"#,
|
||||||
|
quiz_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let Some(quiz) = quiz else { return Ok(None) };
|
||||||
|
|
||||||
|
let questions = sqlx::query_as!(
|
||||||
|
Question,
|
||||||
|
"SELECT id, quiz_id, text, section, position FROM questions WHERE quiz_id = ? ORDER BY position",
|
||||||
|
quiz_id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for question in questions {
|
||||||
|
let mut answers = sqlx::query_as!(
|
||||||
|
Answer,
|
||||||
|
r#"SELECT id, question_id, label, text, correct as "correct: bool"
|
||||||
|
FROM answers WHERE question_id = ? ORDER BY label"#,
|
||||||
|
question.id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
if quiz.shuffle_answers {
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
question.id.hash(&mut hasher);
|
||||||
|
chrono::Utc::now().timestamp_subsec_nanos().hash(&mut hasher);
|
||||||
|
let seed = hasher.finish();
|
||||||
|
let n = answers.len();
|
||||||
|
let mut rng_state = seed;
|
||||||
|
for i in (1..n).rev() {
|
||||||
|
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||||
|
let j = (rng_state >> 33) as usize % (i + 1);
|
||||||
|
answers.swap(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(QuestionWithAnswers { question, answers });
|
||||||
|
}
|
||||||
|
|
||||||
|
if quiz.shuffle_questions {
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
quiz_id.hash(&mut hasher);
|
||||||
|
chrono::Utc::now().timestamp_subsec_nanos().hash(&mut hasher);
|
||||||
|
let seed = hasher.finish();
|
||||||
|
let n = result.len();
|
||||||
|
let mut rng_state = seed;
|
||||||
|
for i in (1..n).rev() {
|
||||||
|
rng_state = rng_state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||||
|
let j = (rng_state >> 33) as usize % (i + 1);
|
||||||
|
result.swap(i, j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some((quiz, result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn check_already_done(
|
||||||
|
quiz_id: i64,
|
||||||
|
first_name: String,
|
||||||
|
last_name: String,
|
||||||
|
) -> Result<bool, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let fn_lower = first_name.trim().to_lowercase();
|
||||||
|
let ln_lower = last_name.trim().to_lowercase();
|
||||||
|
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"SELECT
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM submissions
|
||||||
|
WHERE quiz_id = ?
|
||||||
|
AND LOWER(TRIM(first_name)) = ?
|
||||||
|
AND LOWER(TRIM(last_name)) = ?
|
||||||
|
) as existing,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM resets
|
||||||
|
WHERE quiz_id = ?
|
||||||
|
AND first_name = ?
|
||||||
|
AND last_name = ?
|
||||||
|
AND used = 0
|
||||||
|
) as has_reset"#,
|
||||||
|
quiz_id, fn_lower, ln_lower,
|
||||||
|
quiz_id, fn_lower, ln_lower
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
if row.existing == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(row.has_reset == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn submit_quiz(
|
||||||
|
quiz_id: i64,
|
||||||
|
first_name: String,
|
||||||
|
last_name: String,
|
||||||
|
answers: Vec<(i64, i64)>,
|
||||||
|
) -> Result<i64, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let fn_lower = first_name.trim().to_lowercase();
|
||||||
|
let ln_lower = last_name.trim().to_lowercase();
|
||||||
|
|
||||||
|
// ── Anti-doublon via EXISTS ───────────────────────────────────────────────
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"SELECT
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM submissions
|
||||||
|
WHERE quiz_id = ?
|
||||||
|
AND first_name = ?
|
||||||
|
AND last_name = ?
|
||||||
|
) as existing,
|
||||||
|
EXISTS(
|
||||||
|
SELECT 1 FROM resets
|
||||||
|
WHERE quiz_id = ?
|
||||||
|
AND first_name = ?
|
||||||
|
AND last_name = ?
|
||||||
|
AND used = 0
|
||||||
|
) as has_reset"#,
|
||||||
|
quiz_id, fn_lower, ln_lower,
|
||||||
|
quiz_id, fn_lower, ln_lower
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
// Consomme le reset s'il existe (qu'il y ait une soumission ou non)
|
||||||
|
if row.has_reset != 0 {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE resets SET used = 1
|
||||||
|
WHERE quiz_id = ?
|
||||||
|
AND first_name = ?
|
||||||
|
AND last_name = ?
|
||||||
|
AND used = 0",
|
||||||
|
quiz_id, fn_lower, ln_lower
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bloque si soumission existante ET pas de reset accordé
|
||||||
|
if row.existing != 0 && row.has_reset == 0 {
|
||||||
|
return Err(ServerFnError::new("Vous avez déjà passé ce quiz."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Score ─────────────────────────────────────────────────────────────────
|
||||||
|
let mut score = 0i64;
|
||||||
|
let total = answers.len() as i64;
|
||||||
|
|
||||||
|
for (_, answer_id) in &answers {
|
||||||
|
let correct = sqlx::query_scalar!(
|
||||||
|
r#"SELECT correct as "correct: bool" FROM answers WHERE id = ?"#,
|
||||||
|
answer_id
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
if correct { score += 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Insertion ─────────────────────────────────────────────────────────────
|
||||||
|
let submission_id = sqlx::query!(
|
||||||
|
"INSERT INTO submissions (quiz_id, first_name, last_name, score, total) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
quiz_id, fn_lower, ln_lower, score, total
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||||||
|
.last_insert_rowid();
|
||||||
|
|
||||||
|
for (question_id, answer_id) in &answers {
|
||||||
|
let correct = sqlx::query_scalar!(
|
||||||
|
r#"SELECT correct as "correct: bool" FROM answers WHERE id = ?"#,
|
||||||
|
answer_id
|
||||||
|
)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO student_answers (submission_id, question_id, answer_id, correct) VALUES (?, ?, ?, ?)",
|
||||||
|
submission_id, question_id, answer_id, correct
|
||||||
|
)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(submission_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_submission_result(submission_id: i64) -> Result<Option<(QuizSubmission, Vec<(SubmissionAnswer, Question, Answer)>)>, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let submission = sqlx::query_as!(
|
||||||
|
QuizSubmission,
|
||||||
|
r#"SELECT id, quiz_id, first_name, last_name, score, total,
|
||||||
|
submitted_at as "submitted_at: chrono::DateTime<chrono::Utc>"
|
||||||
|
FROM submissions WHERE id = ?"#,
|
||||||
|
submission_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let Some(submission) = submission else { return Ok(None) };
|
||||||
|
|
||||||
|
let details = sqlx::query!(
|
||||||
|
r#"SELECT
|
||||||
|
sa.id as sa_id,
|
||||||
|
sa.submission_id,
|
||||||
|
sa.question_id,
|
||||||
|
sa.answer_id,
|
||||||
|
sa.correct as "correct: bool",
|
||||||
|
q.text as question_text,
|
||||||
|
q.section,
|
||||||
|
q.position,
|
||||||
|
q.quiz_id,
|
||||||
|
a.label,
|
||||||
|
a.text as answer_text
|
||||||
|
FROM student_answers sa
|
||||||
|
JOIN questions q ON q.id = sa.question_id
|
||||||
|
JOIN answers a ON a.id = sa.answer_id
|
||||||
|
WHERE sa.submission_id = ?
|
||||||
|
ORDER BY q.position"#,
|
||||||
|
submission_id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let result = details.into_iter().map(|row| {
|
||||||
|
let student_answer = SubmissionAnswer {
|
||||||
|
id: row.sa_id,
|
||||||
|
submission_id: row.submission_id,
|
||||||
|
question_id: row.question_id,
|
||||||
|
answer_id: row.answer_id,
|
||||||
|
correct: row.correct,
|
||||||
|
};
|
||||||
|
let question = Question {
|
||||||
|
id: row.question_id,
|
||||||
|
quiz_id: row.quiz_id,
|
||||||
|
text: row.question_text,
|
||||||
|
section: row.section,
|
||||||
|
position: row.position,
|
||||||
|
};
|
||||||
|
let answer = Answer {
|
||||||
|
id: row.answer_id,
|
||||||
|
question_id: row.question_id,
|
||||||
|
label: row.label,
|
||||||
|
text: row.answer_text,
|
||||||
|
correct: row.correct,
|
||||||
|
};
|
||||||
|
(student_answer, question, answer)
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Some((submission, result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
pub async fn get_result_details(submission_id: i64) -> Result<Option<(QuizSubmission, Vec<crate::models::ResultDetail>)>, ServerFnError> {
|
||||||
|
use axum::Extension;
|
||||||
|
use leptos_axum::extract;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use crate::models::ResultDetail;
|
||||||
|
|
||||||
|
let Extension(pool): Extension<SqlitePool> = extract().await?;
|
||||||
|
|
||||||
|
let submission = sqlx::query_as!(
|
||||||
|
QuizSubmission,
|
||||||
|
r#"SELECT id, quiz_id, first_name, last_name, score, total,
|
||||||
|
submitted_at as "submitted_at: chrono::DateTime<chrono::Utc>"
|
||||||
|
FROM submissions WHERE id = ?"#,
|
||||||
|
submission_id
|
||||||
|
)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let Some(submission) = submission else { return Ok(None) };
|
||||||
|
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"SELECT
|
||||||
|
q.text as q_text,
|
||||||
|
q.section,
|
||||||
|
a.label as chosen_label,
|
||||||
|
a.text as chosen_text,
|
||||||
|
sa.correct as "is_correct: bool",
|
||||||
|
correct_a.label as correct_label,
|
||||||
|
correct_a.text as correct_text
|
||||||
|
FROM student_answers sa
|
||||||
|
JOIN questions q ON q.id = sa.question_id
|
||||||
|
JOIN answers a ON a.id = sa.answer_id
|
||||||
|
JOIN answers correct_a ON correct_a.question_id = sa.question_id
|
||||||
|
AND correct_a.correct = 1
|
||||||
|
WHERE sa.submission_id = ?
|
||||||
|
ORDER BY q.position"#,
|
||||||
|
submission_id
|
||||||
|
)
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
|
||||||
|
let details = rows.into_iter().map(|row| ResultDetail {
|
||||||
|
question_text: row.q_text,
|
||||||
|
section: row.section,
|
||||||
|
chosen_label: row.chosen_label,
|
||||||
|
chosen_text: row.chosen_text,
|
||||||
|
correct_label: row.correct_label.unwrap_or_default(),
|
||||||
|
correct_text: row.correct_text.unwrap_or_default(),
|
||||||
|
is_correct: row.is_correct,
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(Some((submission, details)))
|
||||||
|
}
|
||||||
+902
@@ -0,0 +1,902 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap');
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-enuxia-dark: #0A0B14;
|
||||||
|
--color-enuxia-dark-2: #0E0F1A;
|
||||||
|
--color-enuxia-dark-3: #12141F;
|
||||||
|
--color-enuxia-border: #1A1D2E;
|
||||||
|
--color-enuxia-border-2:#242740;
|
||||||
|
--color-enuxia-blue: #4F8EFF;
|
||||||
|
--color-enuxia-purple: #8B5CF6;
|
||||||
|
--color-enuxia-green: #22C55E;
|
||||||
|
--color-enuxia-text: #E8EAF0;
|
||||||
|
--color-enuxia-muted: #5A6080;
|
||||||
|
--color-enuxia-subtle: #2A2D40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset & Base ─────────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-enuxia-dark);
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
font-family: 'Inter', system-ui, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse 100% 60% at 50% -10%, rgba(79, 142, 255, 0.06) 0%, transparent 70%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 90% 90%, rgba(139, 92, 246, 0.04) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { text-decoration: none; color: inherit; }
|
||||||
|
|
||||||
|
/* ── Inputs ───────────────────────────────────────────────────────────────── */
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
background-color: var(--color-enuxia-dark);
|
||||||
|
border: 1px solid var(--color-enuxia-border-2);
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]::placeholder,
|
||||||
|
input[type="password"]::placeholder,
|
||||||
|
textarea::placeholder { color: var(--color-enuxia-muted); }
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="password"]:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--color-enuxia-blue);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 142, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea { resize: vertical; min-height: 80px; }
|
||||||
|
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
accent-color: var(--color-enuxia-blue);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Boutons globaux ──────────────────────────────────────────────────────── */
|
||||||
|
button {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 11px 22px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-enuxia-blue);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #3a7aff;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 20px rgba(79, 142, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--color-enuxia-border);
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
border: 1px solid var(--color-enuxia-border-2);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover {
|
||||||
|
background: var(--color-enuxia-subtle);
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-green {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
color: var(--color-enuxia-green);
|
||||||
|
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||||
|
}
|
||||||
|
.btn-green:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
box-shadow: 0 4px 16px rgba(34, 197, 94, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: rgba(239, 68, 68, 0.18); }
|
||||||
|
|
||||||
|
.btn-purple {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(139, 92, 246, 0.12);
|
||||||
|
color: var(--color-enuxia-purple);
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.25);
|
||||||
|
}
|
||||||
|
.btn-purple:hover { background: rgba(139, 92, 246, 0.2); }
|
||||||
|
|
||||||
|
/* ── Messages ─────────────────────────────────────────────────────────────── */
|
||||||
|
.msg-error {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg-empty {
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utilitaires ──────────────────────────────────────────────────────────── */
|
||||||
|
.divider { height: 1px; background: var(--color-enuxia-border); width: 100%; }
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════════
|
||||||
|
LOGIN
|
||||||
|
════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-bg-glow {
|
||||||
|
position: absolute;
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(ellipse, rgba(79, 142, 255, 0.08) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-glow {
|
||||||
|
background: radial-gradient(ellipse, rgba(139, 92, 246, 0.08) 0%, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
background: var(--color-enuxia-dark-2);
|
||||||
|
border: 1px solid var(--color-enuxia-border-2);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 40px 80px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
margin: 2px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(139, 92, 246, 0.15);
|
||||||
|
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-divider { height: 1px; background: var(--color-enuxia-border); }
|
||||||
|
|
||||||
|
.login-form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwd-wrapper { position: relative; }
|
||||||
|
|
||||||
|
.pwd-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px 46px 13px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background: var(--color-enuxia-dark);
|
||||||
|
border: 1px solid var(--color-enuxia-border-2);
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
.pwd-input:focus {
|
||||||
|
border-color: var(--color-enuxia-blue);
|
||||||
|
box-shadow: 0 0 0 3px rgba(79, 142, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pwd-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.pwd-toggle:hover { color: var(--color-enuxia-text); }
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #f87171;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-submit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
background: var(--color-enuxia-blue);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 50px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.btn-submit:hover:not(.loading) {
|
||||||
|
background: #3a7aff;
|
||||||
|
box-shadow: 0 6px 24px rgba(79, 142, 255, 0.35);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.btn-submit.loading {
|
||||||
|
background: rgba(79, 142, 255, 0.5);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.login-footer a {
|
||||||
|
color: var(--color-enuxia-blue);
|
||||||
|
font-weight: 500;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.login-footer a:hover { color: #7aaeff; }
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════════
|
||||||
|
HOME ÉTUDIANT
|
||||||
|
════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
.home-page {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 64px 24px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-grid { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.quiz-item {
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: var(--color-enuxia-dark-3);
|
||||||
|
border: 1px solid var(--color-enuxia-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.quiz-item:hover {
|
||||||
|
border-color: var(--color-enuxia-border-2);
|
||||||
|
background: var(--color-enuxia-dark-2);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
.quiz-item.selected {
|
||||||
|
border-color: var(--color-enuxia-blue);
|
||||||
|
background: rgba(79, 142, 255, 0.05);
|
||||||
|
box-shadow: 0 0 0 1px rgba(79, 142, 255, 0.15);
|
||||||
|
}
|
||||||
|
.quiz-item .quiz-title {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
}
|
||||||
|
.quiz-item .quiz-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.quiz-item .arrow {
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
transition: transform 0.2s, color 0.2s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.quiz-item.selected .arrow {
|
||||||
|
color: var(--color-enuxia-blue);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-item.quiz-done {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
border-color: rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-item.quiz-done:hover {
|
||||||
|
transform: none;
|
||||||
|
background: var(--color-enuxia-dark-3);
|
||||||
|
border-color: rgba(34, 197, 94, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-form {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--color-enuxia-dark-3);
|
||||||
|
border: 1px solid var(--color-enuxia-border-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
animation: slideDown 0.25s ease;
|
||||||
|
}
|
||||||
|
@keyframes slideDown {
|
||||||
|
from { opacity: 0; transform: translateY(-8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.identity-form .form-title {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
}
|
||||||
|
.identity-form .btn-start {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-enuxia-blue);
|
||||||
|
color: white;
|
||||||
|
margin-top: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.identity-form .btn-start:hover {
|
||||||
|
background: #3a7aff;
|
||||||
|
box-shadow: 0 4px 20px rgba(79, 142, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════════
|
||||||
|
QUIZ PAGE
|
||||||
|
════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
.quiz-page {
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quiz-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.quiz-topbar .quiz-name {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
}
|
||||||
|
.quiz-topbar .question-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: var(--color-enuxia-border);
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: var(--color-enuxia-border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: linear-gradient(90deg, var(--color-enuxia-blue), var(--color-enuxia-purple));
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-area { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.question-section-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-enuxia-purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
.question-text {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answers-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
|
||||||
|
.answer-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s;
|
||||||
|
background: var(--color-enuxia-dark-3);
|
||||||
|
border: 1px solid var(--color-enuxia-border);
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.answer-option:hover {
|
||||||
|
border-color: var(--color-enuxia-blue);
|
||||||
|
background: rgba(79, 142, 255, 0.06);
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
.answer-option .opt-label {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--color-enuxia-border);
|
||||||
|
color: var(--color-enuxia-blue);
|
||||||
|
transition: all 0.18s;
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
}
|
||||||
|
.answer-option:hover .opt-label {
|
||||||
|
background: rgba(79, 142, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-option.selected {
|
||||||
|
border-color: var(--color-enuxia-blue);
|
||||||
|
background: rgba(79, 142, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-option.selected .opt-label {
|
||||||
|
background: rgba(79, 142, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════════
|
||||||
|
RÉSULTAT
|
||||||
|
════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
.result-page {
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 24px 80px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 52px 40px 44px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: var(--color-enuxia-dark-2);
|
||||||
|
border: 1px solid var(--color-enuxia-border-2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -60%);
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(ellipse, rgba(79, 142, 255, 0.09) 0%, transparent 65%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-name {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-ring-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-ring-inner {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 120px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-ring-num {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-ring-pct {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tag-great { background:rgba(34,197,94,0.1); color:#22C55E; border:1px solid rgba(34,197,94,0.25); }
|
||||||
|
.tag-ok { background:rgba(79,142,255,0.1); color:#4F8EFF; border:1px solid rgba(79,142,255,0.25); }
|
||||||
|
.tag-bad { background:rgba(248,113,113,0.1); color:#f87171; border:1px solid rgba(248,113,113,0.25); }
|
||||||
|
|
||||||
|
.score-pct {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-section { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
.correction-section h2 {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.correction-item {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--color-enuxia-dark-3);
|
||||||
|
border: 1px solid var(--color-enuxia-border);
|
||||||
|
border-bottom: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.correction-item:first-of-type { border-radius: 14px 14px 0 0; }
|
||||||
|
.correction-item:last-of-type { border-radius: 0 0 14px 14px; border-bottom: 1px solid var(--color-enuxia-border); }
|
||||||
|
.correction-item:only-of-type { border-radius: 14px; border-bottom: 1px solid var(--color-enuxia-border); }
|
||||||
|
.correction-item.is-correct { border-color:rgba(34,197,94,0.2); border-left:3px solid #22C55E; background:rgba(34,197,94,0.03); }
|
||||||
|
.correction-item.is-wrong { border-color:rgba(239,68,68,0.2); border-left:3px solid #ef4444; background:rgba(239,68,68,0.03); }
|
||||||
|
.correction-item:hover { background: rgba(255,255,255,0.015); }
|
||||||
|
|
||||||
|
.corr-meta { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.corr-qnum {
|
||||||
|
font-size: 10px; font-weight: 700;
|
||||||
|
padding: 2px 8px; border-radius: 4px;
|
||||||
|
background: rgba(139,92,246,0.1); color: var(--color-enuxia-purple);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.corr-section {
|
||||||
|
font-size: 10px; font-weight: 700;
|
||||||
|
letter-spacing: 0.1em; text-transform: uppercase;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
}
|
||||||
|
.corr-question { font-size: 14px; font-weight: 500; color: var(--color-enuxia-text); line-height: 1.4; }
|
||||||
|
.corr-chosen { font-size: 13px; color: var(--color-enuxia-muted); }
|
||||||
|
.corr-right { font-size: 13px; font-weight: 600; color: var(--color-enuxia-green); }
|
||||||
|
|
||||||
|
.btn-home-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 13px 28px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--color-enuxia-border);
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
transition: all 0.2s;
|
||||||
|
align-self: center;
|
||||||
|
border: 1px solid var(--color-enuxia-border-2);
|
||||||
|
}
|
||||||
|
.btn-home-link:hover {
|
||||||
|
background: var(--color-enuxia-subtle);
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ════════════════════════════════════════════════════════════════════════════
|
||||||
|
ADMIN — COMMUN
|
||||||
|
════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
.admin-page {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 32px 64px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
border-bottom: 1px solid var(--color-enuxia-border);
|
||||||
|
}
|
||||||
|
.admin-topbar h1 { font-size: 26px; font-weight: 700; }
|
||||||
|
.topbar-actions { display: flex; align-items: center; gap: 10px; }
|
||||||
|
|
||||||
|
/* ── Tables ───────────────────────────────────────────────────────────────── */
|
||||||
|
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
.data-table thead tr { border-bottom: 1px solid var(--color-enuxia-border); }
|
||||||
|
.data-table th {
|
||||||
|
padding: 10px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px; font-weight: 700;
|
||||||
|
letter-spacing: 0.08em; text-transform: uppercase;
|
||||||
|
color: var(--color-enuxia-muted);
|
||||||
|
}
|
||||||
|
.data-table td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--color-enuxia-border);
|
||||||
|
color: var(--color-enuxia-text);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.data-table tbody tr:last-child td { border-bottom: none; }
|
||||||
|
.data-table tbody tr:hover td { background: rgba(79,142,255,0.025); }
|
||||||
|
|
||||||
|
.table-actions { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.table-link {
|
||||||
|
font-size: 13px; font-weight: 500;
|
||||||
|
padding: 6px 14px; border-radius: 7px;
|
||||||
|
background: var(--color-enuxia-border); color: var(--color-enuxia-muted);
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.table-link:hover { background: var(--color-enuxia-subtle); color: var(--color-enuxia-text); }
|
||||||
|
|
||||||
|
.badge-on { font-size:11px; font-weight:700; padding:4px 10px; border-radius:20px; background:rgba(34,197,94,0.1); color:var(--color-enuxia-green); letter-spacing:0.04em; }
|
||||||
|
.badge-off { font-size:11px; font-weight:700; padding:4px 10px; border-radius:20px; background:var(--color-enuxia-border); color:var(--color-enuxia-muted); letter-spacing:0.04em; }
|
||||||
|
|
||||||
|
/* ── Éditeur quiz ─────────────────────────────────────────────────────────── */
|
||||||
|
.quiz-editor { display: flex; flex-direction: column; gap: 24px; }
|
||||||
|
|
||||||
|
.quiz-meta-card {
|
||||||
|
padding: 24px; border-radius: 14px;
|
||||||
|
background: var(--color-enuxia-dark-3);
|
||||||
|
border: 1px solid var(--color-enuxia-border);
|
||||||
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
|
}
|
||||||
|
.quiz-meta-card .save-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 14px; color: var(--color-enuxia-muted); cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.questions-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.questions-header h2 { font-size: 18px; font-weight: 700; color: var(--color-enuxia-text); }
|
||||||
|
|
||||||
|
.question-card {
|
||||||
|
padding: 20px; border-radius: 14px;
|
||||||
|
background: var(--color-enuxia-dark-3);
|
||||||
|
border: 1px solid var(--color-enuxia-border);
|
||||||
|
display: flex; flex-direction: column; gap: 14px; margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.question-card .qcard-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
|
||||||
|
.q-badge {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 11px; font-weight: 700;
|
||||||
|
padding: 4px 10px; border-radius: 6px;
|
||||||
|
background: rgba(79,142,255,0.1); color: var(--color-enuxia-blue);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.answers-editor { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.answer-editor-row { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.answer-letter {
|
||||||
|
width: 30px; height: 30px; border-radius: 7px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; font-weight: 700; flex-shrink: 0;
|
||||||
|
background: var(--color-enuxia-border); color: var(--color-enuxia-purple);
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
}
|
||||||
|
.answer-editor-row input[type="text"] { flex: 1; }
|
||||||
|
.radio-correct {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; font-weight: 500;
|
||||||
|
color: var(--color-enuxia-muted); white-space: nowrap; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Admin résultats & détail ─────────────────────────────────────────────── */
|
||||||
|
.csv-box {
|
||||||
|
padding: 16px; border-radius: 12px;
|
||||||
|
background: var(--color-enuxia-dark-3);
|
||||||
|
border: 1px solid rgba(34,197,94,0.25);
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.csv-box p { font-size: 13px; font-weight: 600; color: var(--color-enuxia-green); }
|
||||||
|
.csv-box textarea { font-family: monospace; font-size: 12px; min-height: 160px; }
|
||||||
|
|
||||||
|
.score-summary-bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 18px 24px; border-radius: 12px;
|
||||||
|
background: var(--color-enuxia-dark-3); border: 1px solid var(--color-enuxia-border);
|
||||||
|
}
|
||||||
|
.score-summary-bar .score-val {
|
||||||
|
font-family: 'Space Grotesk', sans-serif;
|
||||||
|
font-size: 24px; font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--color-enuxia-blue), var(--color-enuxia-purple));
|
||||||
|
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||||
|
}
|
||||||
|
.score-summary-bar .date-val { font-size: 13px; color: var(--color-enuxia-muted); }
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: {
|
||||||
|
relative: true,
|
||||||
|
files: ["*.html", "./src/**/*.rs"],
|
||||||
|
transform: {
|
||||||
|
rs: (content) => content.replace(/(?:^|\s)class:/g, ' '),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user