From 4e3be28cf30f9feec808081e2ee063afb6f52bf9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 25 Sep 2025 16:05:59 +0200 Subject: [PATCH] "swapping distro middle save" --- api/src/routes/persons.rs | 70 ++- api/src/routes/tournaments.rs | 587 +++++++++++++++-- web/src/lib/client/person-search.ts | 128 ++++ .../routes/(admin)/admin/checkin/+page.svelte | 12 +- .../admin/checkin/checked-in/+page.svelte | 17 +- .../admin/checkin/checkout/+page.svelte | 18 +- .../admin/checkin/inside-status/+page.svelte | 19 +- .../[slug]/registrations/+page.server.ts | 28 +- .../[slug]/registrations/+page.svelte | 590 ++++++++++++++++-- .../[slug]/registrations/delete/+server.ts | 109 ++++ .../[slug]/registrations/helpers.server.ts | 62 ++ .../[slug]/registrations/refresh/+server.ts | 19 + .../[slug]/registrations/update/+server.ts | 54 ++ .../(tournament)/tournament/+page.svelte | 205 +++--- .../tournament/[slug]/+page.svelte | 4 +- .../slug/[slug]/registrations/+server.ts | 13 +- .../registrations/[registration]/+server.ts | 78 ++- 17 files changed, 1723 insertions(+), 290 deletions(-) create mode 100644 web/src/lib/client/person-search.ts create mode 100644 web/src/routes/(admin)/admin/tournament/[slug]/registrations/delete/+server.ts create mode 100644 web/src/routes/(admin)/admin/tournament/[slug]/registrations/helpers.server.ts create mode 100644 web/src/routes/(admin)/admin/tournament/[slug]/registrations/refresh/+server.ts create mode 100644 web/src/routes/(admin)/admin/tournament/[slug]/registrations/update/+server.ts diff --git a/api/src/routes/persons.rs b/api/src/routes/persons.rs index 954da28..81f3f2c 100644 --- a/api/src/routes/persons.rs +++ b/api/src/routes/persons.rs @@ -9,6 +9,12 @@ use rocket::serde::json::Json; use rocket::Route; use sqlx::QueryBuilder; +enum SearchCondition { + Checked(bool), + Inside(bool), + Query(String), +} + pub fn routes() -> Vec { rocket::routes![ search_persons, @@ -139,17 +145,13 @@ pub async fn list_checked_in( let mut conditions = Vec::new(); if let Some(checked_in) = checked { - if checked_in { - conditions.push("checked_in = true".to_string()); - } else { - conditions.push("checked_in = false".to_string()); - } + conditions.push(SearchCondition::Checked(checked_in)); } if let Some(status) = status { match status { - "inside" => conditions.push("inside = true".to_string()), - "outside" => conditions.push("inside = false".to_string()), + "inside" => conditions.push(SearchCondition::Inside(true)), + "outside" => conditions.push(SearchCondition::Inside(false)), _ => {} } } @@ -162,18 +164,51 @@ pub async fn list_checked_in( Some(trimmed.to_string()) } }) { - conditions.push(format!( - "(CONCAT(first_name, ' ', last_name) ILIKE '%{query}%' OR parent_name ILIKE '%{query}%' OR parent_phone_number ILIKE '%{query}%')" - )); + conditions.push(SearchCondition::Query(query)); } - if !conditions.is_empty() { - query_builder.push(" WHERE "); - for (index, condition) in conditions.iter().enumerate() { - if index > 0 { - query_builder.push(" AND "); + let mut first = true; + for condition in &conditions { + if first { + query_builder.push(" WHERE "); + first = false; + } else { + query_builder.push(" AND "); + } + + match condition { + SearchCondition::Checked(flag) => { + query_builder.push("checked_in = "); + query_builder.push_bind(*flag); + } + SearchCondition::Inside(flag) => { + query_builder.push("inside = "); + query_builder.push_bind(*flag); + } + SearchCondition::Query(term) => { + let like = format!("%{}%", term); + let starts_with = format!("{}%", term); + let digits: String = term.chars().filter(|ch| ch.is_ascii_digit()).collect(); + + query_builder.push("("); + query_builder.push("CAST(id AS TEXT) ILIKE "); + query_builder.push_bind(starts_with.clone()); + query_builder.push(" OR CONCAT(first_name, ' ', last_name) ILIKE "); + query_builder.push_bind(like.clone()); + query_builder.push(" OR parent_name ILIKE "); + query_builder.push_bind(like.clone()); + query_builder.push(" OR parent_phone_number ILIKE "); + query_builder.push_bind(like.clone()); + + if !digits.is_empty() { + let digits_like = format!("%{}%", digits); + query_builder + .push(" OR REGEXP_REPLACE(parent_phone_number, '[^0-9]', '', 'g') ILIKE "); + query_builder.push_bind(digits_like); + } + + query_builder.push(")"); } - query_builder.push(condition.as_str()); } } @@ -198,7 +233,8 @@ pub async fn checkin_person( let person = sqlx::query_as::<_, Person>( r#" UPDATE persons - SET checked_in = true + SET checked_in = true, + inside = true WHERE id = $1 RETURNING id, diff --git a/api/src/routes/tournaments.rs b/api/src/routes/tournaments.rs index a558faa..bda7e2e 100644 --- a/api/src/routes/tournaments.rs +++ b/api/src/routes/tournaments.rs @@ -10,6 +10,7 @@ use crate::models::{ TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest, }; use crate::AppState; +use rocket::http::Status; use rocket::serde::json::Json; use rocket::Route; use serde_json::{Map, Value}; @@ -26,6 +27,8 @@ pub fn routes() -> Vec { delete_tournament, list_registrations_by_slug, get_registration_detail_by_slug, + update_registration_by_slug, + delete_registration_by_slug, create_registration_by_slug ] } @@ -104,6 +107,29 @@ fn build_registration_url(slug: &str) -> String { format!("/tournament/{slug}") } +fn signup_fields_equal(left: &[TournamentSignupField], right: &[TournamentSignupField]) -> bool { + if left.len() != right.len() { + return false; + } + + left.iter().zip(right.iter()).all(|(a, b)| { + a.id == b.id + && a.label == b.label + && a.field_type == b.field_type + && a.required == b.required + && a.placeholder == b.placeholder + && a.unique == b.unique + }) +} + +fn signup_fields_changed( + existing: &TournamentSignupConfig, + updated: &TournamentSignupConfig, +) -> bool { + !signup_fields_equal(&existing.entry_fields, &updated.entry_fields) + || !signup_fields_equal(&existing.participant_fields, &updated.participant_fields) +} + async fn load_sections( pool: &sqlx::PgPool, tournament_id: i32, @@ -403,6 +429,191 @@ async fn insert_participant_values( Ok(()) } +async fn load_registration_detail( + pool: &sqlx::PgPool, + info: TournamentInfo, + registration: TournamentRegistrationRow, +) -> Result { + let entry_value_rows = sqlx::query_as::<_, TournamentRegistrationValueRow>( + r#" + SELECT registration_id, signup_field_id, value + FROM tournament_registration_values + WHERE registration_id = $1 + "#, + ) + .bind(registration.id) + .fetch_all(pool) + .await?; + + let participant_rows = sqlx::query_as::<_, TournamentParticipantRow>( + r#" + SELECT id, registration_id, position + FROM tournament_participants + WHERE registration_id = $1 + ORDER BY position ASC, id ASC + "#, + ) + .bind(registration.id) + .fetch_all(pool) + .await?; + + let participant_value_rows = if participant_rows.is_empty() { + Vec::new() + } else { + let participant_ids: Vec = participant_rows.iter().map(|row| row.id).collect(); + sqlx::query_as::<_, TournamentParticipantValueRow>( + r#" + SELECT participant_id, signup_field_id, value + FROM tournament_participant_values + WHERE participant_id = ANY($1) + "#, + ) + .bind(&participant_ids) + .fetch_all(pool) + .await? + }; + + let field_records = load_signup_field_records(pool, info.id).await?; + let mut field_by_id: HashMap = HashMap::new(); + for record in &field_records { + field_by_id.insert(record.id, record); + } + + let mut entry_map = Map::new(); + for row in entry_value_rows { + if let Some(field) = field_by_id.get(&row.signup_field_id) { + if field.scope == "entry" { + entry_map.insert(field.field_key.clone(), Value::String(row.value)); + } + } + } + + let mut participant_value_map: HashMap> = HashMap::new(); + for row in participant_value_rows { + if let Some(field) = field_by_id.get(&row.signup_field_id) { + if field.scope == "participant" { + let map = participant_value_map + .entry(row.participant_id) + .or_insert_with(Map::new); + map.insert(field.field_key.clone(), Value::String(row.value)); + } + } + } + + let mut participant_array = Vec::new(); + for participant in participant_rows { + let values = participant_value_map + .remove(&participant.id) + .map(Value::Object) + .unwrap_or_else(|| Value::Object(Map::new())); + participant_array.push(values); + } + + let registration_item = TournamentRegistrationItem { + id: registration.id, + created_at: registration.created_at, + entry: Value::Object(entry_map), + participants: Value::Array(participant_array), + }; + + let tournament = load_tournament_data(pool, info).await?; + + Ok(TournamentRegistrationDetailResponse { + tournament, + registration: registration_item, + }) +} + +#[rocket::delete("/slug//registrations/")] +pub async fn delete_registration_by_slug( + _user: AuthUser, + state: &rocket::State, + slug: &str, + registration_id: i32, +) -> Result { + let info = sqlx::query_as::<_, TournamentInfo>( + r#" + SELECT + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + FROM tournament_info + WHERE slug = $1 + "#, + ) + .bind(slug) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; + + let registration = sqlx::query_as::<_, TournamentRegistrationRow>( + r#" + SELECT id, tournament_id, entry_label, created_at + FROM tournament_registrations + WHERE id = $1 AND tournament_id = $2 + "#, + ) + .bind(registration_id) + .bind(info.id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?; + + let mut tx = state.db.begin().await?; + + sqlx::query( + "DELETE FROM tournament_participant_values WHERE participant_id IN ( + SELECT id FROM tournament_participants WHERE registration_id = $1 + )", + ) + .bind(registration.id) + .execute(&mut *tx) + .await?; + + sqlx::query("DELETE FROM tournament_participants WHERE registration_id = $1") + .bind(registration.id) + .execute(&mut *tx) + .await?; + + sqlx::query("DELETE FROM tournament_registration_values WHERE registration_id = $1") + .bind(registration.id) + .execute(&mut *tx) + .await?; + + sqlx::query("DELETE FROM tournament_registrations WHERE id = $1") + .bind(registration.id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + + match load_tournament_data(&state.db, info).await { + Ok(tournament) => { + let _ = state + .event_sender + .send(AppEvent::TournamentUpserted { tournament }); + } + Err(err) => { + eprintln!( + "Failed to load tournament data after deleting registration {registration_id}: {err:?}" + ); + } + } + + Ok(Status::NoContent) +} + #[rocket::get("/")] pub async fn list_tournaments( state: &rocket::State, @@ -619,6 +830,35 @@ pub async fn update_tournament( ) -> Result, ApiError> { let request = payload.into_inner(); + let existing_info = sqlx::query_as::<_, TournamentInfo>( + r#" + SELECT + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + FROM tournament_info + WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; + + let existing_field_records = load_signup_field_records(&state.db, existing_info.id).await?; + let existing_config = build_signup_config(&existing_info, &existing_field_records); + if request.title.trim().is_empty() { return Err(ApiError::bad_request("Turneringen måste ha en titel.")); } @@ -639,6 +879,8 @@ pub async fn update_tournament( let description = normalize_optional(request.description); let contact = normalize_optional(request.contact); + let fields_changed = signup_fields_changed(&existing_config, &signup_config); + let mut tx = state.db.begin().await?; let info = sqlx::query_as::<_, TournamentInfo>( @@ -700,7 +942,9 @@ pub async fn update_tournament( .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; insert_sections(&mut tx, id, §ions).await?; - insert_signup_fields(&mut tx, id, &signup_config).await?; + if fields_changed { + insert_signup_fields(&mut tx, id, &signup_config).await?; + } tx.commit().await?; @@ -1053,6 +1297,11 @@ pub async fn create_registration_by_slug( tx.commit().await?; + let tournament_data = load_tournament_data(&state.db, info).await?; + let _ = state.event_sender.send(AppEvent::TournamentUpserted { + tournament: tournament_data, + }); + Ok(Json(TournamentRegistrationResponse { registration_id: registration.id, })) @@ -1265,92 +1514,298 @@ pub async fn get_registration_detail_by_slug( .await? .ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?; - let entry_value_rows = sqlx::query_as::<_, TournamentRegistrationValueRow>( + let detail = load_registration_detail(&state.db, info, registration).await?; + + Ok(Json(detail)) +} + +#[rocket::put("/slug//registrations/", data = "")] +pub async fn update_registration_by_slug( + _user: AuthUser, + state: &rocket::State, + slug: &str, + registration_id: i32, + payload: Json, +) -> Result, ApiError> { + let info = sqlx::query_as::<_, TournamentInfo>( r#" - SELECT registration_id, signup_field_id, value - FROM tournament_registration_values - WHERE registration_id = $1 + SELECT + id, + title, + game, + slug, + tagline, + start_at, + location, + description, + contact, + signup_mode, + team_size_min, + team_size_max, + created_at, + updated_at + FROM tournament_info + WHERE slug = $1 "#, ) - .bind(registration.id) - .fetch_all(&state.db) - .await?; + .bind(slug) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; - let participant_rows = sqlx::query_as::<_, TournamentParticipantRow>( + let mut registration = sqlx::query_as::<_, TournamentRegistrationRow>( r#" - SELECT id, registration_id, position - FROM tournament_participants - WHERE registration_id = $1 - ORDER BY position ASC, id ASC + SELECT id, tournament_id, entry_label, created_at + FROM tournament_registrations + WHERE id = $1 AND tournament_id = $2 "#, ) - .bind(registration.id) - .fetch_all(&state.db) - .await?; - - let participant_value_rows = if participant_rows.is_empty() { - Vec::new() - } else { - let participant_ids: Vec = participant_rows.iter().map(|row| row.id).collect(); - sqlx::query_as::<_, TournamentParticipantValueRow>( - r#" - SELECT participant_id, signup_field_id, value - FROM tournament_participant_values - WHERE participant_id = ANY($1) - "#, - ) - .bind(&participant_ids) - .fetch_all(&state.db) - .await? - }; + .bind(registration_id) + .bind(info.id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?; let field_records = load_signup_field_records(&state.db, info.id).await?; - let mut field_by_id: HashMap = HashMap::new(); - for record in &field_records { - field_by_id.insert(record.id, record); + let config = build_signup_config(&info, &field_records); + let submission = payload.into_inner(); + + validate_submission(&config, &submission)?; + + let mut entry_values: HashMap = HashMap::new(); + for field in &config.entry_fields { + let value = submission + .entry + .get(&field.id) + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + entry_values.insert(field.id.clone(), value); } - let mut entry_map = Map::new(); - for row in entry_value_rows { - if let Some(field) = field_by_id.get(&row.signup_field_id) { - if field.scope == "entry" { - entry_map.insert(field.field_key.clone(), Value::String(row.value)); + let mut participant_values: Vec> = Vec::new(); + for participant in submission.participants { + let mut map = HashMap::new(); + for field in &config.participant_fields { + let value = participant + .get(&field.id) + .map(|v| v.trim().to_string()) + .unwrap_or_default(); + map.insert(field.id.clone(), value); + } + participant_values.push(map); + } + + let mut field_map: HashMap = HashMap::new(); + for record in field_records { + field_map.insert(record.field_key.clone(), record); + } + + let mut tx = state.db.begin().await?; + + let entry_label = config + .entry_fields + .first() + .and_then(|field| entry_values.get(&field.id)) + .and_then(|value| trimmed(Some(value))); + + let entry_label_requires_unique = config + .entry_fields + .first() + .map(|field| field.unique) + .unwrap_or(false); + + if entry_label_requires_unique { + if let Some(label) = entry_label.clone() { + let is_duplicate = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 + FROM tournament_registrations + WHERE tournament_id = $1 + AND entry_label = $2 + AND id <> $3 + ) + "#, + ) + .bind(info.id) + .bind(&label) + .bind(registration.id) + .fetch_one(&state.db) + .await?; + + if is_duplicate { + return Err(ApiError::bad_request( + "Den här spelaren eller laget är redan anmäld till turneringen.", + )); } } } - let mut participant_value_map: HashMap> = HashMap::new(); - for row in participant_value_rows { - if let Some(field) = field_by_id.get(&row.signup_field_id) { - if field.scope == "participant" { - let map = participant_value_map - .entry(row.participant_id) - .or_insert_with(Map::new); - map.insert(field.field_key.clone(), Value::String(row.value)); + for field in &config.entry_fields { + if !field.unique { + continue; + } + + if let Some(value) = entry_values.get(&field.id) { + if value.is_empty() { + continue; + } + + if let Some(record) = field_map.get(&field.id) { + let exists = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 + FROM tournament_registration_values v + INNER JOIN tournament_registrations r ON r.id = v.registration_id + WHERE r.tournament_id = $1 + AND v.signup_field_id = $2 + AND v.value = $3 + AND r.id <> $4 + ) + "#, + ) + .bind(info.id) + .bind(record.id) + .bind(value) + .bind(registration.id) + .fetch_one(&state.db) + .await?; + + if exists { + return Err(ApiError::bad_request(format!( + "Värdet för '{label}' används redan i en annan anmälan.", + label = field.label + ))); + } } } } - let mut participant_array = Vec::new(); - for participant in participant_rows { - let values = participant_value_map - .remove(&participant.id) - .map(Value::Object) - .unwrap_or_else(|| Value::Object(Map::new())); - participant_array.push(values); + for participant_values_map in &participant_values { + for field in &config.participant_fields { + if !field.unique { + continue; + } + + if let Some(value) = participant_values_map.get(&field.id) { + if value.is_empty() { + continue; + } + + if let Some(record) = field_map.get(&field.id) { + let exists = sqlx::query_scalar::<_, bool>( + r#" + SELECT EXISTS ( + SELECT 1 + FROM tournament_participant_values pv + INNER JOIN tournament_participants tp ON tp.id = pv.participant_id + INNER JOIN tournament_registrations tr ON tr.id = tp.registration_id + WHERE tr.tournament_id = $1 + AND pv.signup_field_id = $2 + AND pv.value = $3 + AND tr.id <> $4 + ) + "#, + ) + .bind(info.id) + .bind(record.id) + .bind(value) + .bind(registration.id) + .fetch_one(&state.db) + .await?; + + if exists { + return Err(ApiError::bad_request(format!( + "Värdet för '{label}' används redan i en annan anmälan.", + label = field.label + ))); + } + } + } + } } - let registration_item = TournamentRegistrationItem { - id: registration.id, - created_at: registration.created_at, - entry: Value::Object(entry_map), - participants: Value::Array(participant_array), - }; + sqlx::query("DELETE FROM tournament_registration_values WHERE registration_id = $1") + .bind(registration.id) + .execute(&mut *tx) + .await?; - let tournament = load_tournament_data(&state.db, info.clone()).await?; + sqlx::query( + "DELETE FROM tournament_participant_values WHERE participant_id IN ( + SELECT id FROM tournament_participants WHERE registration_id = $1 + )", + ) + .bind(registration.id) + .execute(&mut *tx) + .await?; - Ok(Json(TournamentRegistrationDetailResponse { - tournament, - registration: registration_item, - })) + sqlx::query("DELETE FROM tournament_participants WHERE registration_id = $1") + .bind(registration.id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + UPDATE tournament_registrations + SET entry_label = $1 + WHERE id = $2 + "#, + ) + .bind(entry_label) + .bind(registration.id) + .execute(&mut *tx) + .await?; + + for field in &config.entry_fields { + if let Some(record) = field_map.get(&field.id) { + if let Some(value) = entry_values.get(&field.id) { + insert_registration_values(&mut tx, registration.id, record, value).await?; + } + } + } + + for (position, values) in participant_values.iter().enumerate() { + let participant = sqlx::query_as::<_, TournamentParticipantRow>( + r#" + INSERT INTO tournament_participants (registration_id, position) + VALUES ($1, $2) + RETURNING id, registration_id, position + "#, + ) + .bind(registration.id) + .bind(position as i32) + .fetch_one(&mut *tx) + .await?; + + for field in &config.participant_fields { + if let Some(record) = field_map.get(&field.id) { + if let Some(value) = values.get(&field.id) { + insert_participant_values(&mut tx, participant.id, record, value).await?; + } + } + } + } + + tx.commit().await?; + + registration = sqlx::query_as::<_, TournamentRegistrationRow>( + r#" + SELECT id, tournament_id, entry_label, created_at + FROM tournament_registrations + WHERE id = $1 AND tournament_id = $2 + "#, + ) + .bind(registration_id) + .bind(info.id) + .fetch_one(&state.db) + .await?; + + let detail = load_registration_detail(&state.db, info, registration).await?; + + let _ = state.event_sender.send(AppEvent::TournamentUpserted { + tournament: detail.tournament.clone(), + }); + + Ok(Json(detail)) } diff --git a/web/src/lib/client/person-search.ts b/web/src/lib/client/person-search.ts new file mode 100644 index 0000000..0a4b830 --- /dev/null +++ b/web/src/lib/client/person-search.ts @@ -0,0 +1,128 @@ +import type { Person } from '$lib/types'; + +type NormalizedQuery = { + text: string; + digits: string; + numericId: number | null; +}; + +function normalizeQuery(raw: string): NormalizedQuery { + const trimmed = raw.trim(); + if (!trimmed) { + return { text: '', digits: '', numericId: null }; + } + + const numericId = /^\d+$/.test(trimmed) ? Number.parseInt(trimmed, 10) : null; + const lower = trimmed.toLowerCase(); + const digits = trimmed.replace(/\D/g, ''); + + return { + text: lower, + digits, + numericId + }; +} + +export function personMatchesQuery(person: Person, rawQuery: string): boolean { + const query = normalizeQuery(rawQuery); + if (!query.text) { + return true; + } + + if (query.numericId !== null && person.id === query.numericId) { + return true; + } + + const idString = person.id.toString(); + if (idString.includes(query.text)) { + return true; + } + + const fullName = `${person.first_name} ${person.last_name}`.toLowerCase(); + if (fullName.includes(query.text)) { + return true; + } + + const parentName = person.parent_name?.toLowerCase() ?? ''; + if (parentName && parentName.includes(query.text)) { + return true; + } + + if (query.digits) { + const phoneDigits = (person.parent_phone_number ?? '').replace(/\D/g, ''); + if (phoneDigits.includes(query.digits)) { + return true; + } + } + + if (query.text && person.grade !== null && query.text === String(person.grade)) { + return true; + } + + return false; +} + +function scorePerson(person: Person, query: NormalizedQuery): number { + if (!query.text) { + return 0; + } + + let score = 0; + + if (query.numericId !== null && person.id === query.numericId) { + return 1_000; + } + + const idString = person.id.toString(); + if (idString.startsWith(query.text)) { + score += 180; + } else if (idString.includes(query.text)) { + score += 140; + } + + const fullName = `${person.first_name} ${person.last_name}`.toLowerCase(); + if (fullName.includes(query.text)) { + score += 120; + } + + const parentName = person.parent_name?.toLowerCase() ?? ''; + if (parentName && parentName.includes(query.text)) { + score += 100; + } + + if (query.digits) { + const phoneDigits = (person.parent_phone_number ?? '').replace(/\D/g, ''); + if (phoneDigits.includes(query.digits)) { + score += 80; + } + } + + if (person.grade !== null && query.text === String(person.grade)) { + score += 40; + } + + return score; +} + +export function sortPersonsByQuery(list: Person[], rawQuery: string): Person[] { + const query = normalizeQuery(rawQuery); + if (!query.text) { + return [...list]; + } + + return [...list].sort((a, b) => { + const scoreA = scorePerson(a, query); + const scoreB = scorePerson(b, query); + if (scoreA !== scoreB) { + return scoreB - scoreA; + } + return a.id - b.id; + }); +} + +export function filterPersonsByQuery(list: Person[], rawQuery: string): Person[] { + if (!rawQuery.trim()) { + return [...list]; + } + return list.filter((person) => personMatchesQuery(person, rawQuery)); +} diff --git a/web/src/routes/(admin)/admin/checkin/+page.svelte b/web/src/routes/(admin)/admin/checkin/+page.svelte index 3dbc8f1..f30d8a1 100644 --- a/web/src/routes/(admin)/admin/checkin/+page.svelte +++ b/web/src/routes/(admin)/admin/checkin/+page.svelte @@ -9,6 +9,10 @@ isLowerGrade, personIsComplete } from '$lib/client/person-utils'; + import { + personMatchesQuery, + sortPersonsByQuery + } from '$lib/client/person-search'; import EditPersonModal from '$lib/components/edit-person-modal.svelte'; let searchQuery = $state(''); @@ -158,8 +162,11 @@ } function updateVisibleResults() { - const filtered = searchResults.filter((person) => !person.checked_in); - visibleResults = filtered; + const query = searchQuery.trim(); + const filtered = searchResults.filter( + (person) => !person.checked_in && (!query || personMatchesQuery(person, query)) + ); + visibleResults = query ? sortPersonsByQuery(filtered, query) : filtered; if (searchResults.length === 0) { searchInfo = 'Ingen träff på sökningen.'; @@ -179,6 +186,7 @@ stop(); }; }); +
diff --git a/web/src/routes/(admin)/admin/checkin/checked-in/+page.svelte b/web/src/routes/(admin)/admin/checkin/checked-in/+page.svelte index 328b1a9..bbc6732 100644 --- a/web/src/routes/(admin)/admin/checkin/checked-in/+page.svelte +++ b/web/src/routes/(admin)/admin/checkin/checked-in/+page.svelte @@ -5,6 +5,7 @@ import { listenToPersonEvents } from '$lib/client/person-events'; import { updateCollection } from '$lib/client/person-collection'; import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils'; + import { personMatchesQuery, sortPersonsByQuery } from '$lib/client/person-search'; import EditPersonModal from '$lib/components/edit-person-modal.svelte'; type StatusFilter = 'all' | 'inside' | 'outside'; @@ -49,22 +50,16 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats'; if (visitorFilter === 'besoksplats' && !person.visitor) return false; if (visitorFilter === 'lanplats' && person.visitor) return false; if (sleepingFilter === 'needs' && !person.sleeping_spot) return false; - if (sleepingFilter === 'not-needed' && person.sleeping_spot) return false; - if (searchQuery.trim()) { - const query = searchQuery.trim().toLowerCase(); - const matchesText = - `${person.first_name} ${person.last_name}`.toLowerCase().includes(query) || - person.parent_name?.toLowerCase().includes(query) || - person.parent_phone_number?.toLowerCase().includes(query) || - person.id.toString() === query; - if (!matchesText) return false; - } + if (sleepingFilter === 'not-needed' && person.sleeping_spot) return false; + const trimmed = searchQuery.trim(); + if (trimmed && !personMatchesQuery(person, trimmed)) return false; return true; } function updateVisiblePersons() { const filtered = allPersons.filter((person) => matchesFilters(person)); - persons = filtered; + const query = searchQuery.trim(); + persons = query ? sortPersonsByQuery(filtered, query) : filtered; infoMessage = filtered.length === 0 ? 'Inga personer matchar kriterierna.' : ''; } diff --git a/web/src/routes/(admin)/admin/checkin/checkout/+page.svelte b/web/src/routes/(admin)/admin/checkin/checkout/+page.svelte index bea11df..8c695b6 100644 --- a/web/src/routes/(admin)/admin/checkin/checkout/+page.svelte +++ b/web/src/routes/(admin)/admin/checkin/checkout/+page.svelte @@ -4,6 +4,7 @@ import { onMount } from 'svelte'; import { listenToPersonEvents } from '$lib/client/person-events'; import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils'; + import { personMatchesQuery, sortPersonsByQuery } from '$lib/client/person-search'; type GradeFilter = 'all' | 'lt4' | 'ge4'; type VisitorFilter = 'all' | 'besoksplats' | 'lanplats'; @@ -42,17 +43,9 @@ return false; } - const query = searchQuery.trim().toLowerCase(); - if (query) { - const combinedName = `${person.first_name} ${person.last_name}`.toLowerCase(); - const matchesText = - combinedName.includes(query) || - person.parent_name?.toLowerCase().includes(query) || - person.parent_phone_number?.toLowerCase().includes(query) || - person.id.toString() === query; - if (!matchesText) { - return false; - } + const query = searchQuery.trim(); + if (query && !personMatchesQuery(person, query)) { + return false; } return true; @@ -176,7 +169,8 @@ function updateVisibleResults() { const filtered = searchResults.filter((person) => matchesFilters(person)); - visibleResults = filtered; + const query = searchQuery.trim(); + visibleResults = query ? sortPersonsByQuery(filtered, query) : filtered; if (searchResults.length === 0 && !searchQuery.trim()) { searchInfo = 'Inga personer hämtades.'; diff --git a/web/src/routes/(admin)/admin/checkin/inside-status/+page.svelte b/web/src/routes/(admin)/admin/checkin/inside-status/+page.svelte index ac338fb..d229405 100644 --- a/web/src/routes/(admin)/admin/checkin/inside-status/+page.svelte +++ b/web/src/routes/(admin)/admin/checkin/inside-status/+page.svelte @@ -5,6 +5,7 @@ import { listenToPersonEvents } from '$lib/client/person-events'; import { updateCollection } from '$lib/client/person-collection'; import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils'; + import { personMatchesQuery, sortPersonsByQuery } from '$lib/client/person-search'; type StatusFilter = 'all' | 'inside' | 'outside'; @@ -33,21 +34,15 @@ if (!person.checked_in) return false; if (statusFilter === 'inside' && !person.inside) return false; if (statusFilter === 'outside' && person.inside) return false; - const query = searchQuery.trim().toLowerCase(); - if (query) { - const matchesText = - `${person.first_name} ${person.last_name}`.toLowerCase().includes(query) || - person.parent_name?.toLowerCase().includes(query) || - person.parent_phone_number?.toLowerCase().includes(query) || - person.id.toString() === query; - if (!matchesText) return false; - } + const query = searchQuery.trim(); + if (query && !personMatchesQuery(person, query)) return false; return true; } function applyFilteredList(list: Person[]) { const filtered = list.filter(matchesFilters); - persons = filtered; + const query = searchQuery.trim(); + persons = query ? sortPersonsByQuery(filtered, query) : filtered; if (persons.length === 0) { infoMessage = 'Inga incheckade personer matchar kriterierna.'; } else { @@ -57,6 +52,10 @@ function handlePersonUpdate(person: Person) { persons = updateCollection(persons, person, matchesFilters); + const query = searchQuery.trim(); + if (query) { + persons = sortPersonsByQuery(persons, query); + } if (persons.length === 0) { infoMessage = 'Inga incheckade personer matchar kriterierna.'; } else { diff --git a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.server.ts b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.server.ts index e92be9b..002e072 100644 --- a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.server.ts +++ b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.server.ts @@ -1,25 +1,17 @@ import { error } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import type { TournamentRegistrationList } from '$lib/types'; +import { ApiRequestError, fetchRegistrationList, parseApiMessage } from './helpers.server'; + +const loadErrorFallback = 'Kunde inte hämta anmälningar.'; export const load: PageServerLoad = async ({ fetch, params }) => { - const response = await fetch(`/api/tournament/slug/${params.slug}/registrations`); - const text = await response.text(); - - if (!response.ok) { - let message = 'Kunde inte hämta anmälningar.'; - try { - const body = JSON.parse(text); - message = body.message ?? message; - } catch { - if (text) message = text; + try { + return await fetchRegistrationList(fetch, params.slug); + } catch (err) { + if (err instanceof ApiRequestError) { + const message = parseApiMessage(err.body, loadErrorFallback); + throw error(err.status, message); } - throw error(response.status, message); + throw err; } - - const data = JSON.parse(text) as TournamentRegistrationList; - return { - tournament: data.tournament, - registrations: data.registrations - }; }; diff --git a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.svelte b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.svelte index 210a857..3bc4565 100644 --- a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.svelte +++ b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/+page.svelte @@ -1,13 +1,107 @@ + @@ -73,21 +404,40 @@

Format

- {tournament.signup_config.mode === 'team' - ? `Lag (${tournament.signup_config.team_size.min}–${tournament.signup_config.team_size.max} spelare)` + {signupConfig().mode === 'team' + ? `Lag (${signupConfig().team_size.min}–${signupConfig().team_size.max} spelare)` : 'Individuell'}

-
-
-

Registreringar

- {#if registrations.length > 0} -

Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}

- {/if} -
+
+
+

Registreringar

+
+ {#if registrations.length > 0} +

Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}

+ {/if} + +
+
+ + {#if loadError} +

{loadError}

+ {/if} + {#if deleteError} +

{deleteError}

+ {/if} + {#if refreshing && !loadError} +

Uppdaterar…

+ {/if} {#if registrations.length === 0}

@@ -97,52 +447,188 @@

{#each registrations as registration}
-
-

Anmälan #{registration.id}

-

- Skapad {formatDateTime(registration.created_at) ?? registration.created_at} -

+
+
+

Anmälan #{registration.id}

+

+ Skapad {formatDateTime(registration.created_at) ?? registration.created_at} +

+
+
+ {#if editingId === registration.id} + + {:else} + + {/if} + +
- {#if entryFields.length > 0} -
- {#each entryFields as field} -
-

{field.label}

-

{fieldValue(registration.entry, field) || '—'}

+ {#if editingId === registration.id} +
{ + event.preventDefault(); + saveEdit(registration); + }}> + {#if entryFields().length > 0} +
+

Lag / deltagare

+
+ {#each entryFields() as field} + + {/each} +
- {/each} -
- {/if} - -
-

Spelare

- {#if participantFields.length === 0} - {#if registration.participants.length === 0} -

Inga spelare angivna.

- {:else} -

Antal spelare: {registration.participants.length}

{/if} - {:else if registration.participants.length === 0} -

Inga spelare angivna.

- {:else} -
- {#each registration.participants as participant, index} + +
+
+

Spelare

+ {#if signupConfig().mode === 'team'} + + {/if} +
+ + {#if participantFields().length === 0} +

Inga spelarspecifika fält att redigera.

+ {:else if editParticipants.length === 0} +

Inga spelare angivna.

+ {:else} +
+ {#each editParticipants as participant, index} +
+
+

{participantLabel(index)}

+ {#if signupConfig().mode === 'team'} + + {/if} +
+
+ {#each participantFields() as field} + + {/each} +
+
+ {/each} +
+ {/if} +
+ + {#if editError} +

{editError}

+ {/if} + +
+ + +
+ + {:else} + {#if entryFields().length > 0} +
+ {#each entryFields() as field}
-

Spelare {index + 1}

-
    - {#each participantFields as field} -
  • - {field.label}: - {fieldValue(participant, field) || '—'} -
  • - {/each} -
+

{field.label}

+

{fieldValue(registration.entry, field) || '—'}

{/each}
{/if} -
+ +
+

Spelare

+ {#if participantFields().length === 0} + {#if registration.participants.length === 0} +

Inga spelare angivna.

+ {:else} +

Antal spelare: {registration.participants.length}

+ {/if} + {:else if registration.participants.length === 0} +

Inga spelare angivna.

+ {:else} +
+ {#each registration.participants as participant, index} +
+

Spelare {index + 1}

+
    + {#each participantFields() as field} +
  • + {field.label}: + {fieldValue(participant, field) || '—'} +
  • + {/each} +
+
+ {/each} +
+ {/if} +
+ {/if}
{/each}
diff --git a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/delete/+server.ts b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/delete/+server.ts new file mode 100644 index 0000000..add2450 --- /dev/null +++ b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/delete/+server.ts @@ -0,0 +1,109 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { ApiRequestError, fetchRegistrationList, parseApiMessage } from '../helpers.server'; +import type { TournamentInfo } from '$lib/types'; + +const deleteErrorFallback = 'Kunde inte ta bort anmälan.'; + +export const POST: RequestHandler = async ({ fetch, params, request }) => { + try { + const snapshot = await fetchRegistrationList(fetch, params.slug); + const body = await request.json().catch(() => null); + const registrationId = + body && typeof body.registration_id === 'number' + ? body.registration_id + : Number.parseInt(body?.registration_id ?? '', 10); + + if (!Number.isFinite(registrationId)) { + return json({ message: 'Ogiltigt anmälnings-ID.' }, { status: 400 }); + } + + const apiResponse = await fetch( + `/api/tournament/slug/${params.slug}/registrations/${registrationId}`, + { method: 'DELETE' } + ); + const text = await apiResponse.text(); + if (!apiResponse.ok) { + const message = parseApiMessage(text, deleteErrorFallback); + try { + const data = await fetchRegistrationList(fetch, params.slug); + const removed = !data.registrations.some( + (registration) => registration.id === registrationId + ); + if (removed) { + return json({ ...data, warning: message }, { status: 200 }); + } + } catch (err) { + if (err instanceof ApiRequestError) { + const fallbackRegistration = snapshot.registrations.find( + (registration) => registration.id === registrationId + ); + const filteredRegistrations = snapshot.registrations.filter( + (registration) => registration.id !== registrationId + ); + const participantAdjustment = fallbackRegistration?.participants?.length ?? 0; + const fallbackTournament: TournamentInfo = { + ...snapshot.tournament, + total_registrations: Math.max(snapshot.tournament.total_registrations - 1, 0), + total_participants: Math.max( + snapshot.tournament.total_participants - participantAdjustment, + 0 + ), + updated_at: new Date().toISOString() + }; + return json( + { + warning: message, + tournament: fallbackTournament, + registrations: filteredRegistrations + }, + { status: 200 } + ); + } + throw err; + } + return json({ message }, { status: apiResponse.status }); + } + + try { + const data = await fetchRegistrationList(fetch, params.slug); + return json(data); + } catch (err) { + if (err instanceof ApiRequestError) { + const message = parseApiMessage(err.body, deleteErrorFallback); + const fallbackRegistration = snapshot.registrations.find( + (registration) => registration.id === registrationId + ); + const filteredRegistrations = snapshot.registrations.filter( + (registration) => registration.id !== registrationId + ); + const participantAdjustment = fallbackRegistration?.participants?.length ?? 0; + const fallbackTournament: TournamentInfo = { + ...snapshot.tournament, + total_registrations: Math.max(snapshot.tournament.total_registrations - 1, 0), + total_participants: Math.max( + snapshot.tournament.total_participants - participantAdjustment, + 0 + ), + updated_at: new Date().toISOString() + }; + return json( + { + warning: message, + tournament: fallbackTournament, + registrations: filteredRegistrations + }, + { status: 200 } + ); + } + throw err; + } + } catch (err) { + if (err instanceof ApiRequestError) { + const message = parseApiMessage(err.body, deleteErrorFallback); + return json({ message }, { status: err.status }); + } + console.error('Unexpected error deleting registration', err); + return json({ message: 'Ett oväntat fel inträffade.' }, { status: 500 }); + } +}; diff --git a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/helpers.server.ts b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/helpers.server.ts new file mode 100644 index 0000000..b25711d --- /dev/null +++ b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/helpers.server.ts @@ -0,0 +1,62 @@ +import type { TournamentRegistrationList, TournamentRegistrationItem, TournamentInfo } from '$lib/types'; + +export class ApiRequestError extends Error { + status: number; + body: string; + + constructor(status: number, body: string) { + super(`API request failed with status ${status}`); + this.status = status; + this.body = body; + } +} + +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export function parseApiMessage(body: string, fallback: string): string { + if (!body) return fallback; + try { + const parsed = JSON.parse(body) as { message?: string } | undefined; + if (parsed?.message && parsed.message.trim()) { + return parsed.message; + } + } catch { + // ignore JSON parse errors + } + const trimmed = body.trim(); + return trimmed || fallback; +} + +export async function fetchRegistrationList( + fetchFn: FetchFn, + slug: string +): Promise<{ tournament: TournamentInfo; registrations: TournamentRegistrationItem[] }> { + const response = await fetchFn(`/api/tournament/slug/${slug}/registrations`, { + method: 'GET' + }); + const text = await response.text(); + if (!response.ok) { + throw new ApiRequestError(response.status, text); + } + const data = JSON.parse(text) as TournamentRegistrationList; + return { + tournament: data.tournament, + registrations: data.registrations ?? [] + }; +} + +export function normalizeRecord(value: unknown): Record { + if (!value || typeof value !== 'object') return {}; + const record: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + if (typeof entry === 'string') { + record[key] = entry; + } + } + return record; +} + +export function normalizeParticipants(value: unknown): Record[] { + if (!Array.isArray(value)) return []; + return value.map((item) => normalizeRecord(item)); +} diff --git a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/refresh/+server.ts b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/refresh/+server.ts new file mode 100644 index 0000000..4e0b612 --- /dev/null +++ b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/refresh/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { ApiRequestError, fetchRegistrationList, parseApiMessage } from '../helpers.server'; + +const loadErrorFallback = 'Kunde inte hämta anmälningar.'; + +export const POST: RequestHandler = async ({ fetch, params }) => { + try { + const data = await fetchRegistrationList(fetch, params.slug); + return json(data); + } catch (err) { + if (err instanceof ApiRequestError) { + const message = parseApiMessage(err.body, loadErrorFallback); + return json({ message }, { status: err.status }); + } + console.error('Unexpected error refreshing registrations', err); + return json({ message: 'Ett oväntat fel inträffade.' }, { status: 500 }); + } +}; diff --git a/web/src/routes/(admin)/admin/tournament/[slug]/registrations/update/+server.ts b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/update/+server.ts new file mode 100644 index 0000000..6cc5a38 --- /dev/null +++ b/web/src/routes/(admin)/admin/tournament/[slug]/registrations/update/+server.ts @@ -0,0 +1,54 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + ApiRequestError, + fetchRegistrationList, + parseApiMessage, + normalizeRecord, + normalizeParticipants +} from '../helpers.server'; + +const updateErrorFallback = 'Kunde inte uppdatera anmälan.'; + +export const POST: RequestHandler = async ({ fetch, params, request }) => { + try { + const raw = await request.json().catch(() => null); + const registrationId = + raw && typeof raw.registration_id === 'number' + ? raw.registration_id + : Number.parseInt(raw?.registration_id ?? '', 10); + + if (!Number.isFinite(registrationId)) { + return json({ message: 'Ogiltigt anmälnings-ID.' }, { status: 400 }); + } + + const entry = normalizeRecord(raw?.entry); + const participants = normalizeParticipants(raw?.participants); + + const payload = { entry, participants }; + + const apiResponse = await fetch( + `/api/tournament/slug/${params.slug}/registrations/${registrationId}`, + { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload) + } + ); + const text = await apiResponse.text(); + if (!apiResponse.ok) { + const message = parseApiMessage(text, updateErrorFallback); + return json({ message }, { status: apiResponse.status }); + } + + const data = await fetchRegistrationList(fetch, params.slug); + return json(data); + } catch (err) { + if (err instanceof ApiRequestError) { + const message = parseApiMessage(err.body, updateErrorFallback); + return json({ message }, { status: err.status }); + } + console.error('Unexpected error updating registration', err); + return json({ message: 'Ett oväntat fel inträffade.' }, { status: 500 }); + } +}; diff --git a/web/src/routes/(tournament)/tournament/+page.svelte b/web/src/routes/(tournament)/tournament/+page.svelte index c774f9c..da3e78c 100644 --- a/web/src/routes/(tournament)/tournament/+page.svelte +++ b/web/src/routes/(tournament)/tournament/+page.svelte @@ -1,26 +1,36 @@ - LAN Tournament + Turneringar – VBytes LAN
-
-
-

VBytes LAN

-

{featuredTournament?.game ?? 'Turnering'}

-

{featuredTournament?.title ?? 'Turnering & Community'}

- {#if featuredTournament?.tagline} -

{featuredTournament.tagline}

- {:else} -

- Samla laget, följ brackets i realtid och håll koll på allt som händer under turneringen. -

- {/if} -
+
+
+

VBytes LAN

+

Turneringar

+

+ Samla laget, följ brackets i realtid och håll koll på allt som händer under turneringarna. +

+
- {#if featuredTournament} -
- {#if featuredTournament.start_at} -

Start: {formatDate(featuredTournament.start_at) ?? featuredTournament.start_at}

- {/if} - {#if featuredTournament.location} -

Plats: {featuredTournament.location}

- {/if} - {#if featuredTournament.description} -

{featuredTournament.description}

- {/if} -
- {#if featuredTournament.slug} - - Anmäl laget - - {/if} - {#if featuredTournament.contact} - - Kontakt: {featuredTournament.contact} - - {/if} -
-
- {/if} - - {#if otherTournaments.length > 0} -
-

Fler event

-
- {#each otherTournaments as tournament} -
-

{tournament.title}

+ {#if tournaments().length > 0} +
+ {#each tournaments() as tournament} +
+
+ {tournament.game} + + {registrationSummary(tournament)} + +
+

{tournament.title}

+ {#if tournament.tagline} +

{tournament.tagline}

+ {:else if tournament.description} +

{tournament.description}

+ {/if} +
{#if tournament.start_at} -

{formatDate(tournament.start_at) ?? tournament.start_at}

+
+ Start: + {formatDate(tournament.start_at) ?? tournament.start_at} +
{/if} - {#if tournament.tagline} -

{tournament.tagline}

- {:else if tournament.description} -

{tournament.description}

+ {#if tournament.location} +
+ Plats: + {tournament.location} +
{/if} -
+ {#if tournament.contact} +
+ Kontakt: + {tournament.contact} +
+ {/if} +
+
{#if tournament.slug} - Anmälan + Visa turnering + {:else} + Ingen publik sida tillgänglig. {/if} - {#if tournament.contact} - - Kontakt: {tournament.contact} - - {/if} -
- {/each} -
+ + {/each}
+ {:else} +

+ Inga turneringar är publicerade ännu. Kom tillbaka senare! +

{/if} - - Till admin - +
diff --git a/web/src/routes/(tournament)/tournament/[slug]/+page.svelte b/web/src/routes/(tournament)/tournament/[slug]/+page.svelte index 5ce788d..886e7a3 100644 --- a/web/src/routes/(tournament)/tournament/[slug]/+page.svelte +++ b/web/src/routes/(tournament)/tournament/[slug]/+page.svelte @@ -467,11 +467,11 @@
{participantDisplayName(index)} - {#if signupConfig.mode === 'team'} + {#if signupConfig.mode === 'team' && canRemoveParticipant()}