Mergin Turnament project with guest handling project

This commit is contained in:
Sebastian 2025-09-24 15:57:30 +02:00
parent 7c2ca0ccef
commit 89c6a5a340
50 changed files with 5686 additions and 897 deletions

View file

@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS tournament_info (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
game TEXT NOT NULL,
slug TEXT NOT NULL,
tagline TEXT,
start_at TIMESTAMPTZ,
location TEXT,
description TEXT,
contact TEXT,
signup_mode TEXT NOT NULL DEFAULT 'solo',
team_size_min INTEGER NOT NULL DEFAULT 1,
team_size_max INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS tournament_info_slug_idx ON tournament_info(slug);

View file

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS tournament_sections (
id SERIAL PRIMARY KEY,
tournament_id INTEGER NOT NULL REFERENCES tournament_info(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
body TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tournament_sections_tournament
ON tournament_sections (tournament_id, position, id);

View file

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS tournament_signup_fields (
id SERIAL PRIMARY KEY,
tournament_id INTEGER NOT NULL REFERENCES tournament_info(id) ON DELETE CASCADE,
field_key TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('entry', 'participant')),
label TEXT NOT NULL,
field_type TEXT NOT NULL,
required BOOLEAN NOT NULL DEFAULT FALSE,
placeholder TEXT,
position INTEGER NOT NULL DEFAULT 0,
UNIQUE (tournament_id, field_key)
);
CREATE INDEX IF NOT EXISTS idx_tournament_signup_fields_tournament
ON tournament_signup_fields (tournament_id, scope, position, id);

View file

@ -0,0 +1,32 @@
CREATE TABLE IF NOT EXISTS tournament_registrations (
id SERIAL PRIMARY KEY,
tournament_id INTEGER NOT NULL REFERENCES tournament_info(id) ON DELETE CASCADE,
entry_label TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS tournament_registration_values (
registration_id INTEGER NOT NULL REFERENCES tournament_registrations(id) ON DELETE CASCADE,
signup_field_id INTEGER NOT NULL REFERENCES tournament_signup_fields(id) ON DELETE CASCADE,
value TEXT NOT NULL,
PRIMARY KEY (registration_id, signup_field_id)
);
CREATE TABLE IF NOT EXISTS tournament_participants (
id SERIAL PRIMARY KEY,
registration_id INTEGER NOT NULL REFERENCES tournament_registrations(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS tournament_participant_values (
participant_id INTEGER NOT NULL REFERENCES tournament_participants(id) ON DELETE CASCADE,
signup_field_id INTEGER NOT NULL REFERENCES tournament_signup_fields(id) ON DELETE CASCADE,
value TEXT NOT NULL,
PRIMARY KEY (participant_id, signup_field_id)
);
CREATE INDEX IF NOT EXISTS idx_tournament_registrations_tournament
ON tournament_registrations (tournament_id, created_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_tournament_participants_registration
ON tournament_participants (registration_id, position, id);

View file

@ -0,0 +1,2 @@
ALTER TABLE tournament_signup_fields
ADD COLUMN unique_field BOOLEAN NOT NULL DEFAULT FALSE;

View file

@ -2,23 +2,21 @@ mod auth;
mod config; mod config;
mod error; mod error;
mod models; mod models;
mod routes;
mod seed; mod seed;
use auth::{generate_token, AuthUser}; use auth::{generate_token, AuthUser};
use config::AppConfig; use config::AppConfig;
use error::ApiError; use error::ApiError;
use models::{ use models::{AppEvent, LoginRequest, LoginResponse, User};
LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse,
PersonsResponse, UpdatePersonRequest, User,
};
use rocket::http::{Cookie, CookieJar, SameSite, Status}; use rocket::http::{Cookie, CookieJar, SameSite, Status};
use rocket::response::stream::{Event, EventStream}; use rocket::response::stream::{Event, EventStream};
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::time::Duration; use rocket::time::Duration;
use rocket::tokio::sync::broadcast::{self, error::RecvError}; use rocket::tokio::sync::broadcast::{self, error::RecvError};
use rocket::{get, post, put, routes, State}; use rocket::{get, post, routes, State};
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use sqlx::{PgPool, QueryBuilder}; use sqlx::PgPool;
pub struct AppState { pub struct AppState {
pub db: PgPool, pub db: PgPool,
@ -28,7 +26,7 @@ pub struct AppState {
pub cookie_name: String, pub cookie_name: String,
pub cookie_secure: bool, pub cookie_secure: bool,
pub cookie_same_site: SameSite, pub cookie_same_site: SameSite,
pub event_sender: broadcast::Sender<PersonActionResponse>, pub event_sender: broadcast::Sender<AppEvent>,
} }
#[rocket::main] #[rocket::main]
@ -67,19 +65,8 @@ async fn main() -> Result<(), rocket::Error> {
let rocket = rocket::build() let rocket = rocket::build()
.manage(state) .manage(state)
.mount("/", routes![healthz, login, logout, events]) .mount("/", routes![healthz, login, logout, events])
.mount( .mount("/persons", routes::persons::routes())
"/persons", .mount("/tournament", routes::tournaments::routes());
routes![
search_persons,
list_checked_in,
checkin_person,
checkout_person,
mark_inside,
mark_outside,
create_person,
update_person
],
);
rocket.launch().await?; rocket.launch().await?;
Ok(()) Ok(())
@ -170,674 +157,3 @@ fn events(_user: AuthUser, state: &State<AppState>) -> EventStream![Event + '_]
} }
} }
} }
#[get("/search?<q>")]
async fn search_persons(
_user: AuthUser,
state: &State<AppState>,
q: &str,
) -> Result<Json<PersonsResponse>, ApiError> {
let query = q.trim();
if query.is_empty() {
return Err(ApiError::bad_request("Söktext krävs."));
}
let like_pattern = format!("%{}%", query);
let id_value = query.parse::<i32>().ok();
let persons = if let Some(id) = id_value {
sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE first_name ILIKE $1
OR last_name ILIKE $1
OR parent_name ILIKE $1
OR parent_phone_number ILIKE $1
OR (first_name || ' ' || last_name) ILIKE $1
OR id = $2
ORDER BY last_name, first_name
"#,
)
.bind(&like_pattern)
.bind(id)
.fetch_all(&state.db)
.await?
} else {
sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE first_name ILIKE $1
OR last_name ILIKE $1
OR parent_name ILIKE $1
OR parent_phone_number ILIKE $1
OR (first_name || ' ' || last_name) ILIKE $1
ORDER BY last_name, first_name
"#,
)
.bind(&like_pattern)
.fetch_all(&state.db)
.await?
};
let persons = persons.into_iter().map(PersonResponse::from).collect();
Ok(Json(PersonsResponse { persons }))
}
#[get("/checked-in?<q>&<status>&<checked>")]
async fn list_checked_in(
_user: AuthUser,
state: &State<AppState>,
q: Option<&str>,
status: Option<&str>,
checked: Option<&str>,
) -> Result<Json<PersonsResponse>, ApiError> {
let inside_filter = match status.map(|s| s.trim().to_lowercase()) {
Some(value) if value.is_empty() || value == "all" => None,
Some(value) if value == "inside" => Some(true),
Some(value) if value == "outside" => Some(false),
Some(_) => return Err(ApiError::bad_request("Ogiltigt statusvärde.")),
None => None,
};
let checked_filter = match checked.map(|s| s.trim().to_lowercase()) {
Some(value) if value.is_empty() || value == "all" => None,
Some(value) if value == "true" || value == "checked" || value == "in" => Some(true),
Some(value) if value == "false" || value == "unchecked" || value == "out" => Some(false),
Some(_) => return Err(ApiError::bad_request("Ogiltigt filter för incheckning.")),
None => None,
};
let search_term = q
.map(|raw| raw.trim())
.filter(|trimmed| !trimmed.is_empty())
.map(|trimmed| trimmed.to_string());
let like_pattern = search_term.as_ref().map(|s| format!("%{}%", s));
let id_value = search_term.as_ref().and_then(|s| s.parse::<i32>().ok());
let mut qb = QueryBuilder::<sqlx::Postgres>::new(
"SELECT id, first_name, last_name, grade, parent_name, parent_phone_number, checked_in, inside, visitor, sleeping_spot FROM persons",
);
let mut first_condition = true;
let mut append_condition = |qb: &mut QueryBuilder<sqlx::Postgres>| {
if first_condition {
qb.push(" WHERE ");
first_condition = false;
} else {
qb.push(" AND ");
}
};
if let Some(checked) = checked_filter {
append_condition(&mut qb);
qb.push("checked_in = ").push_bind(checked);
}
if let Some(inside) = inside_filter {
append_condition(&mut qb);
qb.push("inside = ").push_bind(inside);
}
if let Some(like) = like_pattern.as_ref() {
append_condition(&mut qb);
qb.push("(");
qb.push("first_name ILIKE ").push_bind(like);
qb.push(" OR last_name ILIKE ").push_bind(like);
qb.push(" OR parent_name ILIKE ").push_bind(like);
qb.push(" OR parent_phone_number ILIKE ").push_bind(like);
qb.push(" OR (first_name || ' ' || last_name) ILIKE ")
.push_bind(like);
if let Some(id) = id_value {
qb.push(" OR id = ").push_bind(id);
}
qb.push(")");
}
if let Some(id) = id_value {
qb.push(" ORDER BY CASE WHEN id = ")
.push_bind(id)
.push(" THEN 0 ELSE 1 END, id, last_name, first_name");
} else {
qb.push(" ORDER BY id, last_name, first_name");
}
let persons = qb.build_query_as::<Person>().fetch_all(&state.db).await?;
let persons = persons.into_iter().map(PersonResponse::from).collect();
Ok(Json(PersonsResponse { persons }))
}
#[post("/<id>/checkin")]
async fn checkin_person(
_user: AuthUser,
state: &State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
update_checked_in(state, id, true).await
}
#[post("/<id>/checkout")]
async fn checkout_person(
_user: AuthUser,
state: &State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
update_checked_in(state, id, false).await
}
#[post("/<id>/inside")]
async fn mark_inside(
_user: AuthUser,
state: &State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
update_inside(state, id, true).await
}
#[post("/<id>/outside")]
async fn mark_outside(
_user: AuthUser,
state: &State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
update_inside(state, id, false).await
}
#[post("/", data = "<payload>")]
async fn create_person(
_user: AuthUser,
state: &State<AppState>,
payload: Json<NewPersonRequest>,
) -> Result<Json<PersonActionResponse>, ApiError> {
let NewPersonRequest {
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
id,
checked_in,
inside,
visitor,
sleeping_spot,
} = payload.into_inner();
let first_name = first_name.trim().to_string();
if first_name.is_empty() {
return Err(ApiError::bad_request("Förnamn får inte vara tomt."));
}
let last_name = last_name.trim().to_string();
if last_name.is_empty() {
return Err(ApiError::bad_request("Efternamn får inte vara tomt."));
}
if grade.map(|value| value < 0).unwrap_or(false) {
return Err(ApiError::bad_request("Klass måste vara noll eller högre."));
}
let parent_name = normalize_optional_string(&parent_name);
let parent_phone_number = normalize_optional_string(&parent_phone_number);
let checked_in = checked_in.unwrap_or(false);
let inside = inside.unwrap_or(false);
let visitor = visitor.unwrap_or(false);
let sleeping_spot = sleeping_spot.unwrap_or(false);
if (checked_in || inside) && !fields_are_complete(grade, &parent_name, &parent_phone_number) {
return Err(ApiError::bad_request(
"Kontaktperson, telefon och klass krävs innan incheckning.",
));
}
let person = match id {
Some(id) => sqlx::query_as::<_, Person>(
r#"
INSERT INTO persons (
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.bind(&first_name)
.bind(&last_name)
.bind(grade)
.bind(parent_name.as_deref())
.bind(parent_phone_number.as_deref())
.bind(checked_in)
.bind(inside)
.bind(visitor)
.bind(sleeping_spot)
.fetch_one(&state.db)
.await
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
None => sqlx::query_as::<_, Person>(
r#"
INSERT INTO persons (
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(&first_name)
.bind(&last_name)
.bind(grade)
.bind(parent_name.as_deref())
.bind(parent_phone_number.as_deref())
.bind(checked_in)
.bind(inside)
.bind(visitor)
.bind(sleeping_spot)
.fetch_one(&state.db)
.await
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
};
let response = PersonActionResponse {
person: person.into(),
};
broadcast_person_update(state, &response);
Ok(Json(response))
}
#[put("/<id>", data = "<payload>")]
async fn update_person(
_user: AuthUser,
state: &State<AppState>,
id: i32,
payload: Json<UpdatePersonRequest>,
) -> Result<Json<PersonActionResponse>, ApiError> {
let UpdatePersonRequest {
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot,
} = payload.into_inner();
let first_name = first_name.trim().to_string();
if first_name.is_empty() {
return Err(ApiError::bad_request("Förnamn får inte vara tomt."));
}
let last_name = last_name.trim().to_string();
if last_name.is_empty() {
return Err(ApiError::bad_request("Efternamn får inte vara tomt."));
}
if grade.map(|value| value < 0).unwrap_or(false) {
return Err(ApiError::bad_request("Klass måste vara noll eller högre."));
}
let parent_name_present = parent_name.is_some();
let parent_phone_present = parent_phone_number.is_some();
let grade_present = grade.is_some();
let parent_name = normalize_optional_string(&parent_name);
let parent_phone_number = normalize_optional_string(&parent_phone_number);
let (final_checked_in, final_inside) = match (checked_in, inside) {
(Some(false), _) => (Some(false), Some(false)),
(Some(true), Some(value)) => (Some(true), Some(value)),
(Some(true), None) => (Some(true), Some(true)),
(None, Some(value)) => (None, Some(value)),
(None, None) => (None, None),
};
let existing = sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?;
let existing = match existing {
Some(person) => person,
None => return Err(ApiError::not_found("Personen hittades inte.")),
};
let parent_name = if parent_name_present {
parent_name
} else {
existing.parent_name.clone()
};
let parent_phone_number = if parent_phone_present {
parent_phone_number
} else {
existing.parent_phone_number.clone()
};
let grade = if grade_present { grade } else { existing.grade };
let desired_checked_in = final_checked_in.unwrap_or(existing.checked_in);
let desired_inside = final_inside.unwrap_or(existing.inside);
if (desired_checked_in || desired_inside)
&& !fields_are_complete(grade, &parent_name, &parent_phone_number)
{
return Err(ApiError::bad_request(
"Kontaktperson, telefon och klass krävs innan incheckning.",
));
}
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET first_name = $2,
last_name = $3,
grade = $4,
parent_name = $5,
parent_phone_number = $6,
checked_in = COALESCE($7, checked_in),
inside = COALESCE($8, inside),
visitor = $9,
sleeping_spot = $10
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.bind(&first_name)
.bind(&last_name)
.bind(grade)
.bind(parent_name.as_deref())
.bind(parent_phone_number.as_deref())
.bind(final_checked_in)
.bind(final_inside)
.bind(visitor)
.bind(sleeping_spot)
.fetch_optional(&state.db)
.await?;
match person {
Some(person) => {
let response = PersonActionResponse {
person: person.into(),
};
broadcast_person_update(state, &response);
Ok(Json(response))
}
None => Err(ApiError::not_found("Personen hittades inte.")),
}
}
async fn update_checked_in(
state: &State<AppState>,
id: i32,
value: bool,
) -> Result<Json<PersonActionResponse>, ApiError> {
let existing = sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE id = $1
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?;
let existing = match existing {
Some(person) => person,
None => return Err(ApiError::not_found("Personen hittades inte.")),
};
if value && !person_is_complete(&existing) {
return Err(ApiError::bad_request(
"Kontaktperson, telefon och klass krävs innan incheckning.",
));
}
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET checked_in = $2,
inside = $2
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.bind(value)
.fetch_optional(&state.db)
.await?;
match person {
Some(person) => {
let response = PersonActionResponse {
person: person.into(),
};
broadcast_person_update(state, &response);
Ok(Json(response))
}
None => Err(ApiError::not_found("Personen hittades inte.")),
}
}
async fn update_inside(
state: &State<AppState>,
id: i32,
value: bool,
) -> Result<Json<PersonActionResponse>, ApiError> {
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET inside = $2
WHERE id = $1
AND checked_in = TRUE
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.bind(value)
.fetch_optional(&state.db)
.await?;
match person {
Some(person) => {
let response = PersonActionResponse {
person: person.into(),
};
broadcast_person_update(state, &response);
Ok(Json(response))
}
None => {
let checked =
sqlx::query_scalar::<_, bool>("SELECT checked_in FROM persons WHERE id = $1")
.bind(id)
.fetch_optional(&state.db)
.await?;
match checked {
Some(_) => Err(ApiError::bad_request(
"Personen måste vara incheckad för att uppdatera inne/ute.",
)),
None => Err(ApiError::not_found("Personen hittades inte.")),
}
}
}
}
fn map_db_error(err: sqlx::Error, context: &str) -> ApiError {
if let sqlx::Error::Database(db_err) = &err {
if let Some(code) = db_err.code() {
if code == "23505" {
return ApiError::bad_request(
"Krock i databasen kontrollera id eller kontaktuppgifter.",
);
}
}
eprintln!("Database error ({}): {:?}", context, db_err);
return ApiError::internal("Databasfel.");
}
eprintln!("Database error ({}): {:?}", context, err);
ApiError::internal("Databasfel.")
}
fn broadcast_person_update(state: &State<AppState>, response: &PersonActionResponse) {
let _ = state.event_sender.send(response.clone());
}
fn normalize_optional_string(value: &Option<String>) -> Option<String> {
value.as_ref().and_then(|text| {
let trimmed = text.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn fields_are_complete(
grade: Option<i32>,
parent_name: &Option<String>,
parent_phone_number: &Option<String>,
) -> bool {
grade.is_some()
&& parent_name
.as_ref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
&& phone_is_valid(parent_phone_number)
}
fn person_is_complete(person: &Person) -> bool {
fields_are_complete(
person.grade,
&person.parent_name,
&person.parent_phone_number,
)
}
fn phone_is_valid(phone: &Option<String>) -> bool {
phone
.as_ref()
.map(|value| {
let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect();
digits.len() == 10
})
.unwrap_or(false)
}

14
api/src/models/event.rs Normal file
View file

@ -0,0 +1,14 @@
use rocket::serde::Serialize;
use super::{person::PersonResponse, tournament::TournamentInfoData};
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "type", crate = "rocket::serde")]
pub enum AppEvent {
#[serde(rename = "person_updated")]
PersonUpdated { person: PersonResponse },
#[serde(rename = "tournament_upserted")]
TournamentUpserted { tournament: TournamentInfoData },
#[serde(rename = "tournament_deleted")]
TournamentDeleted { tournament_id: i32 },
}

7
api/src/models/mod.rs Normal file
View file

@ -0,0 +1,7 @@
pub mod event;
pub mod person;
pub mod tournament;
pub use event::*;
pub use person::*;
pub use tournament::*;

View file

@ -9,7 +9,7 @@ pub struct User {
pub created_at: chrono::DateTime<chrono::Utc>, pub created_at: chrono::DateTime<chrono::Utc>,
} }
#[derive(Debug, FromRow)] #[derive(Debug, FromRow, Clone)]
pub struct Person { pub struct Person {
pub id: i32, pub id: i32,
pub first_name: String, pub first_name: String,

View file

@ -0,0 +1,366 @@
use rocket::serde::{Deserialize, Serialize};
use serde_json::Value;
use sqlx::FromRow;
use std::collections::HashMap;
use std::collections::HashSet;
#[derive(Debug, FromRow, Clone)]
pub struct TournamentInfo {
pub id: i32,
pub title: String,
pub game: String,
pub slug: String,
pub tagline: Option<String>,
pub start_at: Option<chrono::DateTime<chrono::Utc>>,
pub location: Option<String>,
pub description: Option<String>,
pub contact: Option<String>,
pub signup_mode: String,
pub team_size_min: i32,
pub team_size_max: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, FromRow, Clone)]
pub struct TournamentSectionRecord {
pub id: i32,
pub tournament_id: i32,
pub position: i32,
pub title: String,
pub body: String,
}
#[derive(Debug, FromRow, Clone)]
pub struct TournamentSignupFieldRecord {
pub id: i32,
pub tournament_id: i32,
pub field_key: String,
pub scope: String,
pub label: String,
pub field_type: String,
pub required: bool,
pub placeholder: Option<String>,
pub position: i32,
#[sqlx(rename = "unique_field")]
pub unique: bool,
}
#[derive(Debug, FromRow, Clone)]
pub struct TournamentRegistrationRow {
pub id: i32,
pub tournament_id: i32,
pub entry_label: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, FromRow, Clone)]
pub struct TournamentRegistrationValueRow {
pub registration_id: i32,
pub signup_field_id: i32,
pub value: String,
}
#[derive(Debug, FromRow, Clone)]
pub struct TournamentParticipantRow {
pub id: i32,
pub registration_id: i32,
pub position: i32,
}
#[derive(Debug, FromRow, Clone)]
pub struct TournamentParticipantValueRow {
pub participant_id: i32,
pub signup_field_id: i32,
pub value: String,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case", crate = "rocket::serde")]
pub enum TournamentFieldType {
Text,
Email,
Tel,
Discord,
}
impl Default for TournamentFieldType {
fn default() -> Self {
TournamentFieldType::Text
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentSignupField {
pub id: String,
pub label: String,
#[serde(default)]
pub field_type: TournamentFieldType,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub placeholder: Option<String>,
#[serde(default)]
pub unique: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentTeamSize {
pub min: i32,
pub max: i32,
}
impl Default for TournamentTeamSize {
fn default() -> Self {
TournamentTeamSize { min: 1, max: 1 }
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentSignupConfig {
#[serde(default = "TournamentSignupConfig::default_mode")]
pub mode: String,
#[serde(default = "TournamentSignupConfig::default_team_size")]
pub team_size: TournamentTeamSize,
#[serde(default)]
pub entry_fields: Vec<TournamentSignupField>,
#[serde(default)]
pub participant_fields: Vec<TournamentSignupField>,
}
impl Default for TournamentSignupConfig {
fn default() -> Self {
TournamentSignupConfig {
mode: Self::default_mode(),
team_size: Self::default_team_size(),
entry_fields: Vec::new(),
participant_fields: Vec::new(),
}
}
}
impl TournamentSignupConfig {
fn default_mode() -> String {
"solo".to_string()
}
fn default_team_size() -> TournamentTeamSize {
TournamentTeamSize::default()
}
pub fn normalized(mut self) -> Self {
self.mode = match self.mode.as_str() {
"team" => "team".to_string(),
_ => "solo".to_string(),
};
if self.mode == "solo" {
self.team_size.min = 1;
self.team_size.max = 1;
} else {
if self.team_size.min < 1 {
self.team_size.min = 1;
}
if self.team_size.max < self.team_size.min {
self.team_size.max = self.team_size.min;
}
if self.team_size.max > 64 {
self.team_size.max = 64;
}
}
self.entry_fields = normalize_signup_fields(self.entry_fields);
self.participant_fields = normalize_signup_fields(self.participant_fields);
self
}
}
fn normalize_signup_fields(mut fields: Vec<TournamentSignupField>) -> Vec<TournamentSignupField> {
let mut seen = HashSet::new();
for field in fields.iter_mut() {
let base = if field.id.trim().is_empty() {
normalize_field_id(&field.label)
} else {
normalize_field_id(&field.id)
};
let mut candidate = base.clone();
let mut counter = 1;
while seen.contains(&candidate) {
counter += 1;
candidate = format!("{base}-{counter}");
}
seen.insert(candidate.clone());
field.id = candidate;
field.label = field.label.trim().to_string();
if field.label.is_empty() {
field.label = "Fält".to_string();
}
field.placeholder = field
.placeholder
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty());
}
fields
}
fn normalize_field_id(input: &str) -> String {
let mut slug = input
.trim()
.to_lowercase()
.chars()
.map(|ch| match ch {
'a'..='z' | '0'..='9' => ch,
_ => '-',
})
.collect::<String>();
while slug.contains("--") {
slug = slug.replace("--", "-");
}
slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
"field".to_string()
} else {
slug
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentSection {
pub title: String,
pub body: String,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentInfoData {
pub id: i32,
pub title: String,
pub game: String,
pub slug: String,
pub tagline: Option<String>,
pub start_at: Option<chrono::DateTime<chrono::Utc>>,
pub location: Option<String>,
pub description: Option<String>,
pub contact: Option<String>,
pub registration_url: String,
#[serde(default)]
pub sections: Vec<TournamentSection>,
#[serde(default)]
pub signup_config: TournamentSignupConfig,
#[serde(default)]
pub total_registrations: i32,
#[serde(default)]
pub total_participants: i32,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentListResponse {
pub tournaments: Vec<TournamentInfoData>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentItemResponse {
pub tournament: TournamentInfoData,
}
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct CreateTournamentRequest {
pub title: String,
pub game: String,
pub slug: String,
#[serde(default)]
pub tagline: Option<String>,
#[serde(default)]
pub start_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub registration_url: Option<String>,
#[serde(default)]
pub contact: Option<String>,
#[serde(default)]
pub sections: Vec<TournamentSection>,
#[serde(default)]
pub signup_config: TournamentSignupConfig,
}
#[derive(Debug, Deserialize)]
#[serde(crate = "rocket::serde")]
pub struct UpdateTournamentRequest {
pub title: String,
pub game: String,
pub slug: String,
#[serde(default)]
pub tagline: Option<String>,
#[serde(default)]
pub start_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub location: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub registration_url: Option<String>,
#[serde(default)]
pub contact: Option<String>,
#[serde(default)]
pub sections: Vec<TournamentSection>,
#[serde(default)]
pub signup_config: TournamentSignupConfig,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentRegistrationResponse {
pub registration_id: i32,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentSignupSubmission {
#[serde(default)]
pub entry: HashMap<String, String>,
#[serde(default)]
pub participants: Vec<HashMap<String, String>>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentRegistrationItem {
pub id: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub entry: Value,
pub participants: Value,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentRegistrationListResponse {
pub tournament: TournamentInfoData,
pub registrations: Vec<TournamentRegistrationItem>,
}
#[derive(Debug, Serialize, Clone)]
#[serde(crate = "rocket::serde")]
pub struct TournamentRegistrationDetailResponse {
pub tournament: TournamentInfoData,
pub registration: TournamentRegistrationItem,
}

2
api/src/routes/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod persons;
pub mod tournaments;

506
api/src/routes/persons.rs Normal file
View file

@ -0,0 +1,506 @@
use crate::auth::AuthUser;
use crate::error::ApiError;
use crate::models::{
AppEvent, NewPersonRequest, Person, PersonActionResponse, PersonResponse, PersonsResponse,
UpdatePersonRequest,
};
use crate::AppState;
use rocket::serde::json::Json;
use rocket::Route;
use sqlx::QueryBuilder;
pub fn routes() -> Vec<Route> {
rocket::routes![
search_persons,
list_checked_in,
checkin_person,
checkout_person,
mark_inside,
mark_outside,
create_person,
update_person
]
}
#[rocket::get("/search?<q>")]
pub async fn search_persons(
_user: AuthUser,
state: &rocket::State<AppState>,
q: &str,
) -> Result<Json<PersonsResponse>, ApiError> {
let query = q.trim();
if query.is_empty() {
return Err(ApiError::bad_request("Söktext krävs."));
}
let like_pattern = format!("%{}%", query);
let id_value = query.parse::<i32>().ok();
let persons = if let Some(id) = id_value {
sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE id = $1
UNION
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE
CONCAT(first_name, ' ', last_name) ILIKE $2
OR parent_name ILIKE $2
OR parent_phone_number ILIKE $2
LIMIT 50
"#,
)
.bind(id)
.bind(&like_pattern)
.fetch_all(&state.db)
.await?
} else {
sqlx::query_as::<_, Person>(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
WHERE
CONCAT(first_name, ' ', last_name) ILIKE $1
OR parent_name ILIKE $1
OR parent_phone_number ILIKE $1
ORDER BY checked_in DESC, inside DESC, id ASC
LIMIT 50
"#,
)
.bind(&like_pattern)
.fetch_all(&state.db)
.await?
};
let response = PersonsResponse {
persons: persons.into_iter().map(PersonResponse::from).collect(),
};
Ok(Json(response))
}
#[rocket::get("/checked-in?<checked>&<status>&<q>")]
pub async fn list_checked_in(
_user: AuthUser,
state: &rocket::State<AppState>,
checked: Option<bool>,
status: Option<&str>,
q: Option<&str>,
) -> Result<Json<PersonsResponse>, ApiError> {
let mut query_builder = QueryBuilder::new(
r#"
SELECT
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
FROM persons
"#,
);
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());
}
}
if let Some(status) = status {
match status {
"inside" => conditions.push("inside = true".to_string()),
"outside" => conditions.push("inside = false".to_string()),
_ => {}
}
}
if let Some(query) = q.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
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}%')"
));
}
if !conditions.is_empty() {
query_builder.push(" WHERE ");
for (index, condition) in conditions.iter().enumerate() {
if index > 0 {
query_builder.push(" AND ");
}
query_builder.push(condition.as_str());
}
}
query_builder.push(" ORDER BY checked_in DESC, inside DESC, id ASC");
let persons = query_builder
.build_query_as::<Person>()
.fetch_all(&state.db)
.await?;
Ok(Json(PersonsResponse {
persons: persons.into_iter().map(PersonResponse::from).collect(),
}))
}
#[rocket::post("/<id>/checkin")]
pub async fn checkin_person(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET checked_in = true
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::post("/<id>/checkout")]
pub async fn checkout_person(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET checked_in = false, inside = false
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::post("/<id>/inside")]
pub async fn mark_inside(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET inside = true
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::post("/<id>/outside")]
pub async fn mark_outside(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
) -> Result<Json<PersonActionResponse>, ApiError> {
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET inside = false
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::post("/", data = "<payload>")]
pub async fn create_person(
_user: AuthUser,
state: &rocket::State<AppState>,
payload: Json<NewPersonRequest>,
) -> Result<Json<PersonActionResponse>, ApiError> {
let data = payload.into_inner();
if data.first_name.trim().is_empty() {
return Err(ApiError::bad_request("Förnamn krävs."));
}
if data.last_name.trim().is_empty() {
return Err(ApiError::bad_request("Efternamn krävs."));
}
if let Some(ref phone) = data.parent_phone_number {
let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() != 10 {
return Err(ApiError::bad_request(
"Telefonnumret måste innehålla tio siffror.",
));
}
}
let person = sqlx::query_as::<_, Person>(
r#"
INSERT INTO persons (
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot,
id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9,
CASE WHEN $10 IS NULL THEN nextval('persons_id_seq') ELSE $10 END
)
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(data.first_name.trim())
.bind(data.last_name.trim())
.bind(data.grade)
.bind(data.parent_name.map(|v| v.trim().to_string()))
.bind(data.parent_phone_number.map(|v| v.trim().to_string()))
.bind(data.checked_in.unwrap_or(false))
.bind(data.inside.unwrap_or(false))
.bind(data.visitor.unwrap_or(false))
.bind(data.sleeping_spot.unwrap_or(false))
.bind(data.id)
.fetch_one(&state.db)
.await?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}
#[rocket::put("/<id>", data = "<payload>")]
pub async fn update_person(
_user: AuthUser,
state: &rocket::State<AppState>,
id: i32,
payload: Json<UpdatePersonRequest>,
) -> Result<Json<PersonActionResponse>, ApiError> {
let data = payload.into_inner();
if data.first_name.trim().is_empty() {
return Err(ApiError::bad_request("Förnamn krävs."));
}
if data.last_name.trim().is_empty() {
return Err(ApiError::bad_request("Efternamn krävs."));
}
if let Some(ref phone) = data.parent_phone_number {
let digits: String = phone.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() != 10 {
return Err(ApiError::bad_request(
"Telefonnumret måste innehålla tio siffror.",
));
}
}
let person = sqlx::query_as::<_, Person>(
r#"
UPDATE persons
SET
first_name = $2,
last_name = $3,
grade = $4,
parent_name = $5,
parent_phone_number = $6,
checked_in = COALESCE($7, checked_in),
inside = COALESCE($8, inside),
visitor = $9,
sleeping_spot = $10
WHERE id = $1
RETURNING
id,
first_name,
last_name,
grade,
parent_name,
parent_phone_number,
checked_in,
inside,
visitor,
sleeping_spot
"#,
)
.bind(id)
.bind(data.first_name.trim())
.bind(data.last_name.trim())
.bind(data.grade)
.bind(data.parent_name.map(|v| v.trim().to_string()))
.bind(data.parent_phone_number.map(|v| v.trim().to_string()))
.bind(data.checked_in)
.bind(data.inside)
.bind(data.visitor)
.bind(data.sleeping_spot)
.fetch_optional(&state.db)
.await?
.ok_or_else(|| ApiError::not_found("Personen hittades inte."))?;
let response = PersonActionResponse {
person: person.clone().into(),
};
let _ = state.event_sender.send(AppEvent::PersonUpdated {
person: response.person.clone(),
});
Ok(Json(response))
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,41 @@
import type { Person, TournamentInfo } from '$lib/types';
export type AppEvent =
| { type: 'person_updated'; person: Person }
| { type: 'tournament_upserted'; tournament: TournamentInfo }
| { type: 'tournament_deleted'; tournament_id: number };
export function listenToEvents(onEvent: (event: AppEvent) => void) {
let stopped = false;
let source: EventSource | null = null;
function connect() {
if (stopped) return;
source = new EventSource('/api/events');
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as AppEvent;
if (data && typeof data.type === 'string') {
onEvent(data);
}
} catch (err) {
console.error('Failed to parse event stream payload', err);
}
};
source.onerror = () => {
source?.close();
source = null;
if (stopped) return;
setTimeout(connect, 2000);
};
}
connect();
return () => {
stopped = true;
source?.close();
source = null;
};
}

View file

@ -1,40 +1,10 @@
import type { Person } from '$lib/types'; import type { Person } from '$lib/types';
import { listenToEvents } from '$lib/client/event-stream';
export type PersonEvent = {
person: Person;
};
export function listenToPersonEvents(onPerson: (person: Person) => void) { export function listenToPersonEvents(onPerson: (person: Person) => void) {
let stopped = false; return listenToEvents((event) => {
let source: EventSource | null = null; if (event.type === 'person_updated') {
onPerson(event.person);
function connect() { }
if (stopped) return; });
source = new EventSource('/api/events');
source.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as PersonEvent;
if (data.person) {
onPerson(data.person);
}
} catch (err) {
console.error('Failed to parse person event', err);
}
};
source.onerror = () => {
source?.close();
source = null;
if (stopped) return;
setTimeout(connect, 2000);
};
}
connect();
return () => {
stopped = true;
source?.close();
source = null;
};
} }

View file

@ -0,0 +1,21 @@
import type { TournamentInfo } from '$lib/types';
import { listenToEvents } from '$lib/client/event-stream';
export type TournamentEvent =
| { type: 'tournament_upserted'; tournament: TournamentInfo }
| { type: 'tournament_deleted'; tournament_id: number };
export function listenToTournamentEvents(
onUpsert: (tournament: TournamentInfo) => void,
onDelete: (tournamentId: number) => void
) {
return listenToEvents((event) => {
if (event.type === 'tournament_upserted') {
onUpsert(event.tournament);
return;
}
if (event.type === 'tournament_deleted') {
onDelete(event.tournament_id);
}
});
}

View file

@ -10,3 +10,66 @@ export interface Person {
visitor: boolean; visitor: boolean;
sleeping_spot: boolean; sleeping_spot: boolean;
} }
export interface TournamentSection {
title: string;
body: string;
}
export type TournamentFieldType = 'text' | 'email' | 'tel' | 'discord';
export interface TournamentSignupField {
id: string;
label: string;
field_type: TournamentFieldType;
required: boolean;
placeholder?: string | null;
unique: boolean;
}
export interface TournamentTeamSize {
min: number;
max: number;
}
export interface TournamentSignupConfig {
mode: 'solo' | 'team';
team_size: TournamentTeamSize;
entry_fields: TournamentSignupField[];
participant_fields: TournamentSignupField[];
}
export interface TournamentRegistrationItem {
id: number;
created_at: string;
entry: Record<string, string>;
participants: Record<string, string>[];
}
export interface TournamentRegistrationList {
tournament: TournamentInfo;
registrations: TournamentRegistrationItem[];
}
export interface TournamentRegistrationDetail {
tournament: TournamentInfo;
registration: TournamentRegistrationItem;
}
export interface TournamentInfo {
id: number;
title: string;
game: string;
slug: string;
tagline: string | null;
start_at: string | null;
location: string | null;
description: string | null;
registration_url: string;
contact: string | null;
sections: TournamentSection[];
signup_config: TournamentSignupConfig;
updated_at: string;
total_registrations: number;
total_participants: number;
}

View file

@ -0,0 +1,14 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { AUTH_COOKIE_NAME } from '$lib/server/config';
export const load: LayoutServerLoad = async ({ cookies, url }) => {
const isLoggedIn = Boolean(cookies.get(AUTH_COOKIE_NAME));
const isLoginRoute = url.pathname === '/admin/login';
if (!isLoggedIn && !isLoginRoute) {
throw redirect(302, '/admin/login');
}
return { isLoggedIn };
};

View file

@ -0,0 +1,172 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import favicon from '$lib/assets/favicon.svg';
const props = $props();
const children = $derived(props.children);
const data = $derived(props.data);
type NavItem = {
href: string;
label: string;
matchExact?: boolean;
tab?: string;
};
type PanelInfo = {
title: string;
swapHref: string | null;
swapLabel: string;
nav: NavItem[];
};
let ui = $state({
loggedIn: false,
loggingOut: false,
message: ''
});
let panel = $state<PanelInfo>({
title: 'Adminpaneler',
swapHref: null,
swapLabel: '',
nav: []
});
$effect(() => {
ui.loggedIn = Boolean(data?.isLoggedIn);
const path = $page.url.pathname;
if (path.startsWith('/admin/checkin')) {
panel = {
title: 'Gästhantering',
swapHref: '/admin/tournament',
swapLabel: 'Till turneringsadmin',
nav: [
{ href: '/admin/checkin', label: 'Checka in', matchExact: true },
{ href: '/admin/checkin/checkout', label: 'Checka ut' },
{ href: '/admin/checkin/create', label: 'Lägg till' },
{ href: '/admin/checkin/inside-status', label: 'Inne/ute' },
{ href: '/admin/checkin/checked-in', label: 'Översikt' }
]
};
return;
}
if (path.startsWith('/admin/tournament')) {
panel = {
title: 'Turneringsadmin',
swapHref: '/admin/checkin',
swapLabel: 'Till gästhantering',
nav: [
{ href: '/admin/tournament', label: 'Översikt', matchExact: true, tab: 'overview' },
{ href: '/admin/tournament?tab=create', label: 'Skapa ny', tab: 'create' },
{ href: '/admin/tournament?tab=manage', label: 'Hantera', tab: 'manage' }
]
};
return;
}
panel = {
title: 'Adminpaneler',
swapHref: null,
swapLabel: '',
nav: []
};
});
function navClasses(item: NavItem) {
const currentPath = $page.url.pathname;
const currentTab = $page.url.searchParams.get('tab') ?? 'overview';
let isActive: boolean;
if (item.tab) {
isActive = currentTab === item.tab;
} else {
isActive = item.matchExact
? currentPath === item.href
: currentPath === item.href || currentPath.startsWith(`${item.href}/`);
}
return `shrink-0 whitespace-nowrap rounded-full px-3 sm:px-4 py-2 text-sm transition-colors ${
isActive
? 'bg-indigo-600 text-white shadow'
: 'text-slate-600 hover:bg-slate-100'
}`;
}
async function handleLogout() {
if (ui.loggingOut) return;
ui.loggingOut = true;
ui.message = '';
try {
const response = await fetch('/api/logout', { method: 'POST' });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
ui.message = body.message ?? 'Kunde inte logga ut. Försök igen.';
} else {
ui.loggedIn = false;
await goto('/admin/login', { invalidateAll: true });
}
} catch (err) {
console.error('Logout failed', err);
ui.message = 'Ett oväntat fel uppstod vid utloggning.';
} finally {
ui.loggingOut = false;
}
}
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="min-h-screen bg-slate-100 text-slate-900">
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-5xl flex-col gap-5 px-3 py-5 sm:px-4 sm:py-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<a
href="/"
class="text-sm font-medium tracking-wide text-slate-500 transition-colors hover:text-indigo-600 uppercase"
>
VBytes
</a>
<h1 class="text-2xl font-semibold text-slate-900">{panel.title}</h1>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
{#if panel.swapHref}
<a
href={panel.swapHref}
class="rounded-full border border-indigo-200 px-3 py-2 text-sm font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 sm:px-4"
>
{panel.swapLabel}
</a>
{/if}
{#if ui.loggedIn}
<button
onclick={handleLogout}
disabled={ui.loggingOut}
class="rounded-full border border-slate-300 px-3 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60 sm:px-4"
>
{ui.loggingOut ? 'Loggar ut…' : 'Logga ut'}
</button>
{/if}
</div>
</div>
{#if panel.nav.length > 0 && ui.loggedIn}
<nav class="flex flex-nowrap items-center gap-2 overflow-x-auto pb-1 pl-1 pr-3 sm:flex-wrap sm:gap-3 sm:pl-0 sm:pr-0">
{#each panel.nav as item}
<a href={item.href} class={navClasses(item)}>{item.label}</a>
{/each}
</nav>
{/if}
{#if ui.message}
<p class="w-full rounded-md bg-red-50 px-4 py-2 text-sm text-red-600">{ui.message}</p>
{/if}
</div>
</header>
<main class="mx-auto max-w-5xl px-3 py-10 sm:px-4">
{@render children?.()}
</main>
</div>

View file

@ -0,0 +1,32 @@
<svelte:head>
<title>Adminpanel</title>
</svelte:head>
<div class="space-y-8">
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h1 class="text-2xl font-semibold text-slate-900">Välj adminområde</h1>
<p class="mt-2 text-sm text-slate-600">
Här kan du välja vilket verktyg du vill administrera efter inloggning.
</p>
</section>
<div class="grid gap-4 sm:grid-cols-2">
<a
href="/admin/checkin"
class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm transition hover:border-indigo-300 hover:shadow"
>
<h2 class="text-lg font-semibold text-slate-900">Check-in</h2>
<p class="mt-2 text-sm text-slate-600">
Hantera deltagare på plats, in- och utcheckning samt status.
</p>
</a>
<a
href="/admin/tournament"
class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm transition hover:border-indigo-300 hover:shadow"
>
<h2 class="text-lg font-semibold text-slate-900">Turnering</h2>
<p class="mt-2 text-sm text-slate-600">
Planera brackets, uppdatera matcher och överblicka resultat.
</p>
</a>
</div>
</div>

View file

@ -0,0 +1,6 @@
<script lang="ts">
const props = $props();
const children = $derived(props.children);
</script>
{@render children?.()}

View file

@ -0,0 +1,5 @@
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {};
};

View file

@ -28,7 +28,7 @@
async function apiFetch(url: string, init?: RequestInit) { async function apiFetch(url: string, init?: RequestInit) {
const response = await fetch(url, init); const response = await fetch(url, init);
if (response.status === 401) { if (response.status === 401) {
await goto('/login'); await goto('/admin/login');
return null; return null;
} }
return response; return response;

View file

@ -33,7 +33,7 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
async function apiFetch(url: string) { async function apiFetch(url: string) {
const response = await fetch(url); const response = await fetch(url);
if (response.status === 401) { if (response.status === 401) {
await goto('/login'); await goto('/admin/login');
return null; return null;
} }
return response; return response;
@ -301,17 +301,17 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
</div> </div>
</form> </form>
{#if errorMessage} {#if errorMessage}
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p> <p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
{/if} {/if}
{#if infoMessage && !errorMessage} {#if infoMessage && !errorMessage}
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p> <p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
{/if} {/if}
</section>
<section class="space-y-4"> {#if persons.length > 0}
{#each persons as person} <div class="mt-6 space-y-4">
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm"> {#each persons as person}
<article class="rounded-lg border border-slate-200 bg-white p-5 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">
<div> <div>
<h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3> <h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
@ -361,7 +361,9 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
</div> </div>
</article> </article>
{/each} {/each}
</section> </div>
{/if}
</section>
</div> </div>
<EditPersonModal <EditPersonModal

View file

@ -61,7 +61,7 @@
async function apiFetch(url: string, init?: RequestInit) { async function apiFetch(url: string, init?: RequestInit) {
const response = await fetch(url, init); const response = await fetch(url, init);
if (response.status === 401) { if (response.status === 401) {
await goto('/login'); await goto('/admin/login');
return null; return null;
} }
return response; return response;

View file

@ -23,7 +23,7 @@
async function apiFetch(url: string, init?: RequestInit) { async function apiFetch(url: string, init?: RequestInit) {
const response = await fetch(url, init); const response = await fetch(url, init);
if (response.status === 401) { if (response.status === 401) {
await goto('/login'); await goto('/admin/login');
return null; return null;
} }
return response; return response;
@ -222,22 +222,22 @@
</div> </div>
</form> </form>
{#if errorMessage} {#if errorMessage}
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p> <p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
{/if} {/if}
{#if infoMessage && !errorMessage} {#if infoMessage && !errorMessage}
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p> <p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
{/if} {/if}
{#if actionMessage && !errorMessage} {#if actionMessage && !errorMessage}
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700"> <p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
{actionMessage} {actionMessage}
</p> </p>
{/if} {/if}
</section>
<section class="space-y-4"> {#if persons.length > 0}
{#each persons as person} <div class="mt-6 space-y-4">
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm"> {#each persons as person}
<article class="rounded-lg border border-slate-200 bg-white p-5 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">
<div> <div>
<h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3> <h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
@ -290,7 +290,9 @@
{person.inside ? 'Markera ute' : 'Markera inne'} {person.inside ? 'Markera ute' : 'Markera inne'}
</button> </button>
</div> </div>
</article> </article>
{/each} {/each}
</section> </div>
{/if}
</section>
</div> </div>

View file

@ -29,7 +29,7 @@
return; return;
} }
await goto('/', { invalidateAll: true }); await goto('/admin', { invalidateAll: true });
} catch (err) { } catch (err) {
console.error('Login failed', err); console.error('Login failed', err);
errorMessage = 'Ett oväntat fel inträffade. Försök igen.'; errorMessage = 'Ett oväntat fel inträffade. Försök igen.';

View file

@ -0,0 +1,6 @@
<script lang="ts">
const props = $props();
const children = $derived(props.children);
</script>
{@render children?.()}

View file

@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import type { TournamentInfo } from '$lib/types';
export const load: PageServerLoad = async ({ fetch }) => {
try {
const response = await fetch('/api/tournament');
if (!response.ok) {
return { tournaments: [] as TournamentInfo[] };
}
const data = await response.json();
return {
tournaments: (data?.tournaments ?? []) as TournamentInfo[]
};
} catch (err) {
console.error('Failed to load tournaments', err);
return { tournaments: [] as TournamentInfo[] };
}
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,25 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { TournamentRegistrationList } from '$lib/types';
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;
}
throw error(response.status, message);
}
const data = JSON.parse(text) as TournamentRegistrationList;
return {
tournament: data.tournament,
registrations: data.registrations
};
};

View file

@ -0,0 +1,152 @@
<script lang="ts">
import type { TournamentRegistrationList, TournamentSignupField } from '$lib/types';
const props = $props<{ data: TournamentRegistrationList }>();
const data = props.data;
const tournament = data.tournament;
const registrations = data.registrations;
const entryFields = tournament.signup_config.entry_fields ?? [];
const participantFields = tournament.signup_config.participant_fields ?? [];
function formatDateTime(value: string | null) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString('sv-SE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
function fieldValue(map: Record<string, string>, field: TournamentSignupField) {
const value = map[field.id];
return value ?? '';
}
</script>
<svelte:head>
<title>Anmälningar {tournament.title} | Admin</title>
</svelte:head>
<div class="min-h-screen bg-slate-100 text-slate-900">
<div class="mx-auto flex min-h-screen max-w-5xl flex-col gap-8 px-3 py-8 sm:px-4">
<header class="flex flex-col gap-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<p class="text-sm uppercase tracking-[0.4em] text-indigo-500">Admin</p>
<h1 class="text-2xl font-semibold text-slate-900">{tournament.title}</h1>
<p class="text-sm text-slate-600">{tournament.game}</p>
</div>
<div class="flex flex-wrap gap-2">
<a
href="/admin/tournament"
class="rounded-full border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100"
>
Till översikt
</a>
<a
href="/admin/tournament"
class="rounded-full border border-indigo-300 px-4 py-2 text-sm font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50"
>
Hantera information
</a>
</div>
</header>
<section class="space-y-3 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h2 class="text-lg font-semibold text-slate-900">Sammanfattning</h2>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-wide text-slate-500">Anmälningar</p>
<p class="mt-1 text-2xl font-semibold text-slate-900">{registrations.length}</p>
</div>
{#if tournament.start_at}
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<p class="text-xs uppercase tracking-wide text-slate-500">Start</p>
<p class="mt-1 text-sm text-slate-800">{formatDateTime(tournament.start_at) ?? tournament.start_at}</p>
</div>
{/if}
<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="mt-1 text-sm text-slate-800">
{tournament.signup_config.mode === 'team'
? `Lag (${tournament.signup_config.team_size.min}${tournament.signup_config.team_size.max} spelare)`
: 'Individuell'}
</p>
</div>
</div>
</section>
<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">
<h2 class="text-lg font-semibold text-slate-900">Registreringar</h2>
{#if registrations.length > 0}
<p class="text-sm text-slate-500">Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}</p>
{/if}
</header>
{#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">
Inga anmälningar ännu. Dela länken till /tournament/{tournament.slug} för att samla in registreringar.
</p>
{:else}
<div class="mt-6 space-y-5">
{#each registrations as registration}
<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">
<h3 class="text-base font-semibold text-slate-900">Anmälan #{registration.id}</h3>
<p class="text-xs uppercase tracking-wide text-slate-500">
Skapad {formatDateTime(registration.created_at) ?? registration.created_at}
</p>
</header>
{#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">
<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>
</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}
{: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>
</article>
{/each}
</div>
{/if}
</section>
</div>
</div>

View file

@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import type { TournamentInfo } from '$lib/types';
export const load: PageServerLoad = async ({ fetch }) => {
try {
const response = await fetch('/api/tournament');
if (!response.ok) {
return { tournaments: [] as TournamentInfo[] };
}
const data = await response.json();
return { tournaments: (data?.tournaments ?? []) as TournamentInfo[] };
} catch (err) {
console.error('Failed to load tournaments', err);
return { tournaments: [] as TournamentInfo[] };
}
};

View file

@ -0,0 +1,141 @@
<script lang="ts">
import type { TournamentInfo } from '$lib/types';
const props = $props<{ data: { tournaments: TournamentInfo[] } }>();
const tournaments = props.data.tournaments ?? [];
function pickFeatured(list: TournamentInfo[]) {
if (list.length === 0) return null;
const now = Date.now();
const withDate = list
.filter((item) => item.start_at)
.map((item) => ({ item, time: new Date(item.start_at as string).getTime() }))
.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 list[0];
}
function formatDate(value: string | null) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString('sv-SE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
const featuredTournament = pickFeatured(tournaments);
const otherTournaments = tournaments.filter(
(item: TournamentInfo) => item.id !== (featuredTournament?.id ?? -1)
);
</script>
<svelte:head>
<title>LAN Tournament</title>
</svelte:head>
<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="space-y-4">
<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">{featuredTournament?.game ?? 'Turnering'}</p>
<h1 class="text-4xl font-bold sm:text-5xl">{featuredTournament?.title ?? 'Turnering & Community'}</h1>
{#if featuredTournament?.tagline}
<p class="mx-auto max-w-2xl text-lg text-slate-300">{featuredTournament.tagline}</p>
{:else}
<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}
<div class="w-full max-w-3xl space-y-4 rounded-2xl bg-slate-900/70 p-6 text-left shadow-lg">
{#if featuredTournament.start_at}
<p class="text-sm font-semibold text-indigo-200">Start: {formatDate(featuredTournament.start_at) ?? featuredTournament.start_at}</p>
{/if}
{#if featuredTournament.location}
<p class="text-sm text-slate-200">Plats: {featuredTournament.location}</p>
{/if}
{#if featuredTournament.description}
<p class="whitespace-pre-line text-sm text-slate-200">{featuredTournament.description}</p>
{/if}
<div class="flex flex-wrap gap-3">
{#if featuredTournament.slug}
<a
href={`/tournament/${featuredTournament.slug}`}
target="_blank"
rel="noreferrer"
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}
<p class="text-xs uppercase tracking-wide text-indigo-200">{formatDate(tournament.start_at) ?? tournament.start_at}</p>
{/if}
{#if tournament.tagline}
<p class="text-sm text-slate-300">{tournament.tagline}</p>
{:else if tournament.description}
<p class="text-sm text-slate-400">{tournament.description}</p>
{/if}
<div class="flex flex-wrap gap-2">
{#if tournament.slug}
<a
href={`/tournament/${tournament.slug}`}
target="_blank"
rel="noreferrer"
class="rounded-full border border-indigo-400 px-3 py-1 text-xs font-semibold text-indigo-200"
>
Anmälan
</a>
{/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>
{/each}
</div>
</div>
{/if}
<a
href="/admin"
class="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>
</div>
</div>

View file

@ -0,0 +1,31 @@
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { TournamentInfo } from '$lib/types';
export const load: PageServerLoad = async ({ fetch, params }) => {
try {
const response = await fetch(`/api/tournament/slug/${params.slug}`);
const text = await response.text();
if (!response.ok) {
let message = 'Kunde inte hämta turneringen.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw error(response.status, message);
}
const body = JSON.parse(text) as { tournament: TournamentInfo };
if (!body?.tournament) {
throw error(404, 'Turneringen hittades inte.');
}
return { tournament: body.tournament };
} catch (err) {
if (err instanceof Response) throw err;
console.error('Failed to load tournament detail', err);
throw error(500, 'Kunde inte hämta turneringen.');
}
};

View file

@ -0,0 +1,534 @@
<script lang="ts">
import type {
TournamentFieldType,
TournamentInfo,
TournamentSignupConfig,
TournamentSignupField
} from '$lib/types';
const props = $props<{ data: { tournament: TournamentInfo } }>();
const tournament = props.data.tournament;
function pickMode(value: string | null | undefined) {
return value === 'team' ? 'team' : 'solo';
}
function sanitizeField(field: TournamentSignupField): TournamentSignupField {
return {
id: field.id,
label: field.label,
field_type: field.field_type ?? 'text',
required: Boolean(field.required),
placeholder: field.placeholder ?? null,
unique: Boolean(field.unique)
};
}
function normalizeSignupConfig(config: TournamentSignupConfig | null | undefined): TournamentSignupConfig {
if (!config) {
return {
mode: 'solo',
team_size: { min: 1, max: 1 },
entry_fields: [],
participant_fields: []
};
}
const mode = pickMode(config.mode);
let min = Math.max(1, Math.floor(config.team_size?.min ?? 1));
let max = Math.max(1, Math.floor(config.team_size?.max ?? 1));
if (max < min) max = min;
if (mode === 'solo') {
min = 1;
max = 1;
}
return {
mode,
team_size: { min, max },
entry_fields: (config.entry_fields ?? []).map(sanitizeField),
participant_fields: (config.participant_fields ?? []).map(sanitizeField)
};
}
type FieldValueMap = Record<string, string>;
const signupConfig = normalizeSignupConfig(tournament.signup_config);
function formatDateTime(value: string | null) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString('sv-SE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
const formattedStart = formatDateTime(tournament.start_at);
function createFieldMap(fields: TournamentSignupField[]): FieldValueMap {
const map: FieldValueMap = {};
for (const field of fields) {
map[field.id] = '';
}
return map;
}
function participantDisplayName(index: number) {
return signupConfig.mode === 'team' ? `Spelare ${index + 1}` : 'Spelare';
}
function fieldInputType(field: TournamentFieldType) {
switch (field) {
case 'email':
return 'email';
case 'tel':
return 'tel';
default:
return 'text';
}
}
const minParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.min : 1;
const maxParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.max : 1;
let signup = $state({
entry: createFieldMap(signupConfig.entry_fields),
participants: [] as FieldValueMap[],
submitting: false,
success: '',
error: '',
successRegistrationId: null as number | null,
submittedEntry: {} as Record<string, string>,
submittedParticipants: [] as Record<string, string>[],
showSuccessModal: false
});
function initializeParticipants() {
const initialCount = Math.max(1, signupConfig.mode === 'team' ? signupConfig.team_size.min : 1);
const list: FieldValueMap[] = [];
for (let i = 0; i < initialCount; i += 1) {
list.push(createFieldMap(signupConfig.participant_fields));
}
signup.participants = list;
}
initializeParticipants();
function canAddParticipant() {
return signupConfig.mode === 'team' && signup.participants.length < maxParticipants;
}
function canRemoveParticipant() {
return signupConfig.mode === 'team' && signup.participants.length > minParticipants;
}
function addParticipant() {
if (!canAddParticipant()) return;
signup.participants = [...signup.participants, createFieldMap(signupConfig.participant_fields)];
}
function removeParticipant(index: number) {
if (!canRemoveParticipant()) return;
signup.participants = signup.participants.filter((_, idx) => idx !== index);
}
function resetSignupForm() {
signup.entry = createFieldMap(signupConfig.entry_fields);
signup.participants = [];
initializeParticipants();
signup.success = '';
signup.error = '';
}
type SignupPayload = {
entry: Record<string, string>;
participants: Record<string, string>[];
};
function buildSignupPayload(): SignupPayload {
const entry: Record<string, string> = {};
for (const field of signupConfig.entry_fields) {
entry[field.id] = (signup.entry[field.id] ?? '').trim();
}
const participants = signup.participants.map((participant) => {
const map: Record<string, string> = {};
for (const field of signupConfig.participant_fields) {
map[field.id] = (participant[field.id] ?? '').trim();
}
return map;
});
return { entry, participants };
}
async function handleSignupSubmit(event: SubmitEvent) {
event.preventDefault();
signup.error = '';
signup.success = '';
if (signup.participants.length === 0) {
signup.error = 'Lägg till minst en spelare.';
return;
}
if (signupConfig.mode === 'team') {
if (signup.participants.length < minParticipants) {
signup.error = `Lägg till minst ${minParticipants} spelare.`;
return;
}
}
signup.submitting = true;
try {
const payload = buildSignupPayload();
const response = await fetch(`/api/tournament/slug/${tournament.slug}/signup`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
const text = await response.text();
if (!response.ok) {
let message = 'Kunde inte skicka anmälan.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw new Error(message);
}
let registrationId: number | null = null;
try {
const parsed = JSON.parse(text) as { registration_id?: number | string };
const numeric = Number(parsed?.registration_id);
if (Number.isFinite(numeric) && numeric > 0) {
registrationId = numeric;
}
} catch {
// ignored; we handle below
}
if (!registrationId) {
throw new Error('Kunde inte läsa svar från servern.');
}
const entrySummary = Object.fromEntries(Object.entries(payload.entry));
const participantSummary = payload.participants.map((values) => ({ ...values }));
resetSignupForm();
signup.successRegistrationId = registrationId;
signup.submittedEntry = entrySummary;
signup.submittedParticipants = participantSummary;
signup.success = 'Tack! Din anmälan har skickats.';
signup.showSuccessModal = true;
} catch (err) {
console.error('Failed to submit signup', err);
signup.error = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
} finally {
signup.submitting = false;
}
}
</script>
<svelte:head>
<title>{tournament.title} VBytes LAN</title>
</svelte:head>
<div class="min-h-screen bg-slate-950 text-slate-100">
<div class="mx-auto flex min-h-screen max-w-4xl flex-col gap-8 px-4 py-12">
<nav class="flex items-center justify-between text-sm text-slate-400">
<a href="/tournament" class="inline-flex items-center gap-2 transition hover:text-indigo-300">
<span aria-hidden="true"></span>
<span>Tillbaka till turneringsöversikten</span>
</a>
<span class="uppercase tracking-[0.4em] text-indigo-300">{tournament.game}</span>
</nav>
<header class="space-y-4 rounded-2xl bg-slate-900/70 p-6 shadow-lg">
<div class="space-y-2">
<p class="text-xs uppercase tracking-[0.4em] text-indigo-200">VBytes LAN</p>
<h1 class="text-3xl font-bold sm:text-4xl">{tournament.title}</h1>
{#if tournament.tagline}
<p class="text-base text-slate-300">{tournament.tagline}</p>
{/if}
</div>
<div class="grid gap-3 sm:grid-cols-2">
{#if formattedStart}
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
<p class="text-xs uppercase tracking-wide text-indigo-200">Start</p>
<p class="text-sm text-slate-100">{formattedStart}</p>
</div>
{/if}
{#if tournament.location}
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
<p class="text-xs uppercase tracking-wide text-indigo-200">Plats</p>
<p class="text-sm text-slate-100">{tournament.location}</p>
</div>
{/if}
{#if tournament.contact}
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 sm:col-span-2">
<p class="text-xs uppercase tracking-wide text-indigo-200">Kontakt</p>
<p class="text-sm text-slate-100">{tournament.contact}</p>
</div>
{/if}
</div>
</header>
{#if tournament.description}
<section class="space-y-3 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
<h2 class="text-lg font-semibold text-slate-100">Beskrivning</h2>
<p class="whitespace-pre-line text-sm leading-relaxed text-slate-200">{tournament.description}</p>
</section>
{/if}
{#if tournament.sections.length > 0}
<section class="space-y-4">
{#each tournament.sections as section, index (section.title + index)}
<article class="space-y-2 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
<h3 class="text-base font-semibold text-indigo-200">{section.title}</h3>
<p class="whitespace-pre-line text-sm leading-relaxed text-slate-200">{section.body}</p>
</article>
{/each}
</section>
{/if}
<section class="space-y-5 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
<header class="space-y-1">
<h2 class="text-lg font-semibold text-slate-100">Anmälan</h2>
{#if signupConfig.mode === 'team'}
<p class="text-sm text-slate-300">Lagstorlek: {signupConfig.team_size.min}{signupConfig.team_size.max} spelare.</p>
{:else}
<p class="text-sm text-slate-300">Individuell anmälan.</p>
{/if}
</header>
<form class="space-y-5" onsubmit={handleSignupSubmit}>
{#if signupConfig.entry_fields.length > 0}
<div class="space-y-3">
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Lag / deltagare</h3>
<div class="grid gap-3 md:grid-cols-2">
{#each signupConfig.entry_fields as field}
<label class="flex flex-col gap-1 text-sm font-medium text-slate-200">
<span>{field.label}</span>
<input
type={fieldInputType(field.field_type)}
required={field.required}
placeholder={field.placeholder ?? ''}
value={signup.entry[field.id]}
oninput={(event) => (signup.entry[field.id] = (event.currentTarget as HTMLInputElement).value)}
class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
/>
</label>
{/each}
</div>
</div>
{#if signup.showSuccessModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 px-4">
<div
class="w-full max-w-2xl space-y-6 rounded-2xl border border-slate-800 bg-slate-900 p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="signup-success-title"
>
<header class="space-y-2 text-center">
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p>
<h2 id="signup-success-title" class="text-2xl font-semibold text-slate-100 sm:text-3xl">
Anmälan bekräftad
</h2>
<p class="text-sm text-slate-300">Du är registrerad till {tournament.title}.</p>
{#if signup.successRegistrationId}
<p class="text-xs uppercase tracking-wide text-indigo-200">Anmälan #{signup.successRegistrationId}</p>
{/if}
</header>
<section class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-2">
<div class="space-y-2 text-left">
<h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Turnering</h3>
<p class="text-sm text-slate-300"><span class="font-medium text-slate-100">Spel:</span> {tournament.game}</p>
{#if tournament.start_at}
<p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Start:</span>
{formatDateTime(tournament.start_at) ?? tournament.start_at}
</p>
{/if}
{#if tournament.location}
<p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Plats:</span> {tournament.location}
</p>
{/if}
</div>
<div class="space-y-2 text-left">
<h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Format</h3>
{#if signupConfig.mode === 'team'}
<p class="text-sm text-slate-300">
Lag {signupConfig.team_size.min}{signupConfig.team_size.max} spelare
</p>
{:else}
<p class="text-sm text-slate-300">Individuell anmälan</p>
{/if}
{#if tournament.contact}
<p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Kontakt:</span> {tournament.contact}
</p>
{/if}
</div>
</section>
<section class="space-y-3">
<h3 class="text-base font-semibold text-slate-100">Anmälningsuppgifter</h3>
{#if signupConfig.entry_fields.length === 0}
<p class="rounded-md border border-dashed border-slate-700 px-4 py-3 text-sm text-slate-300">
Den här turneringen kräver inga uppgifter utöver spelare.
</p>
{:else}
<div class="grid gap-3 md:grid-cols-2">
{#each signupConfig.entry_fields as field}
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
<p class="text-xs uppercase tracking-wide text-indigo-200">{field.label}</p>
<p class="mt-1 text-sm text-slate-100">{signup.submittedEntry[field.id] || '—'}</p>
</div>
{/each}
</div>
{/if}
</section>
<section class="space-y-3">
<h3 class="text-base font-semibold text-slate-100">Spelare</h3>
{#if signupConfig.participant_fields.length === 0}
{#if signup.submittedParticipants.length === 0}
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
{:else}
<p class="text-sm text-slate-300">Antal spelare: {signup.submittedParticipants.length}</p>
{/if}
{:else if signup.submittedParticipants.length === 0}
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
{:else}
<div class="space-y-3">
{#each signup.submittedParticipants as participant, index}
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Spelare {index + 1}</p>
<ul class="mt-2 space-y-1 text-sm text-slate-100">
{#each signupConfig.participant_fields as field}
<li>
<span class="font-medium text-slate-300">{field.label}:</span>
<span class="ml-1">{participant[field.id] || '—'}</span>
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</section>
<div class="flex justify-center pt-2">
<a
href="/"
class="inline-flex items-center justify-center rounded-full border border-emerald-300 px-5 py-2 text-sm font-semibold text-emerald-200 transition hover:border-emerald-400 hover:bg-emerald-500/10"
>
Stäng
</a>
</div>
</div>
</div>
{/if}
{/if}
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Spelare</h3>
{#if signupConfig.mode === 'team'}
<button
type="button"
onclick={addParticipant}
disabled={!canAddParticipant() || signup.submitting}
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-200 transition hover:border-indigo-400 hover:bg-indigo-500/10 disabled:cursor-not-allowed disabled:opacity-60"
>
Lägg till spelare
</button>
{/if}
</div>
{#if signup.participants.length > 0}
<div class="space-y-4">
{#each signup.participants as participant, index (index)}
<div class="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4">
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-slate-200">{participantDisplayName(index)}</span>
{#if signupConfig.mode === 'team'}
<button
type="button"
onclick={() => removeParticipant(index)}
disabled={!canRemoveParticipant() || 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"
>
Ta bort
</button>
{/if}
</div>
{#if signupConfig.participant_fields.length > 0}
<div class="grid gap-3 md:grid-cols-2">
{#each signupConfig.participant_fields as field}
<label class="flex flex-col gap-1 text-sm font-medium text-slate-200">
<span>{field.label}</span>
<input
type={fieldInputType(field.field_type)}
required={field.required}
placeholder={field.placeholder ?? ''}
value={participant[field.id] ?? ''}
oninput={(event) => (participant[field.id] = (event.currentTarget as HTMLInputElement).value)}
class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
/>
</label>
{/each}
</div>
{:else}
<p class="text-xs text-slate-400">Inga spelarspecifika fält krävs.</p>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<div class="space-y-2 text-sm">
{#if signup.error}
<p class="rounded-md border border-red-400 bg-red-500/10 px-4 py-2 text-red-200">{signup.error}</p>
{:else if signup.success}
<p class="rounded-md border border-emerald-400 bg-emerald-500/10 px-4 py-2 text-emerald-200">{signup.success}</p>
{:else}
<p class="text-slate-400">Din anmälan skickas direkt till arrangören.</p>
{/if}
</div>
<button
type="submit"
disabled={signup.submitting}
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"
>
{signup.submitting ? 'Skickar…' : 'Skicka anmälan'}
</button>
</form>
</section>
<footer class="mt-auto flex items-center justify-between text-xs text-slate-500">
<p>Senast uppdaterad {formatDateTime(tournament.updated_at) ?? tournament.updated_at}</p>
<a href="/admin/tournament" class="rounded-full border border-indigo-300 px-4 py-2 font-semibold text-indigo-300 transition hover:border-indigo-400 hover:bg-indigo-50/5">
Administrera
</a>
</footer>
</div>
</div>

View file

@ -0,0 +1,54 @@
import { error, redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import type { TournamentRegistrationDetail } from '$lib/types';
import { API_BASE_URL } from '$lib/server/config';
export const load: PageServerLoad = async ({ params }) => {
const registrationId = Number.parseInt(params.registration, 10);
if (!Number.isFinite(registrationId) || registrationId <= 0) {
throw error(400, 'Ogiltigt registrerings-id.');
}
const apiUrl = new URL(
`/tournament/slug/${params.slug}/registrations/${registrationId}`,
API_BASE_URL
).toString();
try {
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
accept: 'application/json'
}
});
const text = await response.text();
if (!response.ok) {
let message = 'Kunde inte hämta anmälan.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
if (response.status === 404) {
throw redirect(302, `/tournament/${params.slug}?signup-missing=1`);
}
console.error('Failed to load registration detail', {
status: response.status,
message,
text
});
throw error(response.status, message);
}
const data = JSON.parse(text) as TournamentRegistrationDetail;
return { data };
} catch (err) {
if (err instanceof Response) throw err;
console.error('Unexpected error loading signup success', err);
throw error(500, 'Kunde inte hämta anmälan.');
}
};

View file

@ -0,0 +1,157 @@
<script lang="ts">
import type {
TournamentRegistrationDetail,
TournamentSignupField
} from '$lib/types';
const props = $props<{ data: TournamentRegistrationDetail }>();
const { tournament, registration } = props.data;
const entryFields = tournament.signup_config.entry_fields ?? [];
const participantFields = tournament.signup_config.participant_fields ?? [];
const entryValues = registration.entry;
const participantValues = registration.participants ?? [];
function formatDateTime(value: string | null | undefined) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString('sv-SE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
function fieldValue(map: Record<string, string>, field: TournamentSignupField) {
const value = map[field.id];
return value ?? '';
}
</script>
<svelte:head>
<title>Anmälan bekräftad {tournament.title}</title>
</svelte:head>
<div class="min-h-screen bg-slate-950 text-slate-100">
<div class="mx-auto flex min-h-screen max-w-4xl flex-col gap-8 px-4 py-12">
<header class="space-y-4 rounded-2xl bg-slate-900/70 p-6 text-center shadow-lg">
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p>
<h1 class="text-3xl font-bold sm:text-4xl">Anmälan bekräftad</h1>
<p class="text-sm text-slate-300">Du är registrerad till {tournament.title}.</p>
<p class="text-xs uppercase tracking-wide text-indigo-200">
Skapad {formatDateTime(registration.created_at) ?? registration.created_at}
</p>
</header>
<section class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900/60 p-6 md:grid-cols-2">
<div class="space-y-3">
<h2 class="text-lg font-semibold text-slate-100">Turnering</h2>
<p class="text-sm text-slate-300"><span class="font-medium text-slate-100">Spel:</span> {tournament.game}</p>
{#if tournament.start_at}
<p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Start:</span>
{formatDateTime(tournament.start_at) ?? tournament.start_at}
</p>
{/if}
{#if tournament.location}
<p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Plats:</span> {tournament.location}
</p>
{/if}
</div>
<div class="space-y-3">
<h2 class="text-lg font-semibold text-slate-100">Format</h2>
{#if tournament.signup_config.mode === 'team'}
<p class="text-sm text-slate-300">
Lag {tournament.signup_config.team_size.min}{tournament.signup_config.team_size.max} spelare
</p>
{:else}
<p class="text-sm text-slate-300">Individuell anmälan</p>
{/if}
{#if tournament.contact}
<p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Kontakt:</span> {tournament.contact}
</p>
{/if}
</div>
</section>
<section class="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/60 p-6">
<h2 class="text-lg font-semibold text-slate-100">Anmälningsuppgifter</h2>
{#if entryFields.length === 0}
<p class="rounded-md border border-dashed border-slate-700 px-4 py-3 text-sm text-slate-300">
Den här turneringen kräver inga uppgifter utöver spelare.
</p>
{:else}
<div class="grid gap-3 md:grid-cols-2">
{#each entryFields as field}
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
<p class="text-xs uppercase tracking-wide text-indigo-200">{field.label}</p>
<p class="mt-1 text-sm text-slate-100">{fieldValue(entryValues, field) || '—'}</p>
</div>
{/each}
</div>
{/if}
</section>
<section class="space-y-4 rounded-2xl border border-slate-800 bg-slate-900/60 p-6">
<h2 class="text-lg font-semibold text-slate-100">Spelare</h2>
{#if participantFields.length === 0}
{#if participantValues.length === 0}
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
{:else}
<p class="text-sm text-slate-300">Antal spelare: {participantValues.length}</p>
{/if}
{:else if participantValues.length === 0}
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
{:else}
<div class="space-y-3">
{#each participantValues as participant, index}
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Spelare {index + 1}</p>
<ul class="mt-2 space-y-1 text-sm text-slate-100">
{#each participantFields as field}
<li>
<span class="font-medium text-slate-300">{field.label}:</span>
<span class="ml-1">{fieldValue(participant, field) || '—'}</span>
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
</section>
<footer class="mt-auto flex flex-col gap-3 text-center text-sm text-slate-400">
<p>
Behöver du uppdatera informationen? Kontakta arrangören eller skicka in en ny anmälan.
</p>
<div class="flex flex-wrap justify-center gap-3">
<a
href="/"
class="inline-flex items-center justify-center rounded-full border border-emerald-300 px-5 py-2 text-sm font-semibold text-emerald-200 transition hover:border-emerald-400 hover:bg-emerald-500/10"
>
Hem
</a>
<a
href={`/tournament/${tournament.slug}`}
class="inline-flex items-center justify-center rounded-full border border-indigo-300 px-5 py-2 text-sm font-semibold text-indigo-200 transition hover:border-indigo-400 hover:bg-indigo-500/10"
>
Visa turneringen
</a>
<a
href="/tournament"
class="inline-flex items-center justify-center rounded-full border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-200 transition hover:border-slate-400 hover:bg-slate-800"
>
Visa turneringsöversikt
</a>
</div>
</footer>
</div>
</div>

View file

@ -1,7 +0,0 @@
import type { LayoutServerLoad } from './$types';
import { AUTH_COOKIE_NAME } from '$lib/server/config';
export const load: LayoutServerLoad = async ({ cookies }) => {
const isLoggedIn = Boolean(cookies.get(AUTH_COOKIE_NAME));
return { isLoggedIn };
};

View file

@ -1,127 +1,7 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import favicon from '$lib/assets/favicon.svg';
const props = $props(); const props = $props();
const children = $derived(props.children); const children = $derived(props.children);
const data = $derived(props.data);
let ui = $state({
loggedIn: false,
loggingOut: false,
message: ''
});
$effect(() => {
ui.loggedIn = Boolean(data?.isLoggedIn);
});
async function handleLogout() {
if (ui.loggingOut) return;
ui.loggingOut = true;
ui.message = '';
try {
const response = await fetch('/api/logout', { method: 'POST' });
if (!response.ok) {
const body = await response.json().catch(() => ({}));
ui.message = body.message ?? 'Kunde inte logga ut. Försök igen.';
} else {
ui.loggedIn = false;
await goto('/login', { invalidateAll: true });
}
} catch (err) {
console.error('Logout failed', err);
ui.message = 'Ett oväntat fel uppstod vid utloggning.';
} finally {
ui.loggingOut = false;
}
}
</script> </script>
<svelte:head> {@render children?.()}
<link rel="icon" href={favicon} />
</svelte:head>
<div class="min-h-screen bg-slate-100 text-slate-900">
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-5xl flex-col gap-4 px-4 py-4">
<div class="flex items-start justify-between gap-3 sm:items-center">
<div>
<p class="text-sm font-medium tracking-wide text-slate-500 uppercase">VBytes</p>
<h1 class="text-xl font-semibold text-slate-900">Gästhantering</h1>
</div>
{#if ui.loggedIn}
<button
onclick={handleLogout}
disabled={ui.loggingOut}
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{ui.loggingOut ? 'Loggar ut…' : 'Logga ut'}
</button>
{/if}
</div>
{#if ui.loggedIn}
<nav class="flex flex-wrap items-center justify-center gap-2 sm:justify-between">
<a
href="/"
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
$page.url.pathname === '/'
? 'bg-indigo-600 text-white shadow'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
Checka in
</a>
<a
href="/checkout"
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
$page.url.pathname === '/checkout'
? 'bg-indigo-600 text-white shadow'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
Checka ut
</a>
<a
href="/create"
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
$page.url.pathname === '/create'
? 'bg-indigo-600 text-white shadow'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
Lägg till
</a>
<a
href="/inside-status"
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
$page.url.pathname === '/inside-status'
? 'bg-indigo-600 text-white shadow'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
Inne/ute
</a>
<a
href="/checked-in"
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
$page.url.pathname === '/checked-in'
? 'bg-indigo-600 text-white shadow'
: 'text-slate-600 hover:bg-slate-100'
}`}
>
Översikt
</a>
</nav>
{/if}
</div>
{#if ui.message}
<p class="bg-red-50 px-4 py-2 text-center text-sm text-red-600">{ui.message}</p>
{/if}
</header>
<main class="mx-auto max-w-5xl px-4 py-6">
{@render children?.()}
</main>
</div>

View file

@ -1,11 +1,6 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { AUTH_COOKIE_NAME } from '$lib/server/config';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = () => {
if (!cookies.get(AUTH_COOKIE_NAME)) { throw redirect(302, '/tournament');
throw redirect(302, '/login');
}
return {};
}; };

View file

@ -0,0 +1,63 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend';
function buildHeaders(response: Response, setCookies: string[]): Headers {
const headers = new Headers();
const contentType = response.headers.get('content-type');
if (contentType) {
headers.set('content-type', contentType);
} else {
headers.set('content-type', 'application/json');
}
for (const cookie of setCookies) {
headers.append('set-cookie', cookie);
}
return headers;
}
export const GET: RequestHandler = async (event) => {
const { response, setCookies } = await proxyRequest(event, '/tournament', {
method: 'GET'
});
const text = await response.text();
const headers = buildHeaders(response, setCookies);
if (!response.ok) {
let message = 'Kunde inte hämta turneringar.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw error(response.status, message);
}
return new Response(text, { status: response.status, headers });
};
export const POST: RequestHandler = async (event) => {
const payload = await event.request.json();
const { response, setCookies } = await proxyRequest(event, '/tournament', {
method: 'POST',
body: JSON.stringify(payload)
});
const text = await response.text();
const headers = buildHeaders(response, setCookies);
if (!response.ok) {
let message = 'Kunde inte skapa turnering.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw error(response.status, message);
}
return new Response(text, { status: response.status, headers });
};

View file

@ -0,0 +1,50 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend';
function buildHeaders(response: Response, setCookies: string[]): Headers {
const headers = new Headers();
const contentType = response.headers.get('content-type');
if (contentType) {
headers.set('content-type', contentType);
} else {
headers.set('content-type', 'application/json');
}
for (const cookie of setCookies) {
headers.append('set-cookie', cookie);
}
return headers;
}
async function proxyTournament(event: Parameters<RequestHandler>[0], init: RequestInit) {
const path = `/tournament/${event.params.id}`;
const { response, setCookies } = await proxyRequest(event, path, init);
const text = await response.text();
const headers = buildHeaders(response, setCookies);
if (!response.ok) {
let message = 'Kunde inte hantera turnering.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw error(response.status, message);
}
return new Response(text, { status: response.status, headers });
}
export const GET: RequestHandler = async (event) => proxyTournament(event, { method: 'GET' });
export const PUT: RequestHandler = async (event) => {
const payload = await event.request.json();
return proxyTournament(event, {
method: 'PUT',
body: JSON.stringify(payload)
});
};
export const DELETE: RequestHandler = async (event) =>
proxyTournament(event, { method: 'DELETE' });

View file

@ -0,0 +1,39 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend';
function buildHeaders(response: Response, setCookies: string[]): Headers {
const headers = new Headers();
const contentType = response.headers.get('content-type');
if (contentType) {
headers.set('content-type', contentType);
} else {
headers.set('content-type', 'application/json');
}
for (const cookie of setCookies) {
headers.append('set-cookie', cookie);
}
return headers;
}
async function proxyTournament(event: Parameters<RequestHandler>[0], init: RequestInit) {
const path = `/tournament/slug/${event.params.slug}`;
const { response, setCookies } = await proxyRequest(event, path, init);
const text = await response.text();
const headers = buildHeaders(response, setCookies);
if (!response.ok) {
let message = 'Kunde inte hämta turnering.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw error(response.status, message);
}
return new Response(text, { status: response.status, headers });
}
export const GET: RequestHandler = async (event) => proxyTournament(event, { method: 'GET' });

View file

@ -0,0 +1,30 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend';
export const GET: RequestHandler = async (event) => {
const { response, setCookies } = await proxyRequest(
event,
`/tournament/slug/${event.params.slug}/registrations`,
{ method: 'GET' }
);
const text = await response.text();
const headers = new Headers();
const contentType = response.headers.get('content-type');
if (contentType) headers.set('content-type', contentType);
for (const cookie of setCookies) headers.append('set-cookie', cookie);
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;
}
throw error(response.status, message);
}
return new Response(text, { status: response.status, headers });
};

View file

@ -0,0 +1,37 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend';
function buildHeaders(response: Response, setCookies: string[]): Headers {
const headers = new Headers();
const contentType = response.headers.get('content-type');
if (contentType) {
headers.set('content-type', contentType);
} else {
headers.set('content-type', 'application/json');
}
for (const cookie of setCookies) {
headers.append('set-cookie', cookie);
}
return headers;
}
export const GET: RequestHandler = async (event) => {
const path = `/tournament/slug/${event.params.slug}/registrations/${event.params.registration}`;
const { response, setCookies } = await proxyRequest(event, path, { method: 'GET' });
const text = await response.text();
const headers = buildHeaders(response, setCookies);
if (!response.ok) {
let message = 'Kunde inte hämta anmälan.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw error(response.status, message);
}
return new Response(text, { status: response.status, headers });
};

View file

@ -0,0 +1,45 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend';
function buildHeaders(response: Response, setCookies: string[]): Headers {
const headers = new Headers();
const contentType = response.headers.get('content-type');
if (contentType) {
headers.set('content-type', contentType);
} else {
headers.set('content-type', 'application/json');
}
for (const cookie of setCookies) {
headers.append('set-cookie', cookie);
}
return headers;
}
export const POST: RequestHandler = async (event) => {
const body = await event.request.text();
const { response, setCookies } = await proxyRequest(
event,
`/tournament/slug/${event.params.slug}/signup`,
{
method: 'POST',
body
}
);
const text = await response.text();
const headers = buildHeaders(response, setCookies);
if (!response.ok) {
let message = 'Kunde inte skicka anmälan.';
try {
const parsed = JSON.parse(text);
message = parsed.message ?? message;
} catch {
if (text) message = text;
}
throw error(response.status, message);
}
return new Response(text, { status: response.status, headers });
};