commit 6c092dc0147f335dfae653f2a9c58f6a1a126a49 Author: Julien Denizot Date: Mon Apr 13 17:56:39 2026 +0200 first version diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f556b15 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +DATABASE_URL=sqlite:///chemin/absolu/vers/quiz.db +SESSION_PASSWORD=changeme +ADMIN_PASSWORD=admin +LEPTOS_TAILWIND_VERSION=v4.1.13 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb97a4c --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0da1198 --- /dev/null +++ b/Cargo.toml @@ -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 //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" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..66dc773 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/end2end/.gitignore b/end2end/.gitignore new file mode 100644 index 0000000..169a2af --- /dev/null +++ b/end2end/.gitignore @@ -0,0 +1,3 @@ +node_modules +playwright-report +test-results diff --git a/end2end/package-lock.json b/end2end/package-lock.json new file mode 100644 index 0000000..260e8eb --- /dev/null +++ b/end2end/package-lock.json @@ -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 + } + } +} diff --git a/end2end/package.json b/end2end/package.json new file mode 100644 index 0000000..8adc60a --- /dev/null +++ b/end2end/package.json @@ -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" + } +} diff --git a/end2end/playwright.config.ts b/end2end/playwright.config.ts new file mode 100644 index 0000000..aee2d46 --- /dev/null +++ b/end2end/playwright.config.ts @@ -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, + // }, +}); diff --git a/end2end/tests/example.spec.ts b/end2end/tests/example.spec.ts new file mode 100644 index 0000000..0139fc3 --- /dev/null +++ b/end2end/tests/example.spec.ts @@ -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!"); +}); diff --git a/end2end/tsconfig.json b/end2end/tsconfig.json new file mode 100644 index 0000000..e075f97 --- /dev/null +++ b/end2end/tsconfig.json @@ -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 ''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. */ + } +} diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..c2cc31b Binary files /dev/null and b/public/favicon.png differ diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..98ef822 --- /dev/null +++ b/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..9df2632 --- /dev/null +++ b/src/app.rs @@ -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! { + + + + + + + + + + + + + + + } +} + +#[component] +pub fn App() -> impl IntoView { + provide_meta_context(); + + view! { + + + <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> + } +} \ No newline at end of file diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..ae1dc29 --- /dev/null +++ b/src/db.rs @@ -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) +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..28e2f38 --- /dev/null +++ b/src/lib.rs @@ -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); +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3741ca4 --- /dev/null +++ b/src/main.rs @@ -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() {} \ No newline at end of file diff --git a/src/middleware.rs b/src/middleware.rs new file mode 100644 index 0000000..15544a4 --- /dev/null +++ b/src/middleware.rs @@ -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) +} \ No newline at end of file diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..3cdd4b1 --- /dev/null +++ b/src/models.rs @@ -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, +} \ No newline at end of file diff --git a/src/pages/admin/config.rs b/src/pages/admin/config.rs new file mode 100644 index 0000000..0a1ee5a --- /dev/null +++ b/src/pages/admin/config.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/pages/admin/dashboard.rs b/src/pages/admin/dashboard.rs new file mode 100644 index 0000000..ec0eaa2 --- /dev/null +++ b/src/pages/admin/dashboard.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/pages/admin/mod.rs b/src/pages/admin/mod.rs new file mode 100644 index 0000000..b8c9d4f --- /dev/null +++ b/src/pages/admin/mod.rs @@ -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; \ No newline at end of file diff --git a/src/pages/admin/quiz_edit.rs b/src/pages/admin/quiz_edit.rs new file mode 100644 index 0000000..b29a973 --- /dev/null +++ b/src/pages/admin/quiz_edit.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/pages/admin/quiz_results.rs b/src/pages/admin/quiz_results.rs new file mode 100644 index 0000000..8a38660 --- /dev/null +++ b/src/pages/admin/quiz_results.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/pages/admin/quiz_stats.rs b/src/pages/admin/quiz_stats.rs new file mode 100644 index 0000000..49012a6 --- /dev/null +++ b/src/pages/admin/quiz_stats.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/pages/admin/student_detail.rs b/src/pages/admin/student_detail.rs new file mode 100644 index 0000000..b4a2045 --- /dev/null +++ b/src/pages/admin/student_detail.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/pages/login.rs b/src/pages/login.rs new file mode 100644 index 0000000..09dd15b --- /dev/null +++ b/src/pages/login.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/pages/mod.rs b/src/pages/mod.rs new file mode 100644 index 0000000..2fad016 --- /dev/null +++ b/src/pages/mod.rs @@ -0,0 +1,3 @@ +pub mod admin; +pub mod student; +pub mod login; \ No newline at end of file diff --git a/src/pages/student/home.rs b/src/pages/student/home.rs new file mode 100644 index 0000000..2f6c7f5 --- /dev/null +++ b/src/pages/student/home.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/pages/student/mod.rs b/src/pages/student/mod.rs new file mode 100644 index 0000000..e5f2238 --- /dev/null +++ b/src/pages/student/mod.rs @@ -0,0 +1,3 @@ +pub mod home; +pub mod quiz; +pub mod result; \ No newline at end of file diff --git a/src/pages/student/quiz.rs b/src/pages/student/quiz.rs new file mode 100644 index 0000000..01c3705 --- /dev/null +++ b/src/pages/student/quiz.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/pages/student/result.rs b/src/pages/student/result.rs new file mode 100644 index 0000000..14a6fbf --- /dev/null +++ b/src/pages/student/result.rs @@ -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> + } +} \ No newline at end of file diff --git a/src/server/admin.rs b/src/server/admin.rs new file mode 100644 index 0000000..e06cb8d --- /dev/null +++ b/src/server/admin.rs @@ -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, + }) +} \ No newline at end of file diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..1c73efc --- /dev/null +++ b/src/server/mod.rs @@ -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(()) +} \ No newline at end of file diff --git a/src/server/student.rs b/src/server/student.rs new file mode 100644 index 0000000..fe42197 --- /dev/null +++ b/src/server/student.rs @@ -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))) +} \ No newline at end of file diff --git a/style/main.css b/style/main.css new file mode 100644 index 0000000..4c08ad1 --- /dev/null +++ b/style/main.css @@ -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); } \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..1388179 --- /dev/null +++ b/tailwind.config.js @@ -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: [], +}