feat(timer): changement d'équipe libre pendant un timer — bandeau « dans l'équipe X », stop = bascule vers l'équipe du timerUnverified
0c22849 parent: ea1fd01 modified
assets/style.css +0 -13 | @@ -187,19 +187,6 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | ||
| 187 | 187 | transition: background .15s ease, color .15s ease; |
| 188 | 188 | } |
| 189 | 189 | .timer-start:hover { background: var(--color-accent-2); color: var(--color-bg); } |
| 190 | -/* Timer ou mesure d'une autre équipe : pill compacte inerte. */ | |
| 191 | -.timer-foreign { | |
| 192 | - pointer-events: auto; display: flex; align-items: center; gap: 9px; | |
| 193 | - padding: 10px 18px; border-radius: 999px; box-shadow: var(--shadow-md); | |
| 194 | - background: var(--color-neutral-200); | |
| 195 | - color: color-mix(in srgb, var(--color-text) 62%, transparent); | |
| 196 | - font-size: 15px; cursor: default; | |
| 197 | -} | |
| 198 | -.timer-foreign .t-elapsed { | |
| 199 | - font-family: var(--font-heading); font-weight: var(--font-heading-weight); | |
| 200 | - letter-spacing: -0.01em; | |
| 201 | -} | |
| 202 | -.timer-foreign .t-note { font-size: 12px; opacity: .85; max-width: 13em; text-align: left; line-height: 1.25; } | |
| 203 | 190 | |
| 204 | 191 | /* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */ |
| 205 | 192 | .chrono-card { |
| @@ -187,19 +187,6 @@ input[type=number] { -moz-appearance: textfield; appearance: textfield; } | |||
| 187 | transition: background .15s ease, color .15s ease; | 187 | transition: background .15s ease, color .15s ease; |
| 188 | } | 188 | } |
| 189 | .timer-start:hover { background: var(--color-accent-2); color: var(--color-bg); } | 189 | .timer-start:hover { background: var(--color-accent-2); color: var(--color-bg); } |
| 190 | -/* Timer ou mesure d'une autre équipe : pill compacte inerte. */ | ||
| 191 | -.timer-foreign { | ||
| 192 | - pointer-events: auto; display: flex; align-items: center; gap: 9px; | ||
| 193 | - padding: 10px 18px; border-radius: 999px; box-shadow: var(--shadow-md); | ||
| 194 | - background: var(--color-neutral-200); | ||
| 195 | - color: color-mix(in srgb, var(--color-text) 62%, transparent); | ||
| 196 | - font-size: 15px; cursor: default; | ||
| 197 | -} | ||
| 198 | -.timer-foreign .t-elapsed { | ||
| 199 | - font-family: var(--font-heading); font-weight: var(--font-heading-weight); | ||
| 200 | - letter-spacing: -0.01em; | ||
| 201 | -} | ||
| 202 | -.timer-foreign .t-note { font-size: 12px; opacity: .85; max-width: 13em; text-align: left; line-height: 1.25; } | ||
| 203 | 190 | ||
| 204 | /* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */ | 191 | /* Écran minuteur (onglet Ajouter pendant qu'un timer tourne). */ |
| 205 | .chrono-card { | 192 | .chrono-card { |
modified
src/db/timers.rs +15 -14 | @@ -79,20 +79,30 @@ pub async fn start(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result< | ||
| 79 | 79 | Ok(res.rows_affected()) |
| 80 | 80 | } |
| 81 | 81 | |
| 82 | -/// Fige la mesure — seulement depuis l'équipe du timer, 0 ligne sinon | |
| 83 | -/// (timer déjà stoppé ailleurs, ou appareil sur une autre équipe). | |
| 84 | -pub async fn stop(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { | |
| 82 | +/// Fige la mesure, quelle que soit l'équipe active de l'appareil — 0 ligne | |
| 83 | +/// si le timer est déjà stoppé (ou inexistant). | |
| 84 | +pub async fn stop(pool: &PgPool, user_id: Uuid) -> sqlx::Result<u64> { | |
| 85 | 85 | let res = sqlx::query( |
| 86 | 86 | "UPDATE timers SET stopped_at = now() |
| 87 | - WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NULL", | |
| 87 | + WHERE user_id = $1 AND stopped_at IS NULL", | |
| 88 | 88 | ) |
| 89 | 89 | .bind(user_id) |
| 90 | - .bind(team_id) | |
| 91 | 90 | .execute(pool) |
| 92 | 91 | .await?; |
| 93 | 92 | Ok(res.rows_affected()) |
| 94 | 93 | } |
| 95 | 94 | |
| 95 | +/// Nettoyage au départ d'une équipe : un timer ou une mesure y devient | |
| 96 | +/// inutilisable (plus membre), on le jette. | |
| 97 | +pub async fn discard_in_team(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { | |
| 98 | + let res = sqlx::query("DELETE FROM timers WHERE user_id = $1 AND team_id = $2") | |
| 99 | + .bind(user_id) | |
| 100 | + .bind(team_id) | |
| 101 | + .execute(pool) | |
| 102 | + .await?; | |
| 103 | + Ok(res.rows_affected()) | |
| 104 | +} | |
| 105 | + | |
| 96 | 106 | /// « Annuler le minuteur » : détruit le timer en cours sans créer de mesure. |
| 97 | 107 | pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { |
| 98 | 108 | let res = sqlx::query( |
| @@ -120,15 +130,6 @@ pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sql | ||
| 120 | 130 | Ok(res.rows_affected()) |
| 121 | 131 | } |
| 122 | 132 | |
| 123 | -/// Vrai si un timer existe (en cours ou en attente) — verrous sur tout ce | |
| 124 | -/// qui changerait l'équipe active. | |
| 125 | -pub async fn exists(pool: &PgPool, user_id: Uuid) -> sqlx::Result<bool> { | |
| 126 | - sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM timers WHERE user_id = $1)") | |
| 127 | - .bind(user_id) | |
| 128 | - .fetch_one(pool) | |
| 129 | - .await | |
| 130 | -} | |
| 131 | - | |
| 132 | 133 | #[cfg(test)] |
| 133 | 134 | mod tests { |
| 134 | 135 | use super::*; |
| @@ -79,20 +79,30 @@ pub async fn start(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result< | |||
| 79 | Ok(res.rows_affected()) | 79 | Ok(res.rows_affected()) |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | -/// Fige la mesure — seulement depuis l'équipe du timer, 0 ligne sinon | 82 | +/// Fige la mesure, quelle que soit l'équipe active de l'appareil — 0 ligne |
| 83 | -/// (timer déjà stoppé ailleurs, ou appareil sur une autre équipe). | 83 | +/// si le timer est déjà stoppé (ou inexistant). |
| 84 | -pub async fn stop(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { | 84 | +pub async fn stop(pool: &PgPool, user_id: Uuid) -> sqlx::Result<u64> { |
| 85 | let res = sqlx::query( | 85 | let res = sqlx::query( |
| 86 | "UPDATE timers SET stopped_at = now() | 86 | "UPDATE timers SET stopped_at = now() |
| 87 | - WHERE user_id = $1 AND team_id = $2 AND stopped_at IS NULL", | 87 | + WHERE user_id = $1 AND stopped_at IS NULL", |
| 88 | ) | 88 | ) |
| 89 | .bind(user_id) | 89 | .bind(user_id) |
| 90 | - .bind(team_id) | ||
| 91 | .execute(pool) | 90 | .execute(pool) |
| 92 | .await?; | 91 | .await?; |
| 93 | Ok(res.rows_affected()) | 92 | Ok(res.rows_affected()) |
| 94 | } | 93 | } |
| 95 | 94 | ||
| 95 | +/// Nettoyage au départ d'une équipe : un timer ou une mesure y devient | ||
| 96 | +/// inutilisable (plus membre), on le jette. | ||
| 97 | +pub async fn discard_in_team(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { | ||
| 98 | + let res = sqlx::query("DELETE FROM timers WHERE user_id = $1 AND team_id = $2") | ||
| 99 | + .bind(user_id) | ||
| 100 | + .bind(team_id) | ||
| 101 | + .execute(pool) | ||
| 102 | + .await?; | ||
| 103 | + Ok(res.rows_affected()) | ||
| 104 | +} | ||
| 105 | + | ||
| 96 | /// « Annuler le minuteur » : détruit le timer en cours sans créer de mesure. | 106 | /// « Annuler le minuteur » : détruit le timer en cours sans créer de mesure. |
| 97 | pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { | 107 | pub async fn cancel_running(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sqlx::Result<u64> { |
| 98 | let res = sqlx::query( | 108 | let res = sqlx::query( |
| @@ -120,15 +130,6 @@ pub async fn discard_pending(pool: &PgPool, user_id: Uuid, team_id: Uuid) -> sql | |||
| 120 | Ok(res.rows_affected()) | 130 | Ok(res.rows_affected()) |
| 121 | } | 131 | } |
| 122 | 132 | ||
| 123 | -/// Vrai si un timer existe (en cours ou en attente) — verrous sur tout ce | ||
| 124 | -/// qui changerait l'équipe active. | ||
| 125 | -pub async fn exists(pool: &PgPool, user_id: Uuid) -> sqlx::Result<bool> { | ||
| 126 | - sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM timers WHERE user_id = $1)") | ||
| 127 | - .bind(user_id) | ||
| 128 | - .fetch_one(pool) | ||
| 129 | - .await | ||
| 130 | -} | ||
| 131 | - | ||
| 132 | #[cfg(test)] | 133 | #[cfg(test)] |
| 133 | mod tests { | 134 | mod tests { |
| 134 | use super::*; | 135 | use super::*; |
modified
src/routes/settings.rs +2 -10 | @@ -7,7 +7,7 @@ use uuid::Uuid; | ||
| 7 | 7 | |
| 8 | 8 | use crate::auth::password; |
| 9 | 9 | use crate::charts::color_map; |
| 10 | -use crate::db::{invitations, tasks, teams, timers, users}; | |
| 10 | +use crate::db::{invitations, tasks, teams, users}; | |
| 11 | 11 | use crate::error::AppError; |
| 12 | 12 | use crate::render::{HtmlTemplate, Shell}; |
| 13 | 13 | use crate::routes::shell; |
| @@ -50,9 +50,6 @@ pub struct SettingsPage { | ||
| 50 | 50 | pub invite_error: Option<String>, |
| 51 | 51 | pub sent: Vec<SentView>, |
| 52 | 52 | pub can_leave: bool, |
| 53 | - /// Timer en cours ou mesure en attente : bascule / création / acceptation | |
| 54 | - /// / départ d'équipe désactivés. | |
| 55 | - pub timer_lock: bool, | |
| 56 | 53 | } |
| 57 | 54 | |
| 58 | 55 | #[derive(Deserialize)] |
| @@ -81,9 +78,6 @@ pub async fn settings_page( | ||
| 81 | 78 | Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()), |
| 82 | 79 | Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()), |
| 83 | 80 | Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()), |
| 84 | - Some("timer") => { | |
| 85 | - Some("Impossible de changer d'équipe pendant qu'un timer tourne".to_string()) | |
| 86 | - } | |
| 87 | 81 | _ => None, |
| 88 | 82 | }, |
| 89 | 83 | }; |
| @@ -121,8 +115,7 @@ pub async fn settings_page( | ||
| 121 | 115 | name: t.name, |
| 122 | 116 | }) |
| 123 | 117 | .collect(); |
| 124 | - let timer_lock = timers::exists(&state.pool, ctx.user.id).await?; | |
| 125 | - let can_leave = my_teams.len() > 1 && !timer_lock; | |
| 118 | + let can_leave = my_teams.len() > 1; | |
| 126 | 119 | let account_line = if my_teams.len() > 1 { |
| 127 | 120 | format!( |
| 128 | 121 | "Membre de {} équipes. Les comptes sont créés en ligne de commande.", |
| @@ -159,7 +152,6 @@ pub async fn settings_page( | ||
| 159 | 152 | invite_error, |
| 160 | 153 | sent, |
| 161 | 154 | can_leave, |
| 162 | - timer_lock, | |
| 163 | 155 | })) |
| 164 | 156 | } |
| 165 | 157 | |
| @@ -7,7 +7,7 @@ use uuid::Uuid; | |||
| 7 | 7 | ||
| 8 | use crate::auth::password; | 8 | use crate::auth::password; |
| 9 | use crate::charts::color_map; | 9 | use crate::charts::color_map; |
| 10 | -use crate::db::{invitations, tasks, teams, timers, users}; | 10 | +use crate::db::{invitations, tasks, teams, users}; |
| 11 | use crate::error::AppError; | 11 | use crate::error::AppError; |
| 12 | use crate::render::{HtmlTemplate, Shell}; | 12 | use crate::render::{HtmlTemplate, Shell}; |
| 13 | use crate::routes::shell; | 13 | use crate::routes::shell; |
| @@ -50,9 +50,6 @@ pub struct SettingsPage { | |||
| 50 | pub invite_error: Option<String>, | 50 | pub invite_error: Option<String>, |
| 51 | pub sent: Vec<SentView>, | 51 | pub sent: Vec<SentView>, |
| 52 | pub can_leave: bool, | 52 | pub can_leave: bool, |
| 53 | - /// Timer en cours ou mesure en attente : bascule / création / acceptation | ||
| 54 | - /// / départ d'équipe désactivés. | ||
| 55 | - pub timer_lock: bool, | ||
| 56 | } | 53 | } |
| 57 | 54 | ||
| 58 | #[derive(Deserialize)] | 55 | #[derive(Deserialize)] |
| @@ -81,9 +78,6 @@ pub async fn settings_page( | |||
| 81 | Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()), | 78 | Some("eq-doublon") => Some("Ce nom d'équipe est déjà pris".to_string()), |
| 82 | Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()), | 79 | Some("eq-vide") => Some("Le nom d'équipe ne peut pas être vide".to_string()), |
| 83 | Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()), | 80 | Some("derniere") => Some("Impossible de quitter ta dernière équipe".to_string()), |
| 84 | - Some("timer") => { | ||
| 85 | - Some("Impossible de changer d'équipe pendant qu'un timer tourne".to_string()) | ||
| 86 | - } | ||
| 87 | _ => None, | 81 | _ => None, |
| 88 | }, | 82 | }, |
| 89 | }; | 83 | }; |
| @@ -121,8 +115,7 @@ pub async fn settings_page( | |||
| 121 | name: t.name, | 115 | name: t.name, |
| 122 | }) | 116 | }) |
| 123 | .collect(); | 117 | .collect(); |
| 124 | - let timer_lock = timers::exists(&state.pool, ctx.user.id).await?; | 118 | + let can_leave = my_teams.len() > 1; |
| 125 | - let can_leave = my_teams.len() > 1 && !timer_lock; | ||
| 126 | let account_line = if my_teams.len() > 1 { | 119 | let account_line = if my_teams.len() > 1 { |
| 127 | format!( | 120 | format!( |
| 128 | "Membre de {} équipes. Les comptes sont créés en ligne de commande.", | 121 | "Membre de {} équipes. Les comptes sont créés en ligne de commande.", |
| @@ -159,7 +152,6 @@ pub async fn settings_page( | |||
| 159 | invite_error, | 152 | invite_error, |
| 160 | sent, | 153 | sent, |
| 161 | can_leave, | 154 | can_leave, |
| 162 | - timer_lock, | ||
| 163 | })) | 155 | })) |
| 164 | } | 156 | } |
| 165 | 157 | ||
modified
src/routes/teams.rs +2 -18 | @@ -14,12 +14,6 @@ fn is_unique_violation(e: &sqlx::Error) -> bool { | ||
| 14 | 14 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) |
| 15 | 15 | } |
| 16 | 16 | |
| 17 | -/// Un timer (en cours ou mesure en attente) verrouille tout ce qui pourrait | |
| 18 | -/// changer l'équipe active : bascule, création, acceptation, départ. | |
| 19 | -async fn timer_locked(state: &AppState, ctx: &ActiveTeam) -> Result<bool, AppError> { | |
| 20 | - Ok(timers::exists(&state.pool, ctx.user.id).await?) | |
| 21 | -} | |
| 22 | - | |
| 23 | 17 | /// Encodage minimal pour rejouer un username dans une query string. |
| 24 | 18 | fn urlenc(s: &str) -> String { |
| 25 | 19 | let mut out = String::with_capacity(s.len()); |
| @@ -50,9 +44,6 @@ pub async fn team_create( | ||
| 50 | 44 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 51 | 45 | return Err(AppError::Csrf); |
| 52 | 46 | } |
| 53 | - if timer_locked(&state, &ctx).await? { | |
| 54 | - return Ok(Redirect::to("/parametres?err=timer").into_response()); | |
| 55 | - } | |
| 56 | 47 | let name = form.nom.trim(); |
| 57 | 48 | if name.is_empty() { |
| 58 | 49 | return Ok(Redirect::to("/parametres?err=eq-vide").into_response()); |
| @@ -86,9 +77,6 @@ pub async fn team_switch( | ||
| 86 | 77 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 87 | 78 | return Err(AppError::Csrf); |
| 88 | 79 | } |
| 89 | - if timer_locked(&state, &ctx).await? { | |
| 90 | - return Ok(Redirect::to("/parametres?err=timer").into_response()); | |
| 91 | - } | |
| 92 | 80 | if teams::membership(&state.pool, ctx.user.id, form.equipe) |
| 93 | 81 | .await? |
| 94 | 82 | .is_none() |
| @@ -135,12 +123,11 @@ pub async fn team_leave( | ||
| 135 | 123 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 136 | 124 | return Err(AppError::Csrf); |
| 137 | 125 | } |
| 138 | - if timer_locked(&state, &ctx).await? { | |
| 139 | - return Ok(Redirect::to("/parametres?err=timer").into_response()); | |
| 140 | - } | |
| 141 | 126 | if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 { |
| 142 | 127 | return Ok(Redirect::to("/parametres?err=derniere").into_response()); |
| 143 | 128 | } |
| 129 | + // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté. | |
| 130 | + timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?; | |
| 144 | 131 | teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?; |
| 145 | 132 | // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut. |
| 146 | 133 | Ok(( |
| @@ -202,9 +189,6 @@ pub async fn invite_accept( | ||
| 202 | 189 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 203 | 190 | return Err(AppError::Csrf); |
| 204 | 191 | } |
| 205 | - if timer_locked(&state, &ctx).await? { | |
| 206 | - return Ok(Redirect::to("/parametres?err=timer").into_response()); | |
| 207 | - } | |
| 208 | 192 | match invitations::accept(&state.pool, id, ctx.user.id).await? { |
| 209 | 193 | Some(team_id) => Ok(( |
| 210 | 194 | jar.add(team_cookie(team_id)), |
| @@ -14,12 +14,6 @@ fn is_unique_violation(e: &sqlx::Error) -> bool { | |||
| 14 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) | 14 | matches!(e, sqlx::Error::Database(db) if db.is_unique_violation()) |
| 15 | } | 15 | } |
| 16 | 16 | ||
| 17 | -/// Un timer (en cours ou mesure en attente) verrouille tout ce qui pourrait | ||
| 18 | -/// changer l'équipe active : bascule, création, acceptation, départ. | ||
| 19 | -async fn timer_locked(state: &AppState, ctx: &ActiveTeam) -> Result<bool, AppError> { | ||
| 20 | - Ok(timers::exists(&state.pool, ctx.user.id).await?) | ||
| 21 | -} | ||
| 22 | - | ||
| 23 | /// Encodage minimal pour rejouer un username dans une query string. | 17 | /// Encodage minimal pour rejouer un username dans une query string. |
| 24 | fn urlenc(s: &str) -> String { | 18 | fn urlenc(s: &str) -> String { |
| 25 | let mut out = String::with_capacity(s.len()); | 19 | let mut out = String::with_capacity(s.len()); |
| @@ -50,9 +44,6 @@ pub async fn team_create( | |||
| 50 | if !ctx.user.check_csrf(&state, &form.csrf) { | 44 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 51 | return Err(AppError::Csrf); | 45 | return Err(AppError::Csrf); |
| 52 | } | 46 | } |
| 53 | - if timer_locked(&state, &ctx).await? { | ||
| 54 | - return Ok(Redirect::to("/parametres?err=timer").into_response()); | ||
| 55 | - } | ||
| 56 | let name = form.nom.trim(); | 47 | let name = form.nom.trim(); |
| 57 | if name.is_empty() { | 48 | if name.is_empty() { |
| 58 | return Ok(Redirect::to("/parametres?err=eq-vide").into_response()); | 49 | return Ok(Redirect::to("/parametres?err=eq-vide").into_response()); |
| @@ -86,9 +77,6 @@ pub async fn team_switch( | |||
| 86 | if !ctx.user.check_csrf(&state, &form.csrf) { | 77 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 87 | return Err(AppError::Csrf); | 78 | return Err(AppError::Csrf); |
| 88 | } | 79 | } |
| 89 | - if timer_locked(&state, &ctx).await? { | ||
| 90 | - return Ok(Redirect::to("/parametres?err=timer").into_response()); | ||
| 91 | - } | ||
| 92 | if teams::membership(&state.pool, ctx.user.id, form.equipe) | 80 | if teams::membership(&state.pool, ctx.user.id, form.equipe) |
| 93 | .await? | 81 | .await? |
| 94 | .is_none() | 82 | .is_none() |
| @@ -135,12 +123,11 @@ pub async fn team_leave( | |||
| 135 | if !ctx.user.check_csrf(&state, &form.csrf) { | 123 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 136 | return Err(AppError::Csrf); | 124 | return Err(AppError::Csrf); |
| 137 | } | 125 | } |
| 138 | - if timer_locked(&state, &ctx).await? { | ||
| 139 | - return Ok(Redirect::to("/parametres?err=timer").into_response()); | ||
| 140 | - } | ||
| 141 | if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 { | 126 | if teams::teams_of(&state.pool, ctx.user.id).await?.len() < 2 { |
| 142 | return Ok(Redirect::to("/parametres?err=derniere").into_response()); | 127 | return Ok(Redirect::to("/parametres?err=derniere").into_response()); |
| 143 | } | 128 | } |
| 129 | + // Un timer ou une mesure de cette équipe deviendrait inutilisable : jeté. | ||
| 130 | + timers::discard_in_team(&state.pool, ctx.user.id, ctx.team_id).await?; | ||
| 144 | teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?; | 131 | teams::remove_member(&state.pool, ctx.team_id, ctx.user.id).await?; |
| 145 | // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut. | 132 | // Le cookie pointait (au mieux) sur l'équipe quittée : retour au défaut. |
| 146 | Ok(( | 133 | Ok(( |
| @@ -202,9 +189,6 @@ pub async fn invite_accept( | |||
| 202 | if !ctx.user.check_csrf(&state, &form.csrf) { | 189 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 203 | return Err(AppError::Csrf); | 190 | return Err(AppError::Csrf); |
| 204 | } | 191 | } |
| 205 | - if timer_locked(&state, &ctx).await? { | ||
| 206 | - return Ok(Redirect::to("/parametres?err=timer").into_response()); | ||
| 207 | - } | ||
| 208 | match invitations::accept(&state.pool, id, ctx.user.id).await? { | 192 | match invitations::accept(&state.pool, id, ctx.user.id).await? { |
| 209 | Some(team_id) => Ok(( | 193 | Some(team_id) => Ok(( |
| 210 | jar.add(team_cookie(team_id)), | 194 | jar.add(team_cookie(team_id)), |
modified
src/routes/timer.rs +22 -7 | @@ -1,12 +1,13 @@ | ||
| 1 | 1 | use axum::Form; |
| 2 | 2 | use axum::extract::State; |
| 3 | -use axum::response::Redirect; | |
| 3 | +use axum::response::{IntoResponse, Redirect, Response}; | |
| 4 | +use axum_extra::extract::CookieJar; | |
| 4 | 5 | use serde::Deserialize; |
| 5 | 6 | |
| 6 | 7 | use crate::db::timers; |
| 7 | 8 | use crate::error::AppError; |
| 8 | 9 | use crate::state::AppState; |
| 9 | -use crate::team::ActiveTeam; | |
| 10 | +use crate::team::{ActiveTeam, team_cookie}; | |
| 10 | 11 | |
| 11 | 12 | #[derive(Deserialize)] |
| 12 | 13 | pub struct StartForm { |
| @@ -40,18 +41,32 @@ pub struct CsrfOnly { | ||
| 40 | 41 | } |
| 41 | 42 | |
| 42 | 43 | /// POST /timer/arreter — fige la mesure et ouvre le choix de la tâche. |
| 43 | -/// Timer déjà stoppé ailleurs ou appareil sur une autre équipe : 0 ligne, | |
| 44 | -/// redirection douce vers le flux qui affichera l'état réel. | |
| 44 | +/// Si le timer (ou la mesure) vit dans une autre équipe que l'équipe active | |
| 45 | +/// de l'appareil, on bascule dessus au passage : l'enregistrement ne peut se | |
| 46 | +/// faire que là-bas. Timer inexistant : redirection douce. | |
| 45 | 47 | pub async fn timer_stop( |
| 46 | 48 | State(state): State<AppState>, |
| 47 | 49 | ctx: ActiveTeam, |
| 50 | + jar: CookieJar, | |
| 48 | 51 | Form(form): Form<CsrfOnly>, |
| 49 | -) -> Result<Redirect, AppError> { | |
| 52 | +) -> Result<Response, AppError> { | |
| 50 | 53 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 51 | 54 | return Err(AppError::Csrf); |
| 52 | 55 | } |
| 53 | - timers::stop(&state.pool, ctx.user.id, ctx.team_id).await?; | |
| 54 | - Ok(Redirect::to("/temps/nouveau")) | |
| 56 | + let Some(row) = timers::get(&state.pool, ctx.user.id).await? else { | |
| 57 | + return Ok(Redirect::to("/temps/nouveau").into_response()); | |
| 58 | + }; | |
| 59 | + if row.stopped_at.is_none() { | |
| 60 | + timers::stop(&state.pool, ctx.user.id).await?; | |
| 61 | + } | |
| 62 | + // L'appartenance à l'équipe du timer est garantie : quitter une équipe | |
| 63 | + // jette ses timers (cf. team_leave). | |
| 64 | + let jar = if row.team_id != ctx.team_id { | |
| 65 | + jar.add(team_cookie(row.team_id)) | |
| 66 | + } else { | |
| 67 | + jar | |
| 68 | + }; | |
| 69 | + Ok((jar, Redirect::to("/temps/nouveau")).into_response()) | |
| 55 | 70 | } |
| 56 | 71 | |
| 57 | 72 | /// POST /timer/abandonner — « Annuler le minuteur » : détruit le timer en |
| @@ -1,12 +1,13 @@ | |||
| 1 | use axum::Form; | 1 | use axum::Form; |
| 2 | use axum::extract::State; | 2 | use axum::extract::State; |
| 3 | -use axum::response::Redirect; | 3 | +use axum::response::{IntoResponse, Redirect, Response}; |
| 4 | +use axum_extra::extract::CookieJar; | ||
| 4 | use serde::Deserialize; | 5 | use serde::Deserialize; |
| 5 | 6 | ||
| 6 | use crate::db::timers; | 7 | use crate::db::timers; |
| 7 | use crate::error::AppError; | 8 | use crate::error::AppError; |
| 8 | use crate::state::AppState; | 9 | use crate::state::AppState; |
| 9 | -use crate::team::ActiveTeam; | 10 | +use crate::team::{ActiveTeam, team_cookie}; |
| 10 | 11 | ||
| 11 | #[derive(Deserialize)] | 12 | #[derive(Deserialize)] |
| 12 | pub struct StartForm { | 13 | pub struct StartForm { |
| @@ -40,18 +41,32 @@ pub struct CsrfOnly { | |||
| 40 | } | 41 | } |
| 41 | 42 | ||
| 42 | /// POST /timer/arreter — fige la mesure et ouvre le choix de la tâche. | 43 | /// POST /timer/arreter — fige la mesure et ouvre le choix de la tâche. |
| 43 | -/// Timer déjà stoppé ailleurs ou appareil sur une autre équipe : 0 ligne, | 44 | +/// Si le timer (ou la mesure) vit dans une autre équipe que l'équipe active |
| 44 | -/// redirection douce vers le flux qui affichera l'état réel. | 45 | +/// de l'appareil, on bascule dessus au passage : l'enregistrement ne peut se |
| 46 | +/// faire que là-bas. Timer inexistant : redirection douce. | ||
| 45 | pub async fn timer_stop( | 47 | pub async fn timer_stop( |
| 46 | State(state): State<AppState>, | 48 | State(state): State<AppState>, |
| 47 | ctx: ActiveTeam, | 49 | ctx: ActiveTeam, |
| 50 | + jar: CookieJar, | ||
| 48 | Form(form): Form<CsrfOnly>, | 51 | Form(form): Form<CsrfOnly>, |
| 49 | -) -> Result<Redirect, AppError> { | 52 | +) -> Result<Response, AppError> { |
| 50 | if !ctx.user.check_csrf(&state, &form.csrf) { | 53 | if !ctx.user.check_csrf(&state, &form.csrf) { |
| 51 | return Err(AppError::Csrf); | 54 | return Err(AppError::Csrf); |
| 52 | } | 55 | } |
| 53 | - timers::stop(&state.pool, ctx.user.id, ctx.team_id).await?; | 56 | + let Some(row) = timers::get(&state.pool, ctx.user.id).await? else { |
| 54 | - Ok(Redirect::to("/temps/nouveau")) | 57 | + return Ok(Redirect::to("/temps/nouveau").into_response()); |
| 58 | + }; | ||
| 59 | + if row.stopped_at.is_none() { | ||
| 60 | + timers::stop(&state.pool, ctx.user.id).await?; | ||
| 61 | + } | ||
| 62 | + // L'appartenance à l'équipe du timer est garantie : quitter une équipe | ||
| 63 | + // jette ses timers (cf. team_leave). | ||
| 64 | + let jar = if row.team_id != ctx.team_id { | ||
| 65 | + jar.add(team_cookie(row.team_id)) | ||
| 66 | + } else { | ||
| 67 | + jar | ||
| 68 | + }; | ||
| 69 | + Ok((jar, Redirect::to("/temps/nouveau")).into_response()) | ||
| 55 | } | 70 | } |
| 56 | 71 | ||
| 57 | /// POST /timer/abandonner — « Annuler le minuteur » : détruit le timer en | 72 | /// POST /timer/abandonner — « Annuler le minuteur » : détruit le timer en |
modified
templates/app.html +10 -14 | @@ -15,20 +15,7 @@ | ||
| 15 | 15 | {% block content %}{% endblock %} |
| 16 | 16 | </main> |
| 17 | 17 | {% if let Some(t) = shell.timer %} |
| 18 | - {% if t.foreign %} | |
| 19 | -<div class="timer-slot"> | |
| 20 | - <span class="timer-foreign"> | |
| 21 | - {% if t.running %} | |
| 22 | - <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg> | |
| 23 | - <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> | |
| 24 | - {% else %} | |
| 25 | - <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> | |
| 26 | - <span class="t-elapsed">{{ t.label }}</span> | |
| 27 | - {% endif %} | |
| 28 | - <span class="t-note">{{ t.note }}</span> | |
| 29 | - </span> | |
| 30 | -</div> | |
| 31 | - {% else if t.running %} | |
| 18 | + {% if t.running %} | |
| 32 | 19 | <form class="timer-slot" method="post" action="/timer/arreter"> |
| 33 | 20 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 34 | 21 | <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer"> |
| @@ -36,6 +23,15 @@ | ||
| 36 | 23 | <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> |
| 37 | 24 | <span class="t-note">{{ t.note }}</span> |
| 38 | 25 | </button> |
| 26 | +</form> | |
| 27 | + {% else if t.foreign %} | |
| 28 | +<form class="timer-slot" method="post" action="/timer/arreter"> | |
| 29 | + <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | |
| 30 | + <button type="submit" class="timer-banner timer-waiting" aria-label="Enregistrer le temps mesuré (bascule d'équipe)"> | |
| 31 | + <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> | |
| 32 | + <span class="t-elapsed">{{ t.label }}</span> | |
| 33 | + <span class="t-note">{{ t.note }}</span> | |
| 34 | + </button> | |
| 39 | 35 | </form> |
| 40 | 36 | {% else %} |
| 41 | 37 | <div class="timer-slot"> |
| @@ -15,20 +15,7 @@ | |||
| 15 | {% block content %}{% endblock %} | 15 | {% block content %}{% endblock %} |
| 16 | </main> | 16 | </main> |
| 17 | {% if let Some(t) = shell.timer %} | 17 | {% if let Some(t) = shell.timer %} |
| 18 | - {% if t.foreign %} | 18 | + {% if t.running %} |
| 19 | -<div class="timer-slot"> | ||
| 20 | - <span class="timer-foreign"> | ||
| 21 | - {% if t.running %} | ||
| 22 | - <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="13.5" r="7.5"></circle><path d="M12 10v3.5l2.2 1.6"></path><path d="M9.4 2.5h5.2"></path></svg> | ||
| 23 | - <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> | ||
| 24 | - {% else %} | ||
| 25 | - <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> | ||
| 26 | - <span class="t-elapsed">{{ t.label }}</span> | ||
| 27 | - {% endif %} | ||
| 28 | - <span class="t-note">{{ t.note }}</span> | ||
| 29 | - </span> | ||
| 30 | -</div> | ||
| 31 | - {% else if t.running %} | ||
| 32 | <form class="timer-slot" method="post" action="/timer/arreter"> | 19 | <form class="timer-slot" method="post" action="/timer/arreter"> |
| 33 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | 20 | <input type="hidden" name="csrf" value="{{ shell.csrf }}"> |
| 34 | <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer"> | 21 | <button type="submit" class="timer-banner timer-running" aria-label="Arrêter le timer"> |
| @@ -36,6 +23,15 @@ | |||
| 36 | <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> | 23 | <span class="t-elapsed" data-started="{{ t.started_ms }}">{{ t.label }}</span> |
| 37 | <span class="t-note">{{ t.note }}</span> | 24 | <span class="t-note">{{ t.note }}</span> |
| 38 | </button> | 25 | </button> |
| 26 | +</form> | ||
| 27 | + {% else if t.foreign %} | ||
| 28 | +<form class="timer-slot" method="post" action="/timer/arreter"> | ||
| 29 | + <input type="hidden" name="csrf" value="{{ shell.csrf }}"> | ||
| 30 | + <button type="submit" class="timer-banner timer-waiting" aria-label="Enregistrer le temps mesuré (bascule d'équipe)"> | ||
| 31 | + <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.75" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><path d="M12 7.5V12l3 1.8"></path></svg> | ||
| 32 | + <span class="t-elapsed">{{ t.label }}</span> | ||
| 33 | + <span class="t-note">{{ t.note }}</span> | ||
| 34 | + </button> | ||
| 39 | </form> | 35 | </form> |
| 40 | {% else %} | 36 | {% else %} |
| 41 | <div class="timer-slot"> | 37 | <div class="timer-slot"> |
modified
templates/settings.html +4 -10 | @@ -9,14 +9,13 @@ | ||
| 9 | 9 | <div style="display:flex;gap:var(--space-2)"> |
| 10 | 10 | <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex"> |
| 11 | 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 12 | - <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px" {% if timer_lock %}disabled{% endif %}>Accepter</button> | |
| 12 | + <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">Accepter</button> | |
| 13 | 13 | </form> |
| 14 | 14 | <form method="post" action="{{ inv.refuse_action }}" style="display:flex"> |
| 15 | 15 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 16 | 16 | <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button> |
| 17 | 17 | </form> |
| 18 | 18 | </div> |
| 19 | - {% if timer_lock %}<p style="font-size:12px;color:var(--color-accent-900);opacity:.8;margin:var(--space-2) 0 0">Impossible de changer d'équipe pendant qu'un timer tourne</p>{% endif %} | |
| 20 | 19 | </div> |
| 21 | 20 | {% endfor %} |
| 22 | 21 | </div> |
| @@ -35,7 +34,7 @@ | ||
| 35 | 34 | <form method="post" action="/equipes/basculer" style="margin:0"> |
| 36 | 35 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 37 | 36 | <input type="hidden" name="equipe" value="{{ t.id }}"> |
| 38 | - <button type="submit" class="team-choice{% if t.current %} current{% endif %}" {% if timer_lock %}disabled{% endif %}> | |
| 37 | + <button type="submit" class="team-choice{% if t.current %} current{% endif %}"> | |
| 39 | 38 | <span class="team-dot"></span> |
| 40 | 39 | <span style="flex:1;text-align:left"> |
| 41 | 40 | <span style="display:block;font-size:16px">{{ t.name }}</span> |
| @@ -46,7 +45,6 @@ | ||
| 46 | 45 | </form> |
| 47 | 46 | {% endfor %} |
| 48 | 47 | </div> |
| 49 | -{% if timer_lock %}<p style="font-size:12px;color:var(--color-accent-700);margin:calc(-1 * var(--space-2)) 0 var(--space-4)">Impossible de changer d'équipe pendant qu'un timer tourne</p>{% endif %} | |
| 50 | 48 | |
| 51 | 49 | <form method="post" action="/equipes/renommer"> |
| 52 | 50 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| @@ -98,15 +96,11 @@ | ||
| 98 | 96 | <form method="post" action="/equipes"> |
| 99 | 97 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 100 | 98 | <div style="display:flex;gap:var(--space-2)"> |
| 101 | - <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px" {% if timer_lock %}disabled{% endif %}> | |
| 102 | - <button type="submit" class="btn btn-primary" style="min-height:44px" {% if timer_lock %}disabled{% endif %}>Créer</button> | |
| 99 | + <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px"> | |
| 100 | + <button type="submit" class="btn btn-primary" style="min-height:44px">Créer</button> | |
| 103 | 101 | </div> |
| 104 | 102 | </form> |
| 105 | -{% if timer_lock %} | |
| 106 | -<p style="font-size:12px;color:var(--color-accent-700);margin:var(--space-2) 0 0">Impossible de changer d'équipe pendant qu'un timer tourne</p> | |
| 107 | -{% else %} | |
| 108 | 103 | <p style="font-size:12px;opacity:.55;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p> |
| 109 | -{% endif %} | |
| 110 | 104 | |
| 111 | 105 | {% if can_leave %} |
| 112 | 106 | <form method="post" action="/equipes/quitter"> |
| @@ -9,14 +9,13 @@ | |||
| 9 | <div style="display:flex;gap:var(--space-2)"> | 9 | <div style="display:flex;gap:var(--space-2)"> |
| 10 | <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex"> | 10 | <form method="post" action="{{ inv.accept_action }}" style="flex:1;display:flex"> |
| 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 11 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 12 | - <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px" {% if timer_lock %}disabled{% endif %}>Accepter</button> | 12 | + <button type="submit" class="btn btn-primary" style="flex:1;min-height:44px">Accepter</button> |
| 13 | </form> | 13 | </form> |
| 14 | <form method="post" action="{{ inv.refuse_action }}" style="display:flex"> | 14 | <form method="post" action="{{ inv.refuse_action }}" style="display:flex"> |
| 15 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 15 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 16 | <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button> | 16 | <button type="submit" class="btn btn-secondary" style="min-height:44px">Refuser</button> |
| 17 | </form> | 17 | </form> |
| 18 | </div> | 18 | </div> |
| 19 | - {% if timer_lock %}<p style="font-size:12px;color:var(--color-accent-900);opacity:.8;margin:var(--space-2) 0 0">Impossible de changer d'équipe pendant qu'un timer tourne</p>{% endif %} | ||
| 20 | </div> | 19 | </div> |
| 21 | {% endfor %} | 20 | {% endfor %} |
| 22 | </div> | 21 | </div> |
| @@ -35,7 +34,7 @@ | |||
| 35 | <form method="post" action="/equipes/basculer" style="margin:0"> | 34 | <form method="post" action="/equipes/basculer" style="margin:0"> |
| 36 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 35 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 37 | <input type="hidden" name="equipe" value="{{ t.id }}"> | 36 | <input type="hidden" name="equipe" value="{{ t.id }}"> |
| 38 | - <button type="submit" class="team-choice{% if t.current %} current{% endif %}" {% if timer_lock %}disabled{% endif %}> | 37 | + <button type="submit" class="team-choice{% if t.current %} current{% endif %}"> |
| 39 | <span class="team-dot"></span> | 38 | <span class="team-dot"></span> |
| 40 | <span style="flex:1;text-align:left"> | 39 | <span style="flex:1;text-align:left"> |
| 41 | <span style="display:block;font-size:16px">{{ t.name }}</span> | 40 | <span style="display:block;font-size:16px">{{ t.name }}</span> |
| @@ -46,7 +45,6 @@ | |||
| 46 | </form> | 45 | </form> |
| 47 | {% endfor %} | 46 | {% endfor %} |
| 48 | </div> | 47 | </div> |
| 49 | -{% if timer_lock %}<p style="font-size:12px;color:var(--color-accent-700);margin:calc(-1 * var(--space-2)) 0 var(--space-4)">Impossible de changer d'équipe pendant qu'un timer tourne</p>{% endif %} | ||
| 50 | 48 | ||
| 51 | <form method="post" action="/equipes/renommer"> | 49 | <form method="post" action="/equipes/renommer"> |
| 52 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 50 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| @@ -98,15 +96,11 @@ | |||
| 98 | <form method="post" action="/equipes"> | 96 | <form method="post" action="/equipes"> |
| 99 | <input type="hidden" name="csrf" value="{{ csrf }}"> | 97 | <input type="hidden" name="csrf" value="{{ csrf }}"> |
| 100 | <div style="display:flex;gap:var(--space-2)"> | 98 | <div style="display:flex;gap:var(--space-2)"> |
| 101 | - <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px" {% if timer_lock %}disabled{% endif %}> | 99 | + <input class="input" name="nom" placeholder="Nom de l'équipe" required aria-label="Nom de la nouvelle équipe" style="flex:1;min-height:44px"> |
| 102 | - <button type="submit" class="btn btn-primary" style="min-height:44px" {% if timer_lock %}disabled{% endif %}>Créer</button> | 100 | + <button type="submit" class="btn btn-primary" style="min-height:44px">Créer</button> |
| 103 | </div> | 101 | </div> |
| 104 | </form> | 102 | </form> |
| 105 | -{% if timer_lock %} | ||
| 106 | -<p style="font-size:12px;color:var(--color-accent-700);margin:var(--space-2) 0 0">Impossible de changer d'équipe pendant qu'un timer tourne</p> | ||
| 107 | -{% else %} | ||
| 108 | <p style="font-size:12px;opacity:.55;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p> | 103 | <p style="font-size:12px;opacity:.55;margin:var(--space-2) 0 0">Tu bascules dessus aussitôt — invite ensuite qui tu veux.</p> |
| 109 | -{% endif %} | ||
| 110 | 104 | ||
| 111 | {% if can_leave %} | 105 | {% if can_leave %} |
| 112 | <form method="post" action="/equipes/quitter"> | 106 | <form method="post" action="/equipes/quitter"> |