"swapping distro middle save"

This commit is contained in:
Sebastian 2025-09-25 16:05:59 +02:00
parent 89c6a5a340
commit 4e3be28cf3
17 changed files with 1723 additions and 290 deletions

View file

@ -9,6 +9,12 @@ use rocket::serde::json::Json;
use rocket::Route; use rocket::Route;
use sqlx::QueryBuilder; use sqlx::QueryBuilder;
enum SearchCondition {
Checked(bool),
Inside(bool),
Query(String),
}
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
rocket::routes![ rocket::routes![
search_persons, search_persons,
@ -139,17 +145,13 @@ pub async fn list_checked_in(
let mut conditions = Vec::new(); let mut conditions = Vec::new();
if let Some(checked_in) = checked { if let Some(checked_in) = checked {
if checked_in { conditions.push(SearchCondition::Checked(checked_in));
conditions.push("checked_in = true".to_string());
} else {
conditions.push("checked_in = false".to_string());
}
} }
if let Some(status) = status { if let Some(status) = status {
match status { match status {
"inside" => conditions.push("inside = true".to_string()), "inside" => conditions.push(SearchCondition::Inside(true)),
"outside" => conditions.push("inside = false".to_string()), "outside" => conditions.push(SearchCondition::Inside(false)),
_ => {} _ => {}
} }
} }
@ -162,18 +164,51 @@ pub async fn list_checked_in(
Some(trimmed.to_string()) Some(trimmed.to_string())
} }
}) { }) {
conditions.push(format!( conditions.push(SearchCondition::Query(query));
"(CONCAT(first_name, ' ', last_name) ILIKE '%{query}%' OR parent_name ILIKE '%{query}%' OR parent_phone_number ILIKE '%{query}%')"
));
} }
if !conditions.is_empty() { let mut first = true;
query_builder.push(" WHERE "); for condition in &conditions {
for (index, condition) in conditions.iter().enumerate() { if first {
if index > 0 { query_builder.push(" WHERE ");
query_builder.push(" AND "); 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>( let person = sqlx::query_as::<_, Person>(
r#" r#"
UPDATE persons UPDATE persons
SET checked_in = true SET checked_in = true,
inside = true
WHERE id = $1 WHERE id = $1
RETURNING RETURNING
id, id,

View file

@ -10,6 +10,7 @@ use crate::models::{
TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest, TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest,
}; };
use crate::AppState; use crate::AppState;
use rocket::http::Status;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::Route; use rocket::Route;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
@ -26,6 +27,8 @@ pub fn routes() -> Vec<Route> {
delete_tournament, delete_tournament,
list_registrations_by_slug, list_registrations_by_slug,
get_registration_detail_by_slug, get_registration_detail_by_slug,
update_registration_by_slug,
delete_registration_by_slug,
create_registration_by_slug create_registration_by_slug
] ]
} }
@ -104,6 +107,29 @@ fn build_registration_url(slug: &str) -> String {
format!("/tournament/{slug}") 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( async fn load_sections(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
tournament_id: i32, tournament_id: i32,
@ -403,6 +429,191 @@ async fn insert_participant_values(
Ok(()) Ok(())
} }
async fn load_registration_detail(
pool: &sqlx::PgPool,
info: TournamentInfo,
registration: TournamentRegistrationRow,
) -> Result<TournamentRegistrationDetailResponse, ApiError> {
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<i32> = 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<i32, &TournamentSignupFieldRecord> = 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<i32, Map<String, Value>> = 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/<slug>/registrations/<registration_id>")]
pub async fn delete_registration_by_slug(
_user: AuthUser,
state: &rocket::State<AppState>,
slug: &str,
registration_id: i32,
) -> Result<Status, ApiError> {
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("/")] #[rocket::get("/")]
pub async fn list_tournaments( pub async fn list_tournaments(
state: &rocket::State<AppState>, state: &rocket::State<AppState>,
@ -619,6 +830,35 @@ pub async fn update_tournament(
) -> Result<Json<TournamentItemResponse>, ApiError> { ) -> Result<Json<TournamentItemResponse>, ApiError> {
let request = payload.into_inner(); 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() { if request.title.trim().is_empty() {
return Err(ApiError::bad_request("Turneringen måste ha en titel.")); 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 description = normalize_optional(request.description);
let contact = normalize_optional(request.contact); let contact = normalize_optional(request.contact);
let fields_changed = signup_fields_changed(&existing_config, &signup_config);
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
let info = sqlx::query_as::<_, TournamentInfo>( let info = sqlx::query_as::<_, TournamentInfo>(
@ -700,7 +942,9 @@ pub async fn update_tournament(
.ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?; .ok_or_else(|| ApiError::not_found("Turneringen hittades inte."))?;
insert_sections(&mut tx, id, &sections).await?; insert_sections(&mut tx, id, &sections).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?; tx.commit().await?;
@ -1053,6 +1297,11 @@ pub async fn create_registration_by_slug(
tx.commit().await?; 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 { Ok(Json(TournamentRegistrationResponse {
registration_id: registration.id, registration_id: registration.id,
})) }))
@ -1265,92 +1514,298 @@ pub async fn get_registration_detail_by_slug(
.await? .await?
.ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?; .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/<slug>/registrations/<registration_id>", data = "<payload>")]
pub async fn update_registration_by_slug(
_user: AuthUser,
state: &rocket::State<AppState>,
slug: &str,
registration_id: i32,
payload: Json<TournamentSignupSubmission>,
) -> Result<Json<TournamentRegistrationDetailResponse>, ApiError> {
let info = sqlx::query_as::<_, TournamentInfo>(
r#" r#"
SELECT registration_id, signup_field_id, value SELECT
FROM tournament_registration_values id,
WHERE registration_id = $1 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) .bind(slug)
.fetch_all(&state.db) .fetch_optional(&state.db)
.await?; .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#" r#"
SELECT id, registration_id, position SELECT id, tournament_id, entry_label, created_at
FROM tournament_participants FROM tournament_registrations
WHERE registration_id = $1 WHERE id = $1 AND tournament_id = $2
ORDER BY position ASC, id ASC
"#, "#,
) )
.bind(registration.id) .bind(registration_id)
.fetch_all(&state.db) .bind(info.id)
.await?; .fetch_optional(&state.db)
.await?
let participant_value_rows = if participant_rows.is_empty() { .ok_or_else(|| ApiError::not_found("Anmälan hittades inte."))?;
Vec::new()
} else {
let participant_ids: Vec<i32> = 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?
};
let field_records = load_signup_field_records(&state.db, info.id).await?; let field_records = load_signup_field_records(&state.db, info.id).await?;
let mut field_by_id: HashMap<i32, &TournamentSignupFieldRecord> = HashMap::new(); let config = build_signup_config(&info, &field_records);
for record in &field_records { let submission = payload.into_inner();
field_by_id.insert(record.id, record);
validate_submission(&config, &submission)?;
let mut entry_values: HashMap<String, String> = 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(); let mut participant_values: Vec<HashMap<String, String>> = Vec::new();
for row in entry_value_rows { for participant in submission.participants {
if let Some(field) = field_by_id.get(&row.signup_field_id) { let mut map = HashMap::new();
if field.scope == "entry" { for field in &config.participant_fields {
entry_map.insert(field.field_key.clone(), Value::String(row.value)); 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<String, TournamentSignupFieldRecord> = 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<i32, Map<String, Value>> = HashMap::new(); for field in &config.entry_fields {
for row in participant_value_rows { if !field.unique {
if let Some(field) = field_by_id.get(&row.signup_field_id) { continue;
if field.scope == "participant" { }
let map = participant_value_map
.entry(row.participant_id) if let Some(value) = entry_values.get(&field.id) {
.or_insert_with(Map::new); if value.is_empty() {
map.insert(field.field_key.clone(), Value::String(row.value)); 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_values_map in &participant_values {
for participant in participant_rows { for field in &config.participant_fields {
let values = participant_value_map if !field.unique {
.remove(&participant.id) continue;
.map(Value::Object) }
.unwrap_or_else(|| Value::Object(Map::new()));
participant_array.push(values); 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 { sqlx::query("DELETE FROM tournament_registration_values WHERE registration_id = $1")
id: registration.id, .bind(registration.id)
created_at: registration.created_at, .execute(&mut *tx)
entry: Value::Object(entry_map), .await?;
participants: Value::Array(participant_array),
};
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 { sqlx::query("DELETE FROM tournament_participants WHERE registration_id = $1")
tournament, .bind(registration.id)
registration: registration_item, .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))
} }

View file

@ -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));
}

View file

@ -9,6 +9,10 @@
isLowerGrade, isLowerGrade,
personIsComplete personIsComplete
} from '$lib/client/person-utils'; } from '$lib/client/person-utils';
import {
personMatchesQuery,
sortPersonsByQuery
} from '$lib/client/person-search';
import EditPersonModal from '$lib/components/edit-person-modal.svelte'; import EditPersonModal from '$lib/components/edit-person-modal.svelte';
let searchQuery = $state(''); let searchQuery = $state('');
@ -158,8 +162,11 @@
} }
function updateVisibleResults() { function updateVisibleResults() {
const filtered = searchResults.filter((person) => !person.checked_in); const query = searchQuery.trim();
visibleResults = filtered; const filtered = searchResults.filter(
(person) => !person.checked_in && (!query || personMatchesQuery(person, query))
);
visibleResults = query ? sortPersonsByQuery(filtered, query) : filtered;
if (searchResults.length === 0) { if (searchResults.length === 0) {
searchInfo = 'Ingen träff på sökningen.'; searchInfo = 'Ingen träff på sökningen.';
@ -179,6 +186,7 @@
stop(); stop();
}; };
}); });
</script> </script>
<div class="space-y-6"> <div class="space-y-6">

View file

@ -5,6 +5,7 @@
import { listenToPersonEvents } from '$lib/client/person-events'; import { listenToPersonEvents } from '$lib/client/person-events';
import { updateCollection } from '$lib/client/person-collection'; import { updateCollection } from '$lib/client/person-collection';
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils'; 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'; import EditPersonModal from '$lib/components/edit-person-modal.svelte';
type StatusFilter = 'all' | 'inside' | 'outside'; type StatusFilter = 'all' | 'inside' | 'outside';
@ -49,22 +50,16 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
if (visitorFilter === 'besoksplats' && !person.visitor) return false; if (visitorFilter === 'besoksplats' && !person.visitor) return false;
if (visitorFilter === 'lanplats' && person.visitor) return false; if (visitorFilter === 'lanplats' && person.visitor) return false;
if (sleepingFilter === 'needs' && !person.sleeping_spot) return false; if (sleepingFilter === 'needs' && !person.sleeping_spot) return false;
if (sleepingFilter === 'not-needed' && person.sleeping_spot) return false; if (sleepingFilter === 'not-needed' && person.sleeping_spot) return false;
if (searchQuery.trim()) { const trimmed = searchQuery.trim();
const query = searchQuery.trim().toLowerCase(); if (trimmed && !personMatchesQuery(person, trimmed)) return false;
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;
}
return true; return true;
} }
function updateVisiblePersons() { function updateVisiblePersons() {
const filtered = allPersons.filter((person) => matchesFilters(person)); 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.' : ''; infoMessage = filtered.length === 0 ? 'Inga personer matchar kriterierna.' : '';
} }

View file

@ -4,6 +4,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToPersonEvents } from '$lib/client/person-events'; import { listenToPersonEvents } from '$lib/client/person-events';
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils'; import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
import { personMatchesQuery, sortPersonsByQuery } from '$lib/client/person-search';
type GradeFilter = 'all' | 'lt4' | 'ge4'; type GradeFilter = 'all' | 'lt4' | 'ge4';
type VisitorFilter = 'all' | 'besoksplats' | 'lanplats'; type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
@ -42,17 +43,9 @@
return false; return false;
} }
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim();
if (query) { if (query && !personMatchesQuery(person, query)) {
const combinedName = `${person.first_name} ${person.last_name}`.toLowerCase(); return false;
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;
}
} }
return true; return true;
@ -176,7 +169,8 @@
function updateVisibleResults() { function updateVisibleResults() {
const filtered = searchResults.filter((person) => matchesFilters(person)); 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()) { if (searchResults.length === 0 && !searchQuery.trim()) {
searchInfo = 'Inga personer hämtades.'; searchInfo = 'Inga personer hämtades.';

View file

@ -5,6 +5,7 @@
import { listenToPersonEvents } from '$lib/client/person-events'; import { listenToPersonEvents } from '$lib/client/person-events';
import { updateCollection } from '$lib/client/person-collection'; import { updateCollection } from '$lib/client/person-collection';
import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils'; import { gradeLabel, guardianLabel, isLowerGrade } from '$lib/client/person-utils';
import { personMatchesQuery, sortPersonsByQuery } from '$lib/client/person-search';
type StatusFilter = 'all' | 'inside' | 'outside'; type StatusFilter = 'all' | 'inside' | 'outside';
@ -33,21 +34,15 @@
if (!person.checked_in) return false; if (!person.checked_in) return false;
if (statusFilter === 'inside' && !person.inside) return false; if (statusFilter === 'inside' && !person.inside) return false;
if (statusFilter === 'outside' && person.inside) return false; if (statusFilter === 'outside' && person.inside) return false;
const query = searchQuery.trim().toLowerCase(); const query = searchQuery.trim();
if (query) { if (query && !personMatchesQuery(person, query)) return false;
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;
}
return true; return true;
} }
function applyFilteredList(list: Person[]) { function applyFilteredList(list: Person[]) {
const filtered = list.filter(matchesFilters); const filtered = list.filter(matchesFilters);
persons = filtered; const query = searchQuery.trim();
persons = query ? sortPersonsByQuery(filtered, query) : filtered;
if (persons.length === 0) { if (persons.length === 0) {
infoMessage = 'Inga incheckade personer matchar kriterierna.'; infoMessage = 'Inga incheckade personer matchar kriterierna.';
} else { } else {
@ -57,6 +52,10 @@
function handlePersonUpdate(person: Person) { function handlePersonUpdate(person: Person) {
persons = updateCollection(persons, person, matchesFilters); persons = updateCollection(persons, person, matchesFilters);
const query = searchQuery.trim();
if (query) {
persons = sortPersonsByQuery(persons, query);
}
if (persons.length === 0) { if (persons.length === 0) {
infoMessage = 'Inga incheckade personer matchar kriterierna.'; infoMessage = 'Inga incheckade personer matchar kriterierna.';
} else { } else {

View file

@ -1,25 +1,17 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; 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 }) => { export const load: PageServerLoad = async ({ fetch, params }) => {
const response = await fetch(`/api/tournament/slug/${params.slug}/registrations`); try {
const text = await response.text(); return await fetchRegistrationList(fetch, params.slug);
} catch (err) {
if (!response.ok) { if (err instanceof ApiRequestError) {
let message = 'Kunde inte hämta anmälningar.'; const message = parseApiMessage(err.body, loadErrorFallback);
try { throw error(err.status, message);
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
} }
throw error(response.status, message); throw err;
} }
const data = JSON.parse(text) as TournamentRegistrationList;
return {
tournament: data.tournament,
registrations: data.registrations
};
}; };

View file

@ -1,13 +1,107 @@
<script lang="ts"> <script lang="ts">
import type { TournamentRegistrationList, TournamentSignupField } from '$lib/types'; import { onMount } from 'svelte';
import { listenToTournamentEvents } from '$lib/client/tournament-events';
import type {
TournamentRegistrationList,
TournamentRegistrationItem,
TournamentSignupField
} from '$lib/types';
const props = $props<{ data: TournamentRegistrationList }>(); const props = $props<{ data: TournamentRegistrationList }>();
const data = props.data; let tournament = $state(props.data.tournament);
const tournament = data.tournament; let registrations = $state(props.data.registrations ?? []);
const registrations = data.registrations; let refreshing = $state(false);
let loadError = $state('');
const entryFields = tournament.signup_config.entry_fields ?? []; type RegistrationResponse = {
const participantFields = tournament.signup_config.participant_fields ?? []; tournament: TournamentRegistrationList['tournament'];
registrations: TournamentRegistrationItem[];
};
type RegistrationPayload = RegistrationResponse & { warning?: string };
function applyResult(result: RegistrationResponse) {
tournament = result.tournament;
registrations = result.registrations ?? [];
}
async function callEndpoint(
endpoint: string,
payload: unknown,
defaultMessage: string
): Promise<RegistrationPayload> {
if (!tournament.slug) {
throw new Error('Turneringen saknar slug.');
}
const init: RequestInit = { method: 'POST' };
if (payload !== undefined) {
init.headers = { 'content-type': 'application/json' };
init.body = JSON.stringify(payload);
}
const response = await fetch(
`/admin/tournament/${tournament.slug}/registrations/${endpoint}`,
init
);
const text = await response.text();
const trimmed = text.trim();
if (!response.ok) {
let message = defaultMessage;
try {
const parsed = JSON.parse(text);
if (parsed?.message) {
message = parsed.message;
} else if (trimmed) {
message = trimmed;
}
} catch {
if (trimmed) {
message = trimmed;
}
}
throw new Error(message);
}
if (!trimmed) {
throw new Error('Tomt svar från servern.');
}
try {
const data = JSON.parse(text) as RegistrationPayload;
if (
!data ||
typeof data !== 'object' ||
!('tournament' in data) ||
!('registrations' in data)
) {
throw new Error();
}
return data;
} catch {
throw new Error('Kunde inte tolka svaret från servern.');
}
}
let editingId = $state<number | null>(null);
let editEntry = $state<Record<string, string>>({});
let editParticipants = $state<Record<string, string>[]>([]);
let editError = $state('');
let editSaving = $state(false);
let deletingId = $state<number | null>(null);
let deleteError = $state('');
const entryFields = $derived(() => tournament.signup_config.entry_fields ?? []);
const participantFields = $derived(() => tournament.signup_config.participant_fields ?? []);
const signupConfig = $derived(() => tournament.signup_config);
const minParticipants = $derived(() =>
signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.min) : 0
);
const maxParticipants = $derived(() =>
signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.max) : 0
);
function formatDateTime(value: string | null) { function formatDateTime(value: string | null) {
if (!value) return null; if (!value) return null;
@ -27,6 +121,243 @@
const value = map[field.id]; const value = map[field.id];
return value ?? ''; return value ?? '';
} }
function blankEntryMap() {
const map: Record<string, string> = {};
for (const field of entryFields()) {
map[field.id] = '';
}
return map;
}
function blankParticipantMap() {
const map: Record<string, string> = {};
for (const field of participantFields()) {
map[field.id] = '';
}
return map;
}
function fieldInputType(fieldType: string) {
switch (fieldType) {
case 'email':
return 'email';
case 'tel':
return 'tel';
default:
return 'text';
}
}
function ensureParticipantBounds(list: Record<string, string>[]) {
if (signupConfig().mode !== 'team') {
return list;
}
let next = [...list];
const min = minParticipants();
const max = Math.max(min, maxParticipants());
if (next.length < min) {
for (let i = next.length; i < min; i += 1) {
next = [...next, blankParticipantMap()];
}
}
if (next.length > max) {
next = next.slice(0, max);
}
return next;
}
function beginEdit(registration: TournamentRegistrationItem) {
editingId = registration.id;
editEntry = {
...blankEntryMap(),
...registration.entry
};
editParticipants = ensureParticipantBounds(
(registration.participants ?? []).map((participant) => ({
...blankParticipantMap(),
...participant
}))
);
editError = '';
}
function cancelEdit() {
editingId = null;
editEntry = {};
editParticipants = [];
editError = '';
editSaving = false;
}
function updateEntryField(fieldId: string, value: string) {
editEntry = { ...editEntry, [fieldId]: value };
}
function updateParticipantField(index: number, fieldId: string, value: string) {
editParticipants = editParticipants.map((participant, idx) =>
idx === index ? { ...participant, [fieldId]: value } : participant
);
}
function addParticipant() {
if (signupConfig().mode !== 'team') return;
if (maxParticipants() > 0 && editParticipants.length >= maxParticipants()) return;
editParticipants = [...editParticipants, blankParticipantMap()];
}
function removeParticipant(index: number) {
if (signupConfig().mode !== 'team') return;
const min = minParticipants();
if (editParticipants.length <= Math.max(1, min)) return;
editParticipants = editParticipants.filter((_, idx) => idx !== index);
}
function participantLabel(index: number) {
if (signupConfig().mode === 'team') {
return `Spelare ${index + 1}`;
}
return 'Spelare';
}
async function saveEdit(registration: TournamentRegistrationItem) {
if (!tournament.slug || editingId !== registration.id) return;
editSaving = true;
editError = '';
const payload = {
entry: { ...editEntry },
participants: editParticipants.map((participant) => ({ ...participant }))
};
try {
const result = await callEndpoint(
'update',
{
registration_id: registration.id,
entry: payload.entry,
participants: payload.participants
},
'Kunde inte uppdatera anmälan.'
);
applyResult(result);
loadError = result.warning ?? '';
cancelEdit();
} catch (err) {
console.error('Failed to update registration', err);
editError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
} finally {
editSaving = false;
}
}
function exportRegistrations() {
if (typeof window === 'undefined') return;
const lines: string[] = [];
const timestamp = formatDateTime(new Date().toISOString()) ?? new Date().toISOString();
lines.push(`${tournament.title} ${registrations.length} anmälningar`);
lines.push(`Genererad ${timestamp}`);
lines.push('');
if (registrations.length === 0) {
lines.push('Inga anmälningar.');
} else {
for (const registration of registrations) {
lines.push(`Anmälan #${registration.id}`);
const created = formatDateTime(registration.created_at) ?? registration.created_at;
lines.push(`Skapad: ${created}`);
if (entryFields().length > 0) {
lines.push('Lag / deltagare:');
for (const field of entryFields()) {
lines.push(` ${field.label}: ${fieldValue(registration.entry, field) || '—'}`);
}
}
if (participantFields().length === 0) {
if (registration.participants.length > 0) {
lines.push(`Spelare: ${registration.participants.length}`);
}
} else if (registration.participants.length === 0) {
lines.push('Spelare: inga angivna');
} else {
lines.push('Spelare:');
registration.participants.forEach((participant, index) => {
lines.push(` Spelare ${index + 1}`);
for (const field of participantFields()) {
lines.push(` ${field.label}: ${fieldValue(participant, field) || '—'}`);
}
});
}
lines.push('');
}
}
const blob = new Blob([lines.join('\n')], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${tournament.slug?.trim() || 'turnering'}-registreringar.txt`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async function removeRegistration(registration: TournamentRegistrationItem) {
if (!tournament.slug) return;
if (!window.confirm('Ta bort denna anmälan? Det går inte att ångra.')) return;
deletingId = registration.id;
deleteError = '';
try {
const result = await callEndpoint(
'delete',
{ registration_id: registration.id },
'Kunde inte ta bort anmälan.'
);
applyResult(result);
loadError = result.warning ?? '';
if (editingId === registration.id) {
cancelEdit();
}
} catch (err) {
console.error('Failed to delete registration', err);
deleteError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
} finally {
deletingId = null;
}
}
async function refreshRegistrations() {
if (!tournament.slug) return;
refreshing = true;
loadError = '';
try {
const result = await callEndpoint('refresh', undefined, 'Kunde inte hämta anmälningarna.');
applyResult(result);
loadError = result.warning ?? '';
} catch (err) {
console.error('Failed to refresh registrations', err);
loadError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
} finally {
refreshing = false;
}
}
onMount(() => {
const stop = listenToTournamentEvents((updated) => {
if (updated.id === tournament.id && editingId === null) {
void refreshRegistrations();
}
}, (deletedId) => {
if (deletedId === tournament.id) {
registrations = [];
loadError = 'Turneringen har tagits bort.';
}
});
return () => {
stop();
};
});
</script> </script>
<svelte:head> <svelte:head>
@ -73,21 +404,40 @@
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4"> <div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-wide text-slate-500">Format</p> <p class="text-xs uppercase tracking-wide text-slate-500">Format</p>
<p class="mt-1 text-sm text-slate-800"> <p class="mt-1 text-sm text-slate-800">
{tournament.signup_config.mode === 'team' {signupConfig().mode === 'team'
? `Lag (${tournament.signup_config.team_size.min}${tournament.signup_config.team_size.max} spelare)` ? `Lag (${signupConfig().team_size.min}${signupConfig().team_size.max} spelare)`
: 'Individuell'} : 'Individuell'}
</p> </p>
</div> </div>
</div> </div>
</section> </section>
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> <header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-lg font-semibold text-slate-900">Registreringar</h2> <h2 class="text-lg font-semibold text-slate-900">Registreringar</h2>
{#if registrations.length > 0} <div class="flex flex-wrap items-center gap-2">
<p class="text-sm text-slate-500">Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}</p> {#if registrations.length > 0}
{/if} <p class="text-sm text-slate-500">Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}</p>
</header> {/if}
<button
type="button"
onclick={exportRegistrations}
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-600 transition hover:bg-slate-100"
>
Exportera .txt
</button>
</div>
</header>
{#if loadError}
<p class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600">{loadError}</p>
{/if}
{#if deleteError}
<p class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600">{deleteError}</p>
{/if}
{#if refreshing && !loadError}
<p class="mt-2 text-xs text-slate-500">Uppdaterar…</p>
{/if}
{#if registrations.length === 0} {#if registrations.length === 0}
<p class="mt-4 rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500"> <p class="mt-4 rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
@ -97,52 +447,188 @@
<div class="mt-6 space-y-5"> <div class="mt-6 space-y-5">
{#each registrations as registration} {#each registrations as registration}
<article class="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4"> <article class="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
<header class="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between"> <header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<h3 class="text-base font-semibold text-slate-900">Anmälan #{registration.id}</h3> <div>
<p class="text-xs uppercase tracking-wide text-slate-500"> <h3 class="text-base font-semibold text-slate-900">Anmälan #{registration.id}</h3>
Skapad {formatDateTime(registration.created_at) ?? registration.created_at} <p class="text-xs uppercase tracking-wide text-slate-500">
</p> Skapad {formatDateTime(registration.created_at) ?? registration.created_at}
</p>
</div>
<div class="flex gap-2">
{#if editingId === registration.id}
<button
type="button"
onclick={cancelEdit}
class="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-600 transition hover:bg-slate-100"
>
Avbryt
</button>
{:else}
<button
type="button"
onclick={() => beginEdit(registration)}
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50"
>
Redigera
</button>
{/if}
<button
type="button"
onclick={() => removeRegistration(registration)}
disabled={deletingId === registration.id || editSaving}
class="rounded-full border border-red-300 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{deletingId === registration.id ? 'Tar bort…' : 'Ta bort'}
</button>
</div>
</header> </header>
{#if entryFields.length > 0} {#if editingId === registration.id}
<div class="grid gap-3 md:grid-cols-2"> <form class="space-y-4" onsubmit={(event) => {
{#each entryFields as field} event.preventDefault();
<div class="rounded-md border border-slate-200 bg-white p-3"> saveEdit(registration);
<p class="text-xs uppercase tracking-wide text-slate-500">{field.label}</p> }}>
<p class="mt-1 text-sm text-slate-800">{fieldValue(registration.entry, field) || '—'}</p> {#if entryFields().length > 0}
<div class="space-y-3">
<h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Lag / deltagare</h4>
<div class="grid gap-3 md:grid-cols-2">
{#each entryFields() as field}
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>{field.label}</span>
<input
type={fieldInputType(field.field_type)}
value={editEntry[field.id] ?? ''}
oninput={(event) => updateEntryField(field.id, (event.currentTarget as HTMLInputElement).value)}
placeholder={field.placeholder ?? ''}
disabled={editSaving}
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
/>
</label>
{/each}
</div>
</div> </div>
{/each}
</div>
{/if}
<section class="space-y-2">
<h4 class="text-sm font-semibold text-slate-800">Spelare</h4>
{#if participantFields.length === 0}
{#if registration.participants.length === 0}
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
{:else}
<p class="text-xs text-slate-500">Antal spelare: {registration.participants.length}</p>
{/if} {/if}
{:else if registration.participants.length === 0}
<p class="text-xs text-slate-500">Inga spelare angivna.</p> <section class="space-y-3">
{:else} <div class="flex items-center justify-between">
<div class="space-y-3"> <h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Spelare</h4>
{#each registration.participants as participant, index} {#if signupConfig().mode === 'team'}
<button
type="button"
onclick={addParticipant}
disabled={editSaving || (maxParticipants() > 0 && editParticipants.length >= maxParticipants())}
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
>
Lägg till spelare
</button>
{/if}
</div>
{#if participantFields().length === 0}
<p class="text-xs text-slate-500">Inga spelarspecifika fält att redigera.</p>
{:else if editParticipants.length === 0}
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
{:else}
<div class="space-y-3">
{#each editParticipants as participant, index}
<div class="space-y-3 rounded-md border border-slate-200 bg-white p-3">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">{participantLabel(index)}</p>
{#if signupConfig().mode === 'team'}
<button
type="button"
onclick={() => removeParticipant(index)}
disabled={editSaving || editParticipants.length <= Math.max(1, minParticipants())}
class="rounded-full border border-red-200 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
>
Ta bort
</button>
{/if}
</div>
<div class="grid gap-3 md:grid-cols-2">
{#each participantFields() as field}
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>{field.label}</span>
<input
type={fieldInputType(field.field_type)}
value={participant[field.id] ?? ''}
oninput={(event) => updateParticipantField(index, field.id, (event.currentTarget as HTMLInputElement).value)}
placeholder={field.placeholder ?? ''}
disabled={editSaving}
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
/>
</label>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</section>
{#if editError}
<p class="rounded-md border border-red-200 bg-red-500/10 px-3 py-2 text-sm text-red-600">{editError}</p>
{/if}
<div class="flex flex-wrap items-center gap-3 pt-2">
<button
type="submit"
disabled={editSaving}
class="inline-flex items-center justify-center rounded-full bg-indigo-500 px-5 py-2 text-sm font-semibold text-white transition hover:bg-indigo-600 disabled:cursor-not-allowed disabled:opacity-60"
>
{editSaving ? 'Sparar…' : 'Spara ändringar'}
</button>
<button
type="button"
onclick={cancelEdit}
disabled={editSaving}
class="inline-flex items-center justify-center rounded-full border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
>
Avbryt
</button>
</div>
</form>
{:else}
{#if entryFields().length > 0}
<div class="grid gap-3 md:grid-cols-2">
{#each entryFields() as field}
<div class="rounded-md border border-slate-200 bg-white p-3"> <div class="rounded-md border border-slate-200 bg-white p-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Spelare {index + 1}</p> <p class="text-xs uppercase tracking-wide text-slate-500">{field.label}</p>
<ul class="mt-2 space-y-1 text-sm text-slate-800"> <p class="mt-1 text-sm text-slate-800">{fieldValue(registration.entry, field) || '—'}</p>
{#each participantFields as field}
<li>
<span class="font-medium text-slate-600">{field.label}:</span>
<span class="ml-1">{fieldValue(participant, field) || '—'}</span>
</li>
{/each}
</ul>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
</section>
<section class="space-y-2">
<h4 class="text-sm font-semibold text-slate-800">Spelare</h4>
{#if participantFields().length === 0}
{#if registration.participants.length === 0}
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
{:else}
<p class="text-xs text-slate-500">Antal spelare: {registration.participants.length}</p>
{/if}
{:else if registration.participants.length === 0}
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
{:else}
<div class="space-y-3">
{#each registration.participants as participant, index}
<div class="rounded-md border border-slate-200 bg-white p-3">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Spelare {index + 1}</p>
<ul class="mt-2 space-y-1 text-sm text-slate-800">
{#each participantFields() as field}
<li>
<span class="font-medium text-slate-600">{field.label}:</span>
<span class="ml-1">{fieldValue(participant, field) || '—'}</span>
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</section>
{/if}
</article> </article>
{/each} {/each}
</div> </div>

View file

@ -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 });
}
};

View file

@ -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<Response>;
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<string, string> {
if (!value || typeof value !== 'object') return {};
const record: Record<string, string> = {};
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
if (typeof entry === 'string') {
record[key] = entry;
}
}
return record;
}
export function normalizeParticipants(value: unknown): Record<string, string>[] {
if (!Array.isArray(value)) return [];
return value.map((item) => normalizeRecord(item));
}

View file

@ -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 });
}
};

View file

@ -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 });
}
};

View file

@ -1,26 +1,36 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { listenToTournamentEvents } from '$lib/client/tournament-events';
import type { TournamentInfo } from '$lib/types'; import type { TournamentInfo } from '$lib/types';
const props = $props<{ data: { tournaments: TournamentInfo[] } }>(); const props = $props<{ data: { tournaments: TournamentInfo[] } }>();
const tournaments = props.data.tournaments ?? []; let tournamentList = $state(props.data.tournaments ?? []);
function pickFeatured(list: TournamentInfo[]) { function sortTournaments(list: TournamentInfo[]): TournamentInfo[] {
if (list.length === 0) return null; return [...list].sort((a, b) => {
const now = Date.now(); const timeA = a.start_at ? new Date(a.start_at).getTime() : Number.POSITIVE_INFINITY;
const withDate = list const timeB = b.start_at ? new Date(b.start_at).getTime() : Number.POSITIVE_INFINITY;
.filter((item) => item.start_at) if (!Number.isNaN(timeA) && !Number.isNaN(timeB) && timeA !== timeB) {
.map((item) => ({ item, time: new Date(item.start_at as string).getTime() })) return timeA - timeB;
.filter(({ time }) => !Number.isNaN(time));
if (withDate.length > 0) {
const upcoming = withDate
.filter(({ time }) => time >= now)
.sort((a, b) => a.time - b.time);
if (upcoming.length > 0) {
return upcoming[0].item;
} }
return withDate.sort((a, b) => a.time - b.time)[0].item; return a.title.localeCompare(b.title, 'sv');
});
}
const tournaments = $derived(() => sortTournaments(tournamentList));
function upsertTournament(tournament: TournamentInfo) {
const index = tournamentList.findIndex((item) => item.id === tournament.id);
if (index >= 0) {
tournamentList = tournamentList.map((item, idx) => (idx === index ? tournament : item));
return;
} }
return list[0]; tournamentList = [...tournamentList, tournament];
}
function removeTournament(id: number) {
if (!tournamentList.some((item) => item.id === id)) return;
tournamentList = tournamentList.filter((item) => item.id !== id);
} }
function formatDate(value: string | null) { function formatDate(value: string | null) {
@ -37,105 +47,108 @@
}); });
} }
const featuredTournament = pickFeatured(tournaments); function registrationSummary(tournament: TournamentInfo) {
const otherTournaments = tournaments.filter( const teams = tournament.total_registrations ?? 0;
(item: TournamentInfo) => item.id !== (featuredTournament?.id ?? -1) const participants = tournament.total_participants ?? 0;
); if (tournament.signup_config.mode === 'team') {
if (teams === 0) {
return 'Inga lag anmälda ännu';
}
return `${teams} ${teams === 1 ? 'lag' : 'lag'} · ${participants} ${participants === 1 ? 'spelare' : 'spelare'}`;
}
const count = Math.max(participants, teams);
if (count === 0) {
return 'Inga spelare anmälda ännu';
}
return `${count} ${count === 1 ? 'spelare' : 'spelare'}`;
}
onMount(() => {
const stop = listenToTournamentEvents(upsertTournament, removeTournament);
return () => {
stop();
};
});
</script> </script>
<svelte:head> <svelte:head>
<title>LAN Tournament</title> <title>Turneringar VBytes LAN</title>
</svelte:head> </svelte:head>
<div class="min-h-screen bg-slate-950 text-slate-100"> <div class="min-h-screen bg-slate-950 text-slate-100">
<div class="mx-auto flex min-h-screen max-w-5xl flex-col items-center justify-center gap-10 px-4 text-center"> <div class="mx-auto max-w-6xl space-y-12 px-4 py-16 sm:py-20">
<div class="space-y-4"> <header class="space-y-3 text-center">
<p class="text-sm uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p> <p class="text-xs uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p>
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">{featuredTournament?.game ?? 'Turnering'}</p> <h1 class="text-4xl font-bold sm:text-5xl">Turneringar</h1>
<h1 class="text-4xl font-bold sm:text-5xl">{featuredTournament?.title ?? 'Turnering & Community'}</h1> <p class="mx-auto max-w-2xl text-base text-slate-300">
{#if featuredTournament?.tagline} Samla laget, följ brackets i realtid och håll koll på allt som händer under turneringarna.
<p class="mx-auto max-w-2xl text-lg text-slate-300">{featuredTournament.tagline}</p> </p>
{:else} </header>
<p class="mx-auto max-w-2xl text-lg text-slate-300">
Samla laget, följ brackets i realtid och håll koll på allt som händer under turneringen.
</p>
{/if}
</div>
{#if featuredTournament} {#if tournaments().length > 0}
<div class="w-full max-w-3xl space-y-4 rounded-2xl bg-slate-900/70 p-6 text-left shadow-lg"> <div class="flex flex-wrap justify-center gap-8">
{#if featuredTournament.start_at} {#each tournaments() as tournament}
<p class="text-sm font-semibold text-indigo-200">Start: {formatDate(featuredTournament.start_at) ?? featuredTournament.start_at}</p> <article class="group w-full max-w-md flex flex-col rounded-3xl border border-slate-800 bg-gradient-to-br from-slate-900/80 via-slate-900/70 to-slate-900/50 p-8 shadow-xl transition duration-200 hover:-translate-y-1 hover:border-indigo-400/70 hover:shadow-indigo-500/25">
{/if} <div class="flex items-center justify-between gap-3 text-xs uppercase tracking-wide">
{#if featuredTournament.location} <span class="font-semibold text-indigo-200">{tournament.game}</span>
<p class="text-sm text-slate-200">Plats: {featuredTournament.location}</p> <span class="rounded-full border border-slate-700 px-3 py-1 text-[0.7rem] font-semibold text-slate-300">
{/if} {registrationSummary(tournament)}
{#if featuredTournament.description} </span>
<p class="whitespace-pre-line text-sm text-slate-200">{featuredTournament.description}</p> </div>
{/if} <h2 class="mt-4 text-3xl font-semibold text-slate-50">{tournament.title}</h2>
<div class="flex flex-wrap gap-3"> {#if tournament.tagline}
{#if featuredTournament.slug} <p class="mt-3 text-base text-slate-300">{tournament.tagline}</p>
<a {:else if tournament.description}
href={`/tournament/${featuredTournament.slug}`} <p class="mt-3 text-base text-slate-400">{tournament.description}</p>
target="_blank" {/if}
rel="noreferrer" <dl class="mt-6 space-y-3 text-sm text-slate-300">
class="rounded-full bg-indigo-500 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-600"
>
Anmäl laget
</a>
{/if}
{#if featuredTournament.contact}
<span class="rounded-full border border-slate-600 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-200">
Kontakt: {featuredTournament.contact}
</span>
{/if}
</div>
</div>
{/if}
{#if otherTournaments.length > 0}
<div class="w-full max-w-4xl space-y-3 text-left">
<h2 class="text-base font-semibold text-slate-300">Fler event</h2>
<div class="grid gap-3 md:grid-cols-2">
{#each otherTournaments as tournament}
<div class="space-y-2 rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<h3 class="text-lg font-semibold text-slate-100">{tournament.title}</h3>
{#if tournament.start_at} {#if tournament.start_at}
<p class="text-xs uppercase tracking-wide text-indigo-200">{formatDate(tournament.start_at) ?? tournament.start_at}</p> <div class="flex items-center gap-3 text-[0.95rem]">
<span class="text-indigo-200">Start:</span>
<span>{formatDate(tournament.start_at) ?? tournament.start_at}</span>
</div>
{/if} {/if}
{#if tournament.tagline} {#if tournament.location}
<p class="text-sm text-slate-300">{tournament.tagline}</p> <div class="flex items-center gap-3 text-[0.95rem]">
{:else if tournament.description} <span class="text-indigo-200">Plats:</span>
<p class="text-sm text-slate-400">{tournament.description}</p> <span>{tournament.location}</span>
</div>
{/if} {/if}
<div class="flex flex-wrap gap-2"> {#if tournament.contact}
<div class="flex items-center gap-3 text-[0.95rem]">
<span class="text-indigo-200">Kontakt:</span>
<span>{tournament.contact}</span>
</div>
{/if}
</dl>
<div class="mt-auto pt-8">
{#if tournament.slug} {#if tournament.slug}
<a <a
href={`/tournament/${tournament.slug}`} href={`/tournament/${tournament.slug}`}
target="_blank" class="inline-flex items-center justify-center rounded-full bg-indigo-500 px-5 py-2 text-sm font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-600"
rel="noreferrer"
class="rounded-full border border-indigo-400 px-3 py-1 text-xs font-semibold text-indigo-200"
> >
Anmälan Visa turnering
</a> </a>
{:else}
<span class="text-xs text-slate-500">Ingen publik sida tillgänglig.</span>
{/if} {/if}
{#if tournament.contact}
<span class="rounded-full border border-slate-600 px-3 py-1 text-xs font-semibold text-slate-300">
Kontakt: {tournament.contact}
</span>
{/if}
</div>
</div> </div>
{/each} </article>
</div> {/each}
</div> </div>
{:else}
<p class="rounded-2xl border border-dashed border-slate-700 bg-slate-900/40 px-6 py-12 text-center text-sm text-slate-400">
Inga turneringar är publicerade ännu. Kom tillbaka senare!
</p>
{/if} {/if}
<a <div class="text-center">
href="/admin" <a
class="rounded-full bg-indigo-500 px-6 py-3 text-sm font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-600" href="/admin"
> class="inline-flex items-center justify-center rounded-full bg-indigo-500 px-6 py-3 text-sm font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-600"
Till admin >
</a> Till admin
</a>
</div>
</div> </div>
</div> </div>

View file

@ -467,11 +467,11 @@
<div class="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4"> <div class="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-semibold text-slate-200">{participantDisplayName(index)}</span> <span class="text-sm font-semibold text-slate-200">{participantDisplayName(index)}</span>
{#if signupConfig.mode === 'team'} {#if signupConfig.mode === 'team' && canRemoveParticipant()}
<button <button
type="button" type="button"
onclick={() => removeParticipant(index)} onclick={() => removeParticipant(index)}
disabled={!canRemoveParticipant() || signup.submitting} disabled={signup.submitting}
class="rounded-full border border-red-300 px-3 py-1 text-xs font-semibold text-red-200 transition hover:border-red-400 hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-full border border-red-300 px-3 py-1 text-xs font-semibold text-red-200 transition hover:border-red-400 hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-60"
> >
Ta bort Ta bort

View file

@ -1,4 +1,3 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend'; import { proxyRequest } from '$lib/server/backend';
@ -15,6 +14,12 @@ export const GET: RequestHandler = async (event) => {
if (contentType) headers.set('content-type', contentType); if (contentType) headers.set('content-type', contentType);
for (const cookie of setCookies) headers.append('set-cookie', cookie); for (const cookie of setCookies) headers.append('set-cookie', cookie);
if (response.status >= 400) {
console.error(
`GET /tournament/slug/${event.params.slug}/registrations failed with ${response.status}: ${text}`
);
}
if (!response.ok) { if (!response.ok) {
let message = 'Kunde inte hämta anmälningar.'; let message = 'Kunde inte hämta anmälningar.';
try { try {
@ -23,7 +28,11 @@ export const GET: RequestHandler = async (event) => {
} catch { } catch {
if (text) message = text; if (text) message = text;
} }
throw error(response.status, message); headers.set('content-type', 'application/json');
return new Response(JSON.stringify({ message }), {
status: response.status,
headers
});
} }
return new Response(text, { status: response.status, headers }); return new Response(text, { status: response.status, headers });

View file

@ -1,4 +1,3 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend'; import { proxyRequest } from '$lib/server/backend';
@ -21,6 +20,11 @@ export const GET: RequestHandler = async (event) => {
const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' }); const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' });
const text = await response.text(); const text = await response.text();
const headers = buildHeaders(response, setCookies); const headers = buildHeaders(response, setCookies);
if (response.status >= 400) {
console.error(
`GET /tournament/slug/${event.params.slug}/registrations/${event.params.registration} failed with ${response.status}: ${text}`
);
}
if (!response.ok) { if (!response.ok) {
let message = 'Kunde inte hämta anmälan.'; let message = 'Kunde inte hämta anmälan.';
@ -30,8 +34,78 @@ export const GET: RequestHandler = async (event) => {
} catch { } catch {
if (text) message = text; if (text) message = text;
} }
throw error(response.status, message); const errorHeaders = new Headers(headers);
errorHeaders.set('content-type', 'application/json');
return new Response(JSON.stringify({ message }), {
status: response.status,
headers: errorHeaders
});
} }
return new Response(text, { status: response.status, headers }); return new Response(text, { status: response.status, headers });
}; };
export const PUT: RequestHandler = async (event) => {
const body = await event.request.text();
const path = `/tournament/slug/${event.params.slug}/registrations/${event.params.registration}`;
const { response, setCookies } = await proxyRequest(event, path, {
method: 'PUT',
body
});
const text = await response.text();
const headers = buildHeaders(response, setCookies);
if (response.status >= 400) {
console.error(
`PUT /tournament/slug/${event.params.slug}/registrations/${event.params.registration} failed with ${response.status}: ${text}`
);
}
if (!response.ok) {
let message = 'Kunde inte uppdatera anmälan.';
try {
const parsed = JSON.parse(text);
message = parsed.message ?? message;
} catch {
if (text) message = text;
}
const errorHeaders = new Headers(headers);
errorHeaders.set('content-type', 'application/json');
return new Response(JSON.stringify({ message }), {
status: response.status,
headers: errorHeaders
});
}
return new Response(text, { status: response.status, headers });
};
export const DELETE: RequestHandler = async (event) => {
const path = `/tournament/slug/${event.params.slug}/registrations/${event.params.registration}`;
const { response, setCookies } = await proxyRequest(event, path, { method: 'DELETE' });
const text = await response.text();
const headers = buildHeaders(response, setCookies);
if (response.status >= 400) {
console.error(
`DELETE /tournament/slug/${event.params.slug}/registrations/${event.params.registration} failed with ${response.status}: ${text}`
);
}
if (!response.ok) {
let message = 'Kunde inte ta bort anmälan.';
try {
const parsed = JSON.parse(text);
message = parsed.message ?? message;
} catch {
if (text) message = text;
}
const errorHeaders = new Headers(headers);
errorHeaders.set('content-type', 'application/json');
return new Response(JSON.stringify({ message }), {
status: response.status,
headers: errorHeaders
});
}
return new Response(text || '', { status: response.status, headers });
};