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 config;
|
||||||
mod error;
|
mod error;
|
||||||
mod models;
|
mod models;
|
||||||
|
mod routes;
|
||||||
mod seed;
|
mod seed;
|
||||||
|
|
||||||
use auth::{generate_token, AuthUser};
|
use auth::{generate_token, AuthUser};
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use error::ApiError;
|
use error::ApiError;
|
||||||
use models::{
|
use models::{AppEvent, LoginRequest, LoginResponse, User};
|
||||||
LoginRequest, LoginResponse, NewPersonRequest, Person, PersonActionResponse, PersonResponse,
|
|
||||||
PersonsResponse, UpdatePersonRequest, User,
|
|
||||||
};
|
|
||||||
use rocket::http::{Cookie, CookieJar, SameSite, Status};
|
use rocket::http::{Cookie, CookieJar, SameSite, Status};
|
||||||
use rocket::response::stream::{Event, EventStream};
|
use rocket::response::stream::{Event, EventStream};
|
||||||
use rocket::serde::json::Json;
|
use rocket::serde::json::Json;
|
||||||
use rocket::time::Duration;
|
use rocket::time::Duration;
|
||||||
use rocket::tokio::sync::broadcast::{self, error::RecvError};
|
use rocket::tokio::sync::broadcast::{self, error::RecvError};
|
||||||
use rocket::{get, post, put, routes, State};
|
use rocket::{get, post, routes, State};
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::{PgPool, QueryBuilder};
|
use sqlx::PgPool;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: PgPool,
|
pub db: PgPool,
|
||||||
|
|
@ -28,7 +26,7 @@ pub struct AppState {
|
||||||
pub cookie_name: String,
|
pub cookie_name: String,
|
||||||
pub cookie_secure: bool,
|
pub cookie_secure: bool,
|
||||||
pub cookie_same_site: SameSite,
|
pub cookie_same_site: SameSite,
|
||||||
pub event_sender: broadcast::Sender<PersonActionResponse>,
|
pub event_sender: broadcast::Sender<AppEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[rocket::main]
|
#[rocket::main]
|
||||||
|
|
@ -67,19 +65,8 @@ async fn main() -> Result<(), rocket::Error> {
|
||||||
let rocket = rocket::build()
|
let rocket = rocket::build()
|
||||||
.manage(state)
|
.manage(state)
|
||||||
.mount("/", routes![healthz, login, logout, events])
|
.mount("/", routes![healthz, login, logout, events])
|
||||||
.mount(
|
.mount("/persons", routes::persons::routes())
|
||||||
"/persons",
|
.mount("/tournament", routes::tournaments::routes());
|
||||||
routes![
|
|
||||||
search_persons,
|
|
||||||
list_checked_in,
|
|
||||||
checkin_person,
|
|
||||||
checkout_person,
|
|
||||||
mark_inside,
|
|
||||||
mark_outside,
|
|
||||||
create_person,
|
|
||||||
update_person
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
rocket.launch().await?;
|
rocket.launch().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -170,674 +157,3 @@ fn events(_user: AuthUser, state: &State<AppState>) -> EventStream![Event + '_]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/search?<q>")]
|
|
||||||
async fn search_persons(
|
|
||||||
_user: AuthUser,
|
|
||||||
state: &State<AppState>,
|
|
||||||
q: &str,
|
|
||||||
) -> Result<Json<PersonsResponse>, ApiError> {
|
|
||||||
let query = q.trim();
|
|
||||||
if query.is_empty() {
|
|
||||||
return Err(ApiError::bad_request("Söktext krävs."));
|
|
||||||
}
|
|
||||||
|
|
||||||
let like_pattern = format!("%{}%", query);
|
|
||||||
let id_value = query.parse::<i32>().ok();
|
|
||||||
|
|
||||||
let persons = if let Some(id) = id_value {
|
|
||||||
sqlx::query_as::<_, Person>(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
FROM persons
|
|
||||||
WHERE first_name ILIKE $1
|
|
||||||
OR last_name ILIKE $1
|
|
||||||
OR parent_name ILIKE $1
|
|
||||||
OR parent_phone_number ILIKE $1
|
|
||||||
OR (first_name || ' ' || last_name) ILIKE $1
|
|
||||||
OR id = $2
|
|
||||||
ORDER BY last_name, first_name
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&like_pattern)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_all(&state.db)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
sqlx::query_as::<_, Person>(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
FROM persons
|
|
||||||
WHERE first_name ILIKE $1
|
|
||||||
OR last_name ILIKE $1
|
|
||||||
OR parent_name ILIKE $1
|
|
||||||
OR parent_phone_number ILIKE $1
|
|
||||||
OR (first_name || ' ' || last_name) ILIKE $1
|
|
||||||
ORDER BY last_name, first_name
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&like_pattern)
|
|
||||||
.fetch_all(&state.db)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
|
|
||||||
let persons = persons.into_iter().map(PersonResponse::from).collect();
|
|
||||||
Ok(Json(PersonsResponse { persons }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[get("/checked-in?<q>&<status>&<checked>")]
|
|
||||||
async fn list_checked_in(
|
|
||||||
_user: AuthUser,
|
|
||||||
state: &State<AppState>,
|
|
||||||
q: Option<&str>,
|
|
||||||
status: Option<&str>,
|
|
||||||
checked: Option<&str>,
|
|
||||||
) -> Result<Json<PersonsResponse>, ApiError> {
|
|
||||||
let inside_filter = match status.map(|s| s.trim().to_lowercase()) {
|
|
||||||
Some(value) if value.is_empty() || value == "all" => None,
|
|
||||||
Some(value) if value == "inside" => Some(true),
|
|
||||||
Some(value) if value == "outside" => Some(false),
|
|
||||||
Some(_) => return Err(ApiError::bad_request("Ogiltigt statusvärde.")),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let checked_filter = match checked.map(|s| s.trim().to_lowercase()) {
|
|
||||||
Some(value) if value.is_empty() || value == "all" => None,
|
|
||||||
Some(value) if value == "true" || value == "checked" || value == "in" => Some(true),
|
|
||||||
Some(value) if value == "false" || value == "unchecked" || value == "out" => Some(false),
|
|
||||||
Some(_) => return Err(ApiError::bad_request("Ogiltigt filter för incheckning.")),
|
|
||||||
None => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let search_term = q
|
|
||||||
.map(|raw| raw.trim())
|
|
||||||
.filter(|trimmed| !trimmed.is_empty())
|
|
||||||
.map(|trimmed| trimmed.to_string());
|
|
||||||
let like_pattern = search_term.as_ref().map(|s| format!("%{}%", s));
|
|
||||||
let id_value = search_term.as_ref().and_then(|s| s.parse::<i32>().ok());
|
|
||||||
|
|
||||||
let mut qb = QueryBuilder::<sqlx::Postgres>::new(
|
|
||||||
"SELECT id, first_name, last_name, grade, parent_name, parent_phone_number, checked_in, inside, visitor, sleeping_spot FROM persons",
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut first_condition = true;
|
|
||||||
let mut append_condition = |qb: &mut QueryBuilder<sqlx::Postgres>| {
|
|
||||||
if first_condition {
|
|
||||||
qb.push(" WHERE ");
|
|
||||||
first_condition = false;
|
|
||||||
} else {
|
|
||||||
qb.push(" AND ");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(checked) = checked_filter {
|
|
||||||
append_condition(&mut qb);
|
|
||||||
qb.push("checked_in = ").push_bind(checked);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(inside) = inside_filter {
|
|
||||||
append_condition(&mut qb);
|
|
||||||
qb.push("inside = ").push_bind(inside);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(like) = like_pattern.as_ref() {
|
|
||||||
append_condition(&mut qb);
|
|
||||||
qb.push("(");
|
|
||||||
qb.push("first_name ILIKE ").push_bind(like);
|
|
||||||
qb.push(" OR last_name ILIKE ").push_bind(like);
|
|
||||||
qb.push(" OR parent_name ILIKE ").push_bind(like);
|
|
||||||
qb.push(" OR parent_phone_number ILIKE ").push_bind(like);
|
|
||||||
qb.push(" OR (first_name || ' ' || last_name) ILIKE ")
|
|
||||||
.push_bind(like);
|
|
||||||
if let Some(id) = id_value {
|
|
||||||
qb.push(" OR id = ").push_bind(id);
|
|
||||||
}
|
|
||||||
qb.push(")");
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(id) = id_value {
|
|
||||||
qb.push(" ORDER BY CASE WHEN id = ")
|
|
||||||
.push_bind(id)
|
|
||||||
.push(" THEN 0 ELSE 1 END, id, last_name, first_name");
|
|
||||||
} else {
|
|
||||||
qb.push(" ORDER BY id, last_name, first_name");
|
|
||||||
}
|
|
||||||
|
|
||||||
let persons = qb.build_query_as::<Person>().fetch_all(&state.db).await?;
|
|
||||||
|
|
||||||
let persons = persons.into_iter().map(PersonResponse::from).collect();
|
|
||||||
Ok(Json(PersonsResponse { persons }))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/<id>/checkin")]
|
|
||||||
async fn checkin_person(
|
|
||||||
_user: AuthUser,
|
|
||||||
state: &State<AppState>,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
|
||||||
update_checked_in(state, id, true).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/<id>/checkout")]
|
|
||||||
async fn checkout_person(
|
|
||||||
_user: AuthUser,
|
|
||||||
state: &State<AppState>,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
|
||||||
update_checked_in(state, id, false).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/<id>/inside")]
|
|
||||||
async fn mark_inside(
|
|
||||||
_user: AuthUser,
|
|
||||||
state: &State<AppState>,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
|
||||||
update_inside(state, id, true).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/<id>/outside")]
|
|
||||||
async fn mark_outside(
|
|
||||||
_user: AuthUser,
|
|
||||||
state: &State<AppState>,
|
|
||||||
id: i32,
|
|
||||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
|
||||||
update_inside(state, id, false).await
|
|
||||||
}
|
|
||||||
|
|
||||||
#[post("/", data = "<payload>")]
|
|
||||||
async fn create_person(
|
|
||||||
_user: AuthUser,
|
|
||||||
state: &State<AppState>,
|
|
||||||
payload: Json<NewPersonRequest>,
|
|
||||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
|
||||||
let NewPersonRequest {
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
id,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot,
|
|
||||||
} = payload.into_inner();
|
|
||||||
|
|
||||||
let first_name = first_name.trim().to_string();
|
|
||||||
if first_name.is_empty() {
|
|
||||||
return Err(ApiError::bad_request("Förnamn får inte vara tomt."));
|
|
||||||
}
|
|
||||||
|
|
||||||
let last_name = last_name.trim().to_string();
|
|
||||||
if last_name.is_empty() {
|
|
||||||
return Err(ApiError::bad_request("Efternamn får inte vara tomt."));
|
|
||||||
}
|
|
||||||
|
|
||||||
if grade.map(|value| value < 0).unwrap_or(false) {
|
|
||||||
return Err(ApiError::bad_request("Klass måste vara noll eller högre."));
|
|
||||||
}
|
|
||||||
|
|
||||||
let parent_name = normalize_optional_string(&parent_name);
|
|
||||||
let parent_phone_number = normalize_optional_string(&parent_phone_number);
|
|
||||||
|
|
||||||
let checked_in = checked_in.unwrap_or(false);
|
|
||||||
let inside = inside.unwrap_or(false);
|
|
||||||
let visitor = visitor.unwrap_or(false);
|
|
||||||
let sleeping_spot = sleeping_spot.unwrap_or(false);
|
|
||||||
|
|
||||||
if (checked_in || inside) && !fields_are_complete(grade, &parent_name, &parent_phone_number) {
|
|
||||||
return Err(ApiError::bad_request(
|
|
||||||
"Kontaktperson, telefon och klass krävs innan incheckning.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let person = match id {
|
|
||||||
Some(id) => sqlx::query_as::<_, Person>(
|
|
||||||
r#"
|
|
||||||
INSERT INTO persons (
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
||||||
RETURNING
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(&first_name)
|
|
||||||
.bind(&last_name)
|
|
||||||
.bind(grade)
|
|
||||||
.bind(parent_name.as_deref())
|
|
||||||
.bind(parent_phone_number.as_deref())
|
|
||||||
.bind(checked_in)
|
|
||||||
.bind(inside)
|
|
||||||
.bind(visitor)
|
|
||||||
.bind(sleeping_spot)
|
|
||||||
.fetch_one(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
|
|
||||||
None => sqlx::query_as::<_, Person>(
|
|
||||||
r#"
|
|
||||||
INSERT INTO persons (
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
RETURNING
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(&first_name)
|
|
||||||
.bind(&last_name)
|
|
||||||
.bind(grade)
|
|
||||||
.bind(parent_name.as_deref())
|
|
||||||
.bind(parent_phone_number.as_deref())
|
|
||||||
.bind(checked_in)
|
|
||||||
.bind(inside)
|
|
||||||
.bind(visitor)
|
|
||||||
.bind(sleeping_spot)
|
|
||||||
.fetch_one(&state.db)
|
|
||||||
.await
|
|
||||||
.map_err(|err| map_db_error(err, "Kunde inte skapa person"))?,
|
|
||||||
};
|
|
||||||
|
|
||||||
let response = PersonActionResponse {
|
|
||||||
person: person.into(),
|
|
||||||
};
|
|
||||||
broadcast_person_update(state, &response);
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[put("/<id>", data = "<payload>")]
|
|
||||||
async fn update_person(
|
|
||||||
_user: AuthUser,
|
|
||||||
state: &State<AppState>,
|
|
||||||
id: i32,
|
|
||||||
payload: Json<UpdatePersonRequest>,
|
|
||||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
|
||||||
let UpdatePersonRequest {
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot,
|
|
||||||
} = payload.into_inner();
|
|
||||||
|
|
||||||
let first_name = first_name.trim().to_string();
|
|
||||||
if first_name.is_empty() {
|
|
||||||
return Err(ApiError::bad_request("Förnamn får inte vara tomt."));
|
|
||||||
}
|
|
||||||
|
|
||||||
let last_name = last_name.trim().to_string();
|
|
||||||
if last_name.is_empty() {
|
|
||||||
return Err(ApiError::bad_request("Efternamn får inte vara tomt."));
|
|
||||||
}
|
|
||||||
|
|
||||||
if grade.map(|value| value < 0).unwrap_or(false) {
|
|
||||||
return Err(ApiError::bad_request("Klass måste vara noll eller högre."));
|
|
||||||
}
|
|
||||||
|
|
||||||
let parent_name_present = parent_name.is_some();
|
|
||||||
let parent_phone_present = parent_phone_number.is_some();
|
|
||||||
let grade_present = grade.is_some();
|
|
||||||
|
|
||||||
let parent_name = normalize_optional_string(&parent_name);
|
|
||||||
let parent_phone_number = normalize_optional_string(&parent_phone_number);
|
|
||||||
|
|
||||||
let (final_checked_in, final_inside) = match (checked_in, inside) {
|
|
||||||
(Some(false), _) => (Some(false), Some(false)),
|
|
||||||
(Some(true), Some(value)) => (Some(true), Some(value)),
|
|
||||||
(Some(true), None) => (Some(true), Some(true)),
|
|
||||||
(None, Some(value)) => (None, Some(value)),
|
|
||||||
(None, None) => (None, None),
|
|
||||||
};
|
|
||||||
|
|
||||||
let existing = sqlx::query_as::<_, Person>(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
FROM persons
|
|
||||||
WHERE id = $1
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let existing = match existing {
|
|
||||||
Some(person) => person,
|
|
||||||
None => return Err(ApiError::not_found("Personen hittades inte.")),
|
|
||||||
};
|
|
||||||
|
|
||||||
let parent_name = if parent_name_present {
|
|
||||||
parent_name
|
|
||||||
} else {
|
|
||||||
existing.parent_name.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let parent_phone_number = if parent_phone_present {
|
|
||||||
parent_phone_number
|
|
||||||
} else {
|
|
||||||
existing.parent_phone_number.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let grade = if grade_present { grade } else { existing.grade };
|
|
||||||
|
|
||||||
let desired_checked_in = final_checked_in.unwrap_or(existing.checked_in);
|
|
||||||
let desired_inside = final_inside.unwrap_or(existing.inside);
|
|
||||||
|
|
||||||
if (desired_checked_in || desired_inside)
|
|
||||||
&& !fields_are_complete(grade, &parent_name, &parent_phone_number)
|
|
||||||
{
|
|
||||||
return Err(ApiError::bad_request(
|
|
||||||
"Kontaktperson, telefon och klass krävs innan incheckning.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let person = sqlx::query_as::<_, Person>(
|
|
||||||
r#"
|
|
||||||
UPDATE persons
|
|
||||||
SET first_name = $2,
|
|
||||||
last_name = $3,
|
|
||||||
grade = $4,
|
|
||||||
parent_name = $5,
|
|
||||||
parent_phone_number = $6,
|
|
||||||
checked_in = COALESCE($7, checked_in),
|
|
||||||
inside = COALESCE($8, inside),
|
|
||||||
visitor = $9,
|
|
||||||
sleeping_spot = $10
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(&first_name)
|
|
||||||
.bind(&last_name)
|
|
||||||
.bind(grade)
|
|
||||||
.bind(parent_name.as_deref())
|
|
||||||
.bind(parent_phone_number.as_deref())
|
|
||||||
.bind(final_checked_in)
|
|
||||||
.bind(final_inside)
|
|
||||||
.bind(visitor)
|
|
||||||
.bind(sleeping_spot)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match person {
|
|
||||||
Some(person) => {
|
|
||||||
let response = PersonActionResponse {
|
|
||||||
person: person.into(),
|
|
||||||
};
|
|
||||||
broadcast_person_update(state, &response);
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
None => Err(ApiError::not_found("Personen hittades inte.")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_checked_in(
|
|
||||||
state: &State<AppState>,
|
|
||||||
id: i32,
|
|
||||||
value: bool,
|
|
||||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
|
||||||
let existing = sqlx::query_as::<_, Person>(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
FROM persons
|
|
||||||
WHERE id = $1
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let existing = match existing {
|
|
||||||
Some(person) => person,
|
|
||||||
None => return Err(ApiError::not_found("Personen hittades inte.")),
|
|
||||||
};
|
|
||||||
|
|
||||||
if value && !person_is_complete(&existing) {
|
|
||||||
return Err(ApiError::bad_request(
|
|
||||||
"Kontaktperson, telefon och klass krävs innan incheckning.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let person = sqlx::query_as::<_, Person>(
|
|
||||||
r#"
|
|
||||||
UPDATE persons
|
|
||||||
SET checked_in = $2,
|
|
||||||
inside = $2
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(value)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match person {
|
|
||||||
Some(person) => {
|
|
||||||
let response = PersonActionResponse {
|
|
||||||
person: person.into(),
|
|
||||||
};
|
|
||||||
broadcast_person_update(state, &response);
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
None => Err(ApiError::not_found("Personen hittades inte.")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_inside(
|
|
||||||
state: &State<AppState>,
|
|
||||||
id: i32,
|
|
||||||
value: bool,
|
|
||||||
) -> Result<Json<PersonActionResponse>, ApiError> {
|
|
||||||
let person = sqlx::query_as::<_, Person>(
|
|
||||||
r#"
|
|
||||||
UPDATE persons
|
|
||||||
SET inside = $2
|
|
||||||
WHERE id = $1
|
|
||||||
AND checked_in = TRUE
|
|
||||||
RETURNING
|
|
||||||
id,
|
|
||||||
first_name,
|
|
||||||
last_name,
|
|
||||||
grade,
|
|
||||||
parent_name,
|
|
||||||
parent_phone_number,
|
|
||||||
checked_in,
|
|
||||||
inside,
|
|
||||||
visitor,
|
|
||||||
sleeping_spot
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(id)
|
|
||||||
.bind(value)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match person {
|
|
||||||
Some(person) => {
|
|
||||||
let response = PersonActionResponse {
|
|
||||||
person: person.into(),
|
|
||||||
};
|
|
||||||
broadcast_person_update(state, &response);
|
|
||||||
Ok(Json(response))
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let checked =
|
|
||||||
sqlx::query_scalar::<_, bool>("SELECT checked_in FROM persons WHERE id = $1")
|
|
||||||
.bind(id)
|
|
||||||
.fetch_optional(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
match checked {
|
|
||||||
Some(_) => Err(ApiError::bad_request(
|
|
||||||
"Personen måste vara incheckad för att uppdatera inne/ute.",
|
|
||||||
)),
|
|
||||||
None => Err(ApiError::not_found("Personen hittades inte.")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn map_db_error(err: sqlx::Error, context: &str) -> ApiError {
|
|
||||||
if let sqlx::Error::Database(db_err) = &err {
|
|
||||||
if let Some(code) = db_err.code() {
|
|
||||||
if code == "23505" {
|
|
||||||
return ApiError::bad_request(
|
|
||||||
"Krock i databasen – kontrollera id eller kontaktuppgifter.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eprintln!("Database error ({}): {:?}", context, db_err);
|
|
||||||
return ApiError::internal("Databasfel.");
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("Database error ({}): {:?}", context, err);
|
|
||||||
ApiError::internal("Databasfel.")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn broadcast_person_update(state: &State<AppState>, response: &PersonActionResponse) {
|
|
||||||
let _ = state.event_sender.send(response.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn normalize_optional_string(value: &Option<String>) -> Option<String> {
|
|
||||||
value.as_ref().and_then(|text| {
|
|
||||||
let trimmed = text.trim();
|
|
||||||
if trimmed.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(trimmed.to_string())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fields_are_complete(
|
|
||||||
grade: Option<i32>,
|
|
||||||
parent_name: &Option<String>,
|
|
||||||
parent_phone_number: &Option<String>,
|
|
||||||
) -> bool {
|
|
||||||
grade.is_some()
|
|
||||||
&& parent_name
|
|
||||||
.as_ref()
|
|
||||||
.map(|value| !value.trim().is_empty())
|
|
||||||
.unwrap_or(false)
|
|
||||||
&& phone_is_valid(parent_phone_number)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn person_is_complete(person: &Person) -> bool {
|
|
||||||
fields_are_complete(
|
|
||||||
person.grade,
|
|
||||||
&person.parent_name,
|
|
||||||
&person.parent_phone_number,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn phone_is_valid(phone: &Option<String>) -> bool {
|
|
||||||
phone
|
|
||||||
.as_ref()
|
|
||||||
.map(|value| {
|
|
||||||
let digits: String = value.chars().filter(|c| c.is_ascii_digit()).collect();
|
|
||||||
digits.len() == 10
|
|
||||||
})
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
14
api/src/models/event.rs
Normal file
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>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, FromRow, Clone)]
|
||||||
pub struct Person {
|
pub struct Person {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
pub first_name: String,
|
pub first_name: String,
|
||||||
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';
|
import type { Person } from '$lib/types';
|
||||||
|
import { listenToEvents } from '$lib/client/event-stream';
|
||||||
export type PersonEvent = {
|
|
||||||
person: Person;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function listenToPersonEvents(onPerson: (person: Person) => void) {
|
export function listenToPersonEvents(onPerson: (person: Person) => void) {
|
||||||
let stopped = false;
|
return listenToEvents((event) => {
|
||||||
let source: EventSource | null = null;
|
if (event.type === 'person_updated') {
|
||||||
|
onPerson(event.person);
|
||||||
function connect() {
|
}
|
||||||
if (stopped) return;
|
});
|
||||||
source = new EventSource('/api/events');
|
|
||||||
source.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data) as PersonEvent;
|
|
||||||
if (data.person) {
|
|
||||||
onPerson(data.person);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to parse person event', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
source.onerror = () => {
|
|
||||||
source?.close();
|
|
||||||
source = null;
|
|
||||||
if (stopped) return;
|
|
||||||
setTimeout(connect, 2000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
stopped = true;
|
|
||||||
source?.close();
|
|
||||||
source = null;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
visitor: boolean;
|
||||||
sleeping_spot: boolean;
|
sleeping_spot: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TournamentSection {
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TournamentFieldType = 'text' | 'email' | 'tel' | 'discord';
|
||||||
|
|
||||||
|
export interface TournamentSignupField {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
field_type: TournamentFieldType;
|
||||||
|
required: boolean;
|
||||||
|
placeholder?: string | null;
|
||||||
|
unique: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentTeamSize {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentSignupConfig {
|
||||||
|
mode: 'solo' | 'team';
|
||||||
|
team_size: TournamentTeamSize;
|
||||||
|
entry_fields: TournamentSignupField[];
|
||||||
|
participant_fields: TournamentSignupField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentRegistrationItem {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
entry: Record<string, string>;
|
||||||
|
participants: Record<string, string>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentRegistrationList {
|
||||||
|
tournament: TournamentInfo;
|
||||||
|
registrations: TournamentRegistrationItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentRegistrationDetail {
|
||||||
|
tournament: TournamentInfo;
|
||||||
|
registration: TournamentRegistrationItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TournamentInfo {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
game: string;
|
||||||
|
slug: string;
|
||||||
|
tagline: string | null;
|
||||||
|
start_at: string | null;
|
||||||
|
location: string | null;
|
||||||
|
description: string | null;
|
||||||
|
registration_url: string;
|
||||||
|
contact: string | null;
|
||||||
|
sections: TournamentSection[];
|
||||||
|
signup_config: TournamentSignupConfig;
|
||||||
|
updated_at: string;
|
||||||
|
total_registrations: number;
|
||||||
|
total_participants: number;
|
||||||
|
}
|
||||||
|
|
|
||||||
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) {
|
async function apiFetch(url: string, init?: RequestInit) {
|
||||||
const response = await fetch(url, init);
|
const response = await fetch(url, init);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
await goto('/login');
|
await goto('/admin/login');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -33,7 +33,7 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||||
async function apiFetch(url: string) {
|
async function apiFetch(url: string) {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
await goto('/login');
|
await goto('/admin/login');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -301,17 +301,17 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if infoMessage && !errorMessage}
|
{#if infoMessage && !errorMessage}
|
||||||
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
|
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-4">
|
{#if persons.length > 0}
|
||||||
{#each persons as person}
|
<div class="mt-6 space-y-4">
|
||||||
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
{#each persons as person}
|
||||||
|
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
|
<h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
|
||||||
|
|
@ -361,7 +361,9 @@ type VisitorFilter = 'all' | 'besoksplats' | 'lanplats';
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EditPersonModal
|
<EditPersonModal
|
||||||
|
|
@ -61,7 +61,7 @@
|
||||||
async function apiFetch(url: string, init?: RequestInit) {
|
async function apiFetch(url: string, init?: RequestInit) {
|
||||||
const response = await fetch(url, init);
|
const response = await fetch(url, init);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
await goto('/login');
|
await goto('/admin/login');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
async function apiFetch(url: string, init?: RequestInit) {
|
async function apiFetch(url: string, init?: RequestInit) {
|
||||||
const response = await fetch(url, init);
|
const response = await fetch(url, init);
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
await goto('/login');
|
await goto('/admin/login');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
|
|
@ -222,22 +222,22 @@
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
<p class="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-600">{errorMessage}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if infoMessage && !errorMessage}
|
{#if infoMessage && !errorMessage}
|
||||||
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
|
<p class="mt-4 rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-700">{infoMessage}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{#if actionMessage && !errorMessage}
|
{#if actionMessage && !errorMessage}
|
||||||
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
<p class="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-700">
|
||||||
{actionMessage}
|
{actionMessage}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-4">
|
{#if persons.length > 0}
|
||||||
{#each persons as person}
|
<div class="mt-6 space-y-4">
|
||||||
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
{#each persons as person}
|
||||||
|
<article class="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
|
<h3 class="text-base font-semibold text-slate-800">{fullName(person)}</h3>
|
||||||
|
|
@ -290,7 +290,9 @@
|
||||||
{person.inside ? 'Markera ute' : 'Markera inne'}
|
{person.inside ? 'Markera ute' : 'Markera inne'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await goto('/', { invalidateAll: true });
|
await goto('/admin', { invalidateAll: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Login failed', err);
|
console.error('Login failed', err);
|
||||||
errorMessage = 'Ett oväntat fel inträffade. Försök igen.';
|
errorMessage = 'Ett oväntat fel inträffade. Försök igen.';
|
||||||
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">
|
<script lang="ts">
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
|
||||||
|
|
||||||
const props = $props();
|
const props = $props();
|
||||||
const children = $derived(props.children);
|
const children = $derived(props.children);
|
||||||
const data = $derived(props.data);
|
|
||||||
let ui = $state({
|
|
||||||
loggedIn: false,
|
|
||||||
loggingOut: false,
|
|
||||||
message: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
ui.loggedIn = Boolean(data?.isLoggedIn);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleLogout() {
|
|
||||||
if (ui.loggingOut) return;
|
|
||||||
ui.loggingOut = true;
|
|
||||||
ui.message = '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/logout', { method: 'POST' });
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.json().catch(() => ({}));
|
|
||||||
ui.message = body.message ?? 'Kunde inte logga ut. Försök igen.';
|
|
||||||
} else {
|
|
||||||
ui.loggedIn = false;
|
|
||||||
await goto('/login', { invalidateAll: true });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Logout failed', err);
|
|
||||||
ui.message = 'Ett oväntat fel uppstod vid utloggning.';
|
|
||||||
} finally {
|
|
||||||
ui.loggingOut = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
{@render children?.()}
|
||||||
<link rel="icon" href={favicon} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="min-h-screen bg-slate-100 text-slate-900">
|
|
||||||
<header class="border-b border-slate-200 bg-white">
|
|
||||||
<div class="mx-auto flex max-w-5xl flex-col gap-4 px-4 py-4">
|
|
||||||
<div class="flex items-start justify-between gap-3 sm:items-center">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium tracking-wide text-slate-500 uppercase">VBytes</p>
|
|
||||||
<h1 class="text-xl font-semibold text-slate-900">Gästhantering</h1>
|
|
||||||
</div>
|
|
||||||
{#if ui.loggedIn}
|
|
||||||
<button
|
|
||||||
onclick={handleLogout}
|
|
||||||
disabled={ui.loggingOut}
|
|
||||||
class="rounded-md border border-slate-300 px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60"
|
|
||||||
>
|
|
||||||
{ui.loggingOut ? 'Loggar ut…' : 'Logga ut'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if ui.loggedIn}
|
|
||||||
<nav class="flex flex-wrap items-center justify-center gap-2 sm:justify-between">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
$page.url.pathname === '/'
|
|
||||||
? 'bg-indigo-600 text-white shadow'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Checka in
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/checkout"
|
|
||||||
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
$page.url.pathname === '/checkout'
|
|
||||||
? 'bg-indigo-600 text-white shadow'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Checka ut
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/create"
|
|
||||||
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
$page.url.pathname === '/create'
|
|
||||||
? 'bg-indigo-600 text-white shadow'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Lägg till
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/inside-status"
|
|
||||||
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
$page.url.pathname === '/inside-status'
|
|
||||||
? 'bg-indigo-600 text-white shadow'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Inne/ute
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/checked-in"
|
|
||||||
class={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
$page.url.pathname === '/checked-in'
|
|
||||||
? 'bg-indigo-600 text-white shadow'
|
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Översikt
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if ui.message}
|
|
||||||
<p class="bg-red-50 px-4 py-2 text-center text-sm text-red-600">{ui.message}</p>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
<main class="mx-auto max-w-5xl px-4 py-6">
|
|
||||||
{@render children?.()}
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { AUTH_COOKIE_NAME } from '$lib/server/config';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies }) => {
|
export const load: PageServerLoad = () => {
|
||||||
if (!cookies.get(AUTH_COOKIE_NAME)) {
|
throw redirect(302, '/tournament');
|
||||||
throw redirect(302, '/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
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