fix: 🐛 debug timer, debug suppression quiz et debug shuffle reponses quiz
Build & Deploy / build-deploy (push) Failing after 1m5s
Build & Deploy / build-deploy (push) Failing after 1m5s
This commit is contained in:
+2
-2
@@ -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 }
|
||||
|
||||
@@ -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');
|
||||
"#,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+54
-33
@@ -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();
|
||||
@@ -41,15 +66,33 @@ pub fn QuizPage() -> impl IntoView {
|
||||
|
||||
// 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")]
|
||||
{
|
||||
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;
|
||||
}
|
||||
time_left.set(Some(remaining));
|
||||
}
|
||||
// Boucle de décompte
|
||||
loop {
|
||||
TimeoutFuture::new(1000).await;
|
||||
#[cfg(not(feature = "hydrate"))]
|
||||
break;
|
||||
|
||||
if submitted.get() { break; }
|
||||
let t = time_left.get().unwrap_or(0);
|
||||
if t <= 1 {
|
||||
@@ -72,6 +115,7 @@ pub fn QuizPage() -> impl IntoView {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="quiz-page">
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -1066,3 +1066,54 @@ button {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user