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