Mergin Turnament project with guest handling project
This commit is contained in:
parent
7c2ca0ccef
commit
89c6a5a340
50 changed files with 5686 additions and 897 deletions
18
api/migrations/20250101002000_create_tournament_info.sql
Normal file
18
api/migrations/20250101002000_create_tournament_info.sql
Normal 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);
|
||||
10
api/migrations/20250101003000_enable_multi_tournaments.sql
Normal file
10
api/migrations/20250101003000_enable_multi_tournaments.sql
Normal 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);
|
||||
15
api/migrations/20250102004000_add_tournament_fields.sql
Normal file
15
api/migrations/20250102004000_add_tournament_fields.sql
Normal 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);
|
||||
32
api/migrations/20250102005000_add_tournament_signup.sql
Normal file
32
api/migrations/20250102005000_add_tournament_signup.sql
Normal 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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE tournament_signup_fields
|
||||
ADD COLUMN unique_field BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
698
api/src/main.rs
698
api/src/main.rs
|
|
@ -2,23 +2,21 @@ mod auth;
|
|||
mod config;
|
||||
mod error;
|
||||
mod models;
|
||||
mod routes;
|
||||
mod seed;
|
||||
|
||||
use auth::{generate_token, AuthUser};
|
||||
use config::AppConfig;
|
||||
use error::ApiError;
|
||||
use models::{
|
||||
LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse,
|
||||
PersonsResponse, UpdatePersonRequest, User,
|
||||
};
|
||||
use models::{AppEvent, LoginRequest, LoginResponse, User};
|
||||
use rocket::http::{Cookie, CookieJar, SameSite, Status};
|
||||
use rocket::response::stream::{Event, EventStream};
|
||||
use rocket::serde::json::Json;
|
||||
use rocket::time::Duration;
|
||||
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::{PgPool, QueryBuilder};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub struct AppState {
|
||||
pub db: PgPool,
|
||||
|
|
@ -28,7 +26,7 @@ pub struct AppState {
|
|||
pub cookie_name: String,
|
||||
pub cookie_secure: bool,
|
||||
pub cookie_same_site: SameSite,
|
||||
pub event_sender: broadcast::Sender<PersonActionResponse>,
|
||||
pub event_sender: broadcast::Sender<AppEvent>,
|
||||
}
|
||||
|
||||
#[rocket::main]
|
||||
|
|
@ -67,19 +65,8 @@ async fn main() -> Result<(), rocket::Error> {
|
|||
let rocket = rocket::build()
|
||||
.manage(state)
|
||||
.mount("/", routes![healthz, login, logout, events])
|
||||
.mount(
|
||||
"/persons",
|
||||
routes![
|
||||
search_persons,
|
||||
list_checked_in,
|
||||
checkin_person,
|
||||
checkout_person,
|
||||
mark_inside,
|
||||
mark_outside,
|
||||
create_person,
|
||||
update_person
|
||||
],
|
||||
);
|
||||
.mount("/persons", routes::persons::routes())
|
||||
.mount("/tournament", routes::tournaments::routes());
|
||||
|
||||
rocket.launch().await?;
|
||||
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
14
api/src/models/event.rs
Normal 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
7
api/src/models/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
pub mod event;
|
||||
pub mod person;
|
||||
pub mod tournament;
|
||||
|
||||
pub use event::*;
|
||||
pub use person::*;
|
||||
pub use tournament::*;
|
||||
|
|
@ -9,7 +9,7 @@ pub struct User {
|
|||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
#[derive(Debug, FromRow, Clone)]
|
||||
pub struct Person {
|
||||
pub id: i32,
|
||||
pub first_name: String,
|
||||
366
api/src/models/tournament.rs
Normal file
366
api/src/models/tournament.rs
Normal 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
2
api/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod persons;
|
||||
pub mod tournaments;
|
||||
506
api/src/routes/persons.rs
Normal file
506
api/src/routes/persons.rs
Normal 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))
|
||||
}
|
||||
1356
api/src/routes/tournaments.rs
Normal file
1356
api/src/routes/tournaments.rs
Normal file
File diff suppressed because it is too large
Load diff
41
web/src/lib/client/event-stream.ts
Normal file
41
web/src/lib/client/event-stream.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,40 +1,10 @@
|
|||
import type { Person } from '$lib/types';
|
||||
|
||||
export type PersonEvent = {
|
||||
person: Person;
|
||||
};
|
||||
import { listenToEvents } from '$lib/client/event-stream';
|
||||
|
||||
export function listenToPersonEvents(onPerson: (person: Person) => 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 PersonEvent;
|
||||
if (data.person) {
|
||||
onPerson(data.person);
|
||||
return listenToEvents((event) => {
|
||||
if (event.type === 'person_updated') {
|
||||
onPerson(event.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;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
21
web/src/lib/client/tournament-events.ts
Normal file
21
web/src/lib/client/tournament-events.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -10,3 +10,66 @@ export interface Person {
|
|||
visitor: 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;
|
||||
}
|
||||
|
|
|
|||
14
web/src/routes/(admin)/admin/+layout.server.ts
Normal file
14
web/src/routes/(admin)/admin/+layout.server.ts
Normal 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 };
|
||||
};
|
||||
172
web/src/routes/(admin)/admin/+layout.svelte
Normal file
172
web/src/routes/(admin)/admin/+layout.svelte
Normal 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>
|
||||
32
web/src/routes/(admin)/admin/+page.svelte
Normal file
32
web/src/routes/(admin)/admin/+page.svelte
Normal 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>
|
||||
6
web/src/routes/(admin)/admin/checkin/+layout.svelte
Normal file
6
web/src/routes/(admin)/admin/checkin/+layout.svelte
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<script lang="ts">
|
||||
const props = $props();
|
||||
const children = $derived(props.children);
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
5
web/src/routes/(admin)/admin/checkin/+page.server.ts
Normal file
5
web/src/routes/(admin)/admin/checkin/+page.server.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {};
|
||||
};
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status === 401) {
|
||||
await goto('/login');
|
||||
await goto('/admin/login');
|
||||
return null;
|
||||
}
|
||||
return response;
|
||||
|
|
@ -33,7 +33,7 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
|||
async function apiFetch(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (response.status === 401) {
|
||||
await goto('/login');
|
||||
await goto('/admin/login');
|
||||
return null;
|
||||
}
|
||||
return response;
|
||||
|
|
@ -307,9 +307,9 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
|||
{#if infoMessage && !errorMessage}
|
||||
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
{#if persons.length > 0}
|
||||
<div class="mt-6 space-y-4">
|
||||
{#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">
|
||||
|
|
@ -361,7 +361,9 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
|||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<EditPersonModal
|
||||
|
|
@ -61,7 +61,7 @@
|
|||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status === 401) {
|
||||
await goto('/login');
|
||||
await goto('/admin/login');
|
||||
return null;
|
||||
}
|
||||
return response;
|
||||
|
|
@ -23,7 +23,7 @@
|
|||
async function apiFetch(url: string, init?: RequestInit) {
|
||||
const response = await fetch(url, init);
|
||||
if (response.status === 401) {
|
||||
await goto('/login');
|
||||
await goto('/admin/login');
|
||||
return null;
|
||||
}
|
||||
return response;
|
||||
|
|
@ -233,9 +233,9 @@
|
|||
{actionMessage}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
{#if persons.length > 0}
|
||||
<div class="mt-6 space-y-4">
|
||||
{#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">
|
||||
|
|
@ -292,5 +292,7 @@
|
|||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
|
@ -29,7 +29,7 @@
|
|||
return;
|
||||
}
|
||||
|
||||
await goto('/', { invalidateAll: true });
|
||||
await goto('/admin', { invalidateAll: true });
|
||||
} catch (err) {
|
||||
console.error('Login failed', err);
|
||||
errorMessage = 'Ett oväntat fel inträffade. Försök igen.';
|
||||
6
web/src/routes/(admin)/admin/tournament/+layout.svelte
Normal file
6
web/src/routes/(admin)/admin/tournament/+layout.svelte
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<script lang="ts">
|
||||
const props = $props();
|
||||
const children = $derived(props.children);
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
18
web/src/routes/(admin)/admin/tournament/+page.server.ts
Normal file
18
web/src/routes/(admin)/admin/tournament/+page.server.ts
Normal 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[] };
|
||||
}
|
||||
};
|
||||
1551
web/src/routes/(admin)/admin/tournament/+page.svelte
Normal file
1551
web/src/routes/(admin)/admin/tournament/+page.svelte
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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>
|
||||
16
web/src/routes/(tournament)/tournament/+page.server.ts
Normal file
16
web/src/routes/(tournament)/tournament/+page.server.ts
Normal 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[] };
|
||||
}
|
||||
};
|
||||
141
web/src/routes/(tournament)/tournament/+page.svelte
Normal file
141
web/src/routes/(tournament)/tournament/+page.svelte
Normal 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>
|
||||
|
|
@ -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.');
|
||||
}
|
||||
};
|
||||
534
web/src/routes/(tournament)/tournament/[slug]/+page.svelte
Normal file
534
web/src/routes/(tournament)/tournament/[slug]/+page.svelte
Normal 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>
|
||||
|
|
@ -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.');
|
||||
}
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 };
|
||||
};
|
||||
|
|
@ -1,127 +1,7 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
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);
|
||||
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>
|
||||
|
||||
<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-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>
|
||||
{@render children?.()}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { AUTH_COOKIE_NAME } from '$lib/server/config';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
if (!cookies.get(AUTH_COOKIE_NAME)) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
return {};
|
||||
export const load: PageServerLoad = () => {
|
||||
throw redirect(302, '/tournament');
|
||||
};
|
||||
|
|
|
|||
63
web/src/routes/api/tournament/+server.ts
Normal file
63
web/src/routes/api/tournament/+server.ts
Normal 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 });
|
||||
};
|
||||
50
web/src/routes/api/tournament/[id]/+server.ts
Normal file
50
web/src/routes/api/tournament/[id]/+server.ts
Normal 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' });
|
||||
39
web/src/routes/api/tournament/slug/[slug]/+server.ts
Normal file
39
web/src/routes/api/tournament/slug/[slug]/+server.ts
Normal 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' });
|
||||
|
|
@ -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 });
|
||||
};
|
||||
|
|
@ -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 });
|
||||
};
|
||||
45
web/src/routes/api/tournament/slug/[slug]/signup/+server.ts
Normal file
45
web/src/routes/api/tournament/slug/[slug]/signup/+server.ts
Normal 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 });
|
||||
};
|
||||
Loading…
Reference in a new issue