fix: 🐛 debug timer, debug suppression quiz et debug shuffle reponses quiz
Build & Deploy / build-deploy (push) Failing after 1m5s

This commit is contained in:
Julien Denizot
2026-04-14 10:53:28 +02:00
parent 5136f35c4a
commit 3f84b1c5bf
8 changed files with 226 additions and 67 deletions
+2 -2
View File
@@ -9,7 +9,7 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
leptos = { version = "0.8.0" }
leptos_router = { version = "0.8.0" }
axum = { version = "0.8.0", optional = true }
axum = { version = "0.8.9", 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" }
@@ -20,7 +20,7 @@ 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 }
axum-extra = { version = "0.12.6", 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 }
+14 -2
View File
@@ -1,10 +1,14 @@
use sqlx::{SqlitePool, sqlite::SqlitePoolOptions};
use sqlx::{SqlitePool, sqlite::{SqlitePoolOptions, SqliteConnectOptions}};
use std::str::FromStr;
use anyhow::Result;
pub async fn init_db(database_url: &str) -> Result<SqlitePool> {
let options = SqliteConnectOptions::from_str(database_url)?
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect(database_url)
.connect_with(options)
.await?;
// Tables de base
@@ -62,6 +66,14 @@ pub async fn init_db(database_url: &str) -> Result<SqlitePool> {
used BOOLEAN NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS quiz_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quiz_id INTEGER NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
started_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(quiz_id, first_name, last_name)
);
INSERT OR IGNORE INTO config (key, value) VALUES ('session_password', 'changeme');
INSERT OR IGNORE INTO config (key, value) VALUES ('admin_password', 'admin');
"#,
+3 -3
View File
@@ -92,13 +92,13 @@ pub fn StudentDetailPage() -> impl IntoView {
<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)}
{d.chosen_text.clone()}
</p>
{if !d.is_correct {
view! {
<p class="corr-right">
{format!("✔ Bonne réponse : {}) {}",
d.correct_label, d.correct_text)}
{format!("✔ Bonne réponse : {}",
d.correct_text)}
</p>
}.into_any()
} else {
+75 -54
View File
@@ -1,10 +1,35 @@
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};
use crate::server::student::{get_quiz_with_questions, submit_quiz, start_or_resume_quiz};
#[cfg(feature = "hydrate")]
use gloo_timers::future::TimeoutFuture;
#[component]
fn Timer(time_left: RwSignal<Option<i64>>) -> impl IntoView {
view! {
{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>
}
})}
}
}
#[component]
pub fn QuizPage() -> impl IntoView {
let params = use_params_map();
@@ -35,41 +60,60 @@ pub fn QuizPage() -> impl IntoView {
{move || quiz_data.get().map(|result| {
match result {
Ok(Some((quiz, questions))) => {
let total = questions.len();
let questions = StoredValue::new(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() {
if time_left.get_untracked().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());
#[cfg(feature = "hydrate")]
{
let qid = quiz_id();
let fn_ = first_name();
let ln = last_name();
leptos::task::spawn_local(async move {
// Récupère le temps restant côté serveur
if let Ok(remaining) = start_or_resume_quiz(qid, fn_.clone(), ln.clone()).await {
if remaining <= 0 {
time_left.set(Some(0));
if !submitted.get() {
submitted.set(true);
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());
}
}
return;
}
break;
time_left.set(Some(remaining));
}
time_left.set(Some(t - 1));
}
});
// Boucle de décompte
loop {
TimeoutFuture::new(1000).await;
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));
}
});
}
}
}
@@ -80,25 +124,7 @@ pub fn QuizPage() -> impl IntoView {
<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>
}
})}
<Timer time_left=time_left/>
<span class="question-badge">
{move || format!("{} / {}", current.get() + 1, total)}
</span>
@@ -130,7 +156,6 @@ pub fn QuizPage() -> impl IntoView {
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)
@@ -145,9 +170,9 @@ pub fn QuizPage() -> impl IntoView {
<p class="question-text">{question.text.clone()}</p>
<div class="answers-list">
{ans_list.into_iter().map(|answer| {
{ans_list.into_iter().enumerate().map(|(i, answer)| {
let aid = answer.id;
let lbl = answer.label.clone();
let lbl = String::from((b'A' + i as u8) as char);
let txt = answer.text.clone();
view! {
@@ -182,7 +207,6 @@ pub fn QuizPage() -> impl IntoView {
margin-top:8px;
gap:12px;
">
// Bouton précédent
{move || (current.get() > 0).then(|| view! {
<button
class="btn-ghost"
@@ -198,13 +222,11 @@ pub fn QuizPage() -> impl IntoView {
<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!(
@@ -231,7 +253,6 @@ pub fn QuizPage() -> impl IntoView {
</button>
}.into_any()
} else {
// Question suivante
view! {
<button
style=move || format!(
+3 -3
View File
@@ -119,13 +119,13 @@ pub fn ResultPage() -> impl IntoView {
<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)}
{d.chosen_text.clone()}
</p>
{if !d.is_correct {
view! {
<p class="corr-right">
{format!("✔ Bonne réponse : {}) {}",
d.correct_label, d.correct_text)}
{format!("✔ Bonne réponse : {}",
d.correct_text)}
</p>
}.into_any()
} else {
+17 -3
View File
@@ -87,10 +87,24 @@ pub async fn delete_quiz(id: i64) -> Result<(), ServerFnError> {
let Extension(pool): Extension<SqlitePool> = extract().await?;
// Supprime dans l'ordre pour respecter les contraintes
sqlx::query!("DELETE FROM student_answers WHERE submission_id IN (SELECT id FROM submissions WHERE quiz_id = ?)", id)
.execute(&pool).await.map_err(|e| ServerFnError::new(e.to_string()))?;
sqlx::query!("DELETE FROM submissions WHERE quiz_id = ?", id)
.execute(&pool).await.map_err(|e| ServerFnError::new(e.to_string()))?;
sqlx::query!("DELETE FROM answers WHERE question_id IN (SELECT id FROM questions WHERE quiz_id = ?)", id)
.execute(&pool).await.map_err(|e| ServerFnError::new(e.to_string()))?;
sqlx::query!("DELETE FROM questions WHERE quiz_id = ?", id)
.execute(&pool).await.map_err(|e| ServerFnError::new(e.to_string()))?;
sqlx::query!("DELETE FROM resets WHERE quiz_id = ?", id)
.execute(&pool).await.map_err(|e| ServerFnError::new(e.to_string()))?;
sqlx::query!("DELETE FROM quizzes WHERE id = ?", id)
.execute(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
.execute(&pool).await.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(())
}
+61
View File
@@ -110,6 +110,58 @@ pub async fn get_quiz_with_questions(quiz_id: i64) -> Result<Option<(Quiz, Vec<Q
Ok(Some((quiz, result)))
}
#[server]
pub async fn start_or_resume_quiz(
quiz_id: i64,
first_name: String,
last_name: String,
) -> Result<i64, ServerFnError> {
use axum::Extension;
use leptos_axum::extract;
use sqlx::SqlitePool;
use chrono::Utc;
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!(
"SELECT time_limit_seconds FROM quizzes WHERE id = ?",
quiz_id
)
.fetch_one(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
let time_limit = row.time_limit_seconds.unwrap_or(0);
let session = sqlx::query!(
r#"SELECT started_at as "started_at: chrono::DateTime<chrono::Utc>"
FROM quiz_sessions
WHERE quiz_id = ? AND first_name = ? AND last_name = ?"#,
quiz_id, fn_lower, ln_lower
)
.fetch_optional(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
if let Some(row) = session {
let elapsed = (Utc::now() - row.started_at).num_seconds();
Ok((time_limit - elapsed).max(0))
} else {
sqlx::query!(
"INSERT INTO quiz_sessions (quiz_id, first_name, last_name) VALUES (?, ?, ?)",
quiz_id, fn_lower, ln_lower
)
.execute(&pool)
.await
.map_err(|e| ServerFnError::new(e.to_string()))?;
Ok(time_limit)
}
}
#[server]
pub async fn check_already_done(
quiz_id: i64,
@@ -257,6 +309,15 @@ pub async fn submit_quiz(
.map_err(|e| ServerFnError::new(e.to_string()))?;
}
// Nettoyage de la session quiz
sqlx::query!(
"DELETE FROM quiz_sessions WHERE quiz_id = ? AND first_name = ? AND last_name = ?",
quiz_id, fn_lower, ln_lower
)
.execute(&pool)
.await
.ok();
Ok(submission_id)
}
+51
View File
@@ -1065,4 +1065,55 @@ button {
padding: 10px 16px;
font-size: 13px;
}
}
/* ── Text overflow fixes ──────────────────────────────────────────────────── */
.question-text,
.answer-option,
.corr-question,
.corr-chosen,
.corr-right,
.quiz-item .quiz-title,
.quiz-item .quiz-desc,
.q-badge,
.data-table td,
.answer-editor-row input[type="text"] {
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-word;
hyphens: auto;
}
/* Réponses dans l'éditeur admin */
.answer-option {
align-items: flex-start;
}
.answer-option .opt-label {
flex-shrink: 0;
margin-top: 2px;
}
/* Questions dans l'éditeur */
.question-card input[type="text"],
.question-card textarea {
word-break: break-word;
overflow-wrap: break-word;
}
/* Table admin */
.data-table td {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.data-table td {
max-width: 100%;
white-space: normal;
overflow: visible;
text-overflow: unset;
}
}