first version

This commit is contained in:
Julien Denizot
2026-04-13 17:56:39 +02:00
commit 6c092dc014
37 changed files with 4982 additions and 0 deletions
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+24
View File
@@ -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>
+104
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
node_modules
playwright-report
test-results
+167
View File
@@ -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
}
}
}
+15
View File
@@ -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"
}
}
+105
View File
@@ -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,
// },
});
+9
View File
@@ -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!");
});
+109
View File
@@ -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

+1
View File
@@ -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
View File
@@ -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>
}
}
+82
View File
@@ -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
View File
@@ -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
View File
@@ -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() {}
+41
View File
@@ -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
View File
@@ -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,
}
+123
View File
@@ -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>
}
}
+160
View File
@@ -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>
}
}
+6
View File
@@ -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;
+324
View File
@@ -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>
}
}
+135
View File
@@ -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>
}
}
+320
View File
@@ -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>
}
}
+123
View File
@@ -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>
}
}
+177
View File
@@ -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>
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod admin;
pub mod student;
pub mod login;
+195
View File
@@ -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>
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod home;
pub mod quiz;
pub mod result;
+276
View File
@@ -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>
}
}
+152
View File
@@ -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>
}
}
+537
View File
@@ -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,
})
}
+116
View File
@@ -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(())
}
+391
View File
@@ -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
View File
@@ -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); }
+14
View File
@@ -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: [],
}