Compare commits

..

No commits in common. "af4dbdea910c9a8e3c893edf700143ab1e152479" and "687c3943d41f8ef8cc9f2c7d6ac5e11fc832c889" have entirely different histories.

12 changed files with 880 additions and 1716 deletions

2
.env
View file

@ -5,4 +5,4 @@ ADMIN_PASSWORD=admin
JWT_COOKIE_SECURE=false JWT_COOKIE_SECURE=false
ENABLE_HTTPS_REDIRECT=false ENABLE_HTTPS_REDIRECT=false
WEB_PORT=3000 WEB_PORT=3000
CSRF_ALLOWED_ORIGINS=http://localhost:3000 CSRF_ALLOWED_ORIGINS=http://192.168.1.204:3000

View file

@ -64,7 +64,7 @@ async fn main() -> Result<(), rocket::Error> {
let rocket = rocket::build() let rocket = rocket::build()
.manage(state) .manage(state)
.mount("/", routes![healthz, login, logout, events, public_events]) .mount("/", routes![healthz, login, logout, events])
.mount("/persons", routes::persons::routes()) .mount("/persons", routes::persons::routes())
.mount("/tournament", routes::tournaments::routes()); .mount("/tournament", routes::tournaments::routes());
@ -157,25 +157,3 @@ fn events(_user: AuthUser, state: &State<AppState>) -> EventStream![Event + '_]
} }
} }
} }
#[get("/events/public")]
fn public_events(state: &State<AppState>) -> EventStream![Event + '_] {
let mut receiver = state.event_sender.subscribe();
EventStream! {
loop {
match receiver.recv().await {
Ok(event) => {
match &event {
AppEvent::TournamentUpserted { .. } | AppEvent::TournamentDeleted { .. } => {
yield Event::json(&event);
}
_ => continue,
}
}
Err(RecvError::Closed) => break,
Err(RecvError::Lagged(_)) => continue,
}
}
}
}

View file

@ -4,10 +4,6 @@ use sqlx::FromRow;
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::HashSet; use std::collections::HashSet;
pub const ATTENDANCE_ID_FIELD_ID: &str = "attendance-id";
pub const ATTENDANCE_ID_FIELD_LABEL: &str = "Deltagar-ID";
pub const ATTENDANCE_ID_FIELD_PLACEHOLDER: &str = "Ange ditt deltagar-ID från närvarolistan";
#[derive(Debug, FromRow, Clone)] #[derive(Debug, FromRow, Clone)]
pub struct TournamentInfo { pub struct TournamentInfo {
pub id: i32, pub id: i32,
@ -178,7 +174,6 @@ impl TournamentSignupConfig {
self.entry_fields = normalize_signup_fields(self.entry_fields); self.entry_fields = normalize_signup_fields(self.entry_fields);
self.participant_fields = normalize_signup_fields(self.participant_fields); self.participant_fields = normalize_signup_fields(self.participant_fields);
ensure_attendance_field_for_mode(&mut self);
self self
} }
@ -218,92 +213,6 @@ fn normalize_signup_fields(mut fields: Vec<TournamentSignupField>) -> Vec<Tourna
fields fields
} }
fn remove_attendance_id_field(
fields: &mut Vec<TournamentSignupField>,
) -> Option<TournamentSignupField> {
let mut attendance_index = None;
for (index, field) in fields.iter().enumerate() {
if field.id == ATTENDANCE_ID_FIELD_ID {
attendance_index = Some(index);
break;
}
}
attendance_index.map(|index| {
let mut field = fields.remove(index);
sanitize_attendance_id_field(&mut field);
field
})
}
fn insert_attendance_field_front(
fields: &mut Vec<TournamentSignupField>,
mut field: TournamentSignupField,
) {
sanitize_attendance_id_field(&mut field);
fields.insert(0, field);
}
fn ensure_attendance_field_for_mode(config: &mut TournamentSignupConfig) {
let mut attendance = remove_attendance_id_field(&mut config.entry_fields)
.or_else(|| remove_attendance_id_field(&mut config.participant_fields))
.unwrap_or_else(default_attendance_id_field);
if config.mode == "team" {
config
.entry_fields
.retain(|field| field.id != ATTENDANCE_ID_FIELD_ID);
config
.participant_fields
.retain(|field| field.id != ATTENDANCE_ID_FIELD_ID);
insert_attendance_field_front(&mut config.participant_fields, attendance);
} else {
config
.entry_fields
.retain(|field| field.id != ATTENDANCE_ID_FIELD_ID);
config
.participant_fields
.retain(|field| field.id != ATTENDANCE_ID_FIELD_ID);
insert_attendance_field_front(&mut config.entry_fields, attendance);
}
}
fn sanitize_attendance_id_field(field: &mut TournamentSignupField) {
field.id = ATTENDANCE_ID_FIELD_ID.to_string();
field.label = sanitize_attendance_label(&field.label);
field.field_type = TournamentFieldType::Text;
field.required = true;
field.unique = true;
field.placeholder = Some(
field
.placeholder
.as_ref()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.unwrap_or_else(|| ATTENDANCE_ID_FIELD_PLACEHOLDER.to_string()),
);
}
fn sanitize_attendance_label(label: &str) -> String {
let trimmed = label.trim();
if trimmed.is_empty() {
ATTENDANCE_ID_FIELD_LABEL.to_string()
} else {
trimmed.to_string()
}
}
fn default_attendance_id_field() -> TournamentSignupField {
TournamentSignupField {
id: ATTENDANCE_ID_FIELD_ID.to_string(),
label: ATTENDANCE_ID_FIELD_LABEL.to_string(),
field_type: TournamentFieldType::Text,
required: true,
placeholder: Some(ATTENDANCE_ID_FIELD_PLACEHOLDER.to_string()),
unique: true,
}
}
fn normalize_field_id(input: &str) -> String { fn normalize_field_id(input: &str) -> String {
let mut slug = input let mut slug = input
.trim() .trim()

View file

@ -1,14 +1,13 @@
use crate::auth::AuthUser; use crate::auth::AuthUser;
use crate::error::ApiError; use crate::error::ApiError;
use crate::models::{ use crate::models::{
AppEvent, CreateTournamentRequest, Person, TournamentFieldType, TournamentInfo, AppEvent, CreateTournamentRequest, TournamentFieldType, TournamentInfo, TournamentInfoData,
TournamentInfoData, TournamentItemResponse, TournamentListResponse, TournamentParticipantRow, TournamentItemResponse, TournamentListResponse, TournamentParticipantRow,
TournamentParticipantValueRow, TournamentRegistrationDetailResponse, TournamentParticipantValueRow, TournamentRegistrationDetailResponse,
TournamentRegistrationItem, TournamentRegistrationListResponse, TournamentRegistrationResponse, TournamentRegistrationItem, TournamentRegistrationListResponse, TournamentRegistrationResponse,
TournamentRegistrationRow, TournamentRegistrationValueRow, TournamentSection, TournamentRegistrationRow, TournamentRegistrationValueRow, TournamentSection,
TournamentSectionRecord, TournamentSignupConfig, TournamentSignupField, TournamentSectionRecord, TournamentSignupConfig, TournamentSignupField,
TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest, TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest,
ATTENDANCE_ID_FIELD_ID,
}; };
use crate::AppState; use crate::AppState;
use rocket::http::Status; use rocket::http::Status;
@ -16,7 +15,7 @@ use rocket::serde::json::Json;
use rocket::Route; use rocket::Route;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use sqlx::{Postgres, Transaction}; use sqlx::{Postgres, Transaction};
use std::collections::{HashMap, HashSet}; use std::collections::HashMap;
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
rocket::routes![ rocket::routes![
@ -108,46 +107,6 @@ fn build_registration_url(slug: &str) -> String {
format!("/tournament/{slug}") format!("/tournament/{slug}")
} }
fn first_non_attendance_entry_value(
fields: &[TournamentSignupField],
values: &HashMap<String, String>,
) -> Option<String> {
for field in fields {
if field.id == ATTENDANCE_ID_FIELD_ID {
continue;
}
if let Some(value) = values.get(&field.id) {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
fn build_entry_label(
attendance_id: i32,
person: &Person,
config: &TournamentSignupConfig,
entry_values: &HashMap<String, String>,
) -> Option<String> {
let primary = first_non_attendance_entry_value(&config.entry_fields, entry_values)
.unwrap_or_else(|| {
let first_name = person.first_name.trim();
let last_name = person.last_name.trim();
let full = format!("{first_name} {last_name}");
full.trim().to_string()
});
let label = format!("{attendance_id} {primary}").trim().to_string();
if label.is_empty() {
None
} else {
Some(label)
}
}
fn signup_fields_equal(left: &[TournamentSignupField], right: &[TournamentSignupField]) -> bool { fn signup_fields_equal(left: &[TournamentSignupField], right: &[TournamentSignupField]) -> bool {
if left.len() != right.len() { if left.len() != right.len() {
return false; return false;
@ -260,7 +219,6 @@ fn build_signup_config(
entry_fields, entry_fields,
participant_fields, participant_fields,
} }
.normalized()
} }
async fn load_tournament_data( async fn load_tournament_data(
@ -1104,6 +1062,12 @@ fn validate_submission(
Ok(()) Ok(())
} }
fn trimmed(value: Option<&String>) -> Option<String> {
value
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
}
#[rocket::post("/slug/<slug>/signup", data = "<payload>")] #[rocket::post("/slug/<slug>/signup", data = "<payload>")]
pub async fn create_registration_by_slug( pub async fn create_registration_by_slug(
state: &rocket::State<AppState>, state: &rocket::State<AppState>,
@ -1172,128 +1136,41 @@ pub async fn create_registration_by_slug(
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
if !field_map.contains_key(ATTENDANCE_ID_FIELD_ID) { let entry_label = config
.entry_fields
.first()
.and_then(|field| entry_values.get(&field.id))
.and_then(|value| trimmed(Some(value)));
let entry_label_requires_unique = config
.entry_fields
.first()
.map(|field| field.unique)
.unwrap_or(false);
if entry_label_requires_unique {
if let Some(label) = entry_label.clone() {
let is_duplicate = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS (
SELECT 1
FROM tournament_registrations
WHERE tournament_id = $1 AND entry_label = $2
)
"#,
)
.bind(info.id)
.bind(&label)
.fetch_one(&state.db)
.await?;
if is_duplicate {
return Err(ApiError::bad_request( return Err(ApiError::bad_request(
"Turneringen är felkonfigurerad och saknar obligatoriskt deltagar-ID-fält.", "Den här spelaren eller laget är redan anmäld till turneringen.",
)); ));
} }
let is_team = config.mode == "team";
let mut lead_participant: Option<(i32, Person)> = None;
if is_team {
entry_values.remove(ATTENDANCE_ID_FIELD_ID);
let mut seen_attendance_ids: HashSet<i32> = HashSet::new();
for (index, values) in participant_values.iter_mut().enumerate() {
let attendance_raw = values
.get(ATTENDANCE_ID_FIELD_ID)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
ApiError::bad_request(format!(
"Spelare #{number}: ange deltagar-ID från närvarolistan.",
number = index + 1
))
})?;
let attendance_id: i32 = attendance_raw.parse().map_err(|_| {
ApiError::bad_request(format!(
"Spelare #{number}: deltagar-ID måste vara ett heltal från närvarolistan.",
number = index + 1
))
})?;
if !seen_attendance_ids.insert(attendance_id) {
return Err(ApiError::bad_request(format!(
"Spelare #{number}: deltagar-ID används redan av en annan spelare i laget.",
number = index + 1
)));
}
let person = 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(attendance_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
ApiError::bad_request(format!(
"Spelare #{number}: deltagar-ID:t finns inte i närvarolistan.",
number = index + 1
))
})?;
values.insert(
ATTENDANCE_ID_FIELD_ID.to_string(),
attendance_id.to_string(),
);
if lead_participant.is_none() {
lead_participant = Some((attendance_id, person.clone()));
} }
} }
} else {
let attendance_id_value = entry_values
.get(ATTENDANCE_ID_FIELD_ID)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| ApiError::bad_request("Ange ditt deltagar-ID från närvarolistan."))?;
let attendance_id: i32 = attendance_id_value.parse().map_err(|_| {
ApiError::bad_request("Deltagar-ID måste vara ett heltal från närvarolistan.")
})?;
let person = 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(attendance_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
ApiError::bad_request("Det angivna deltagar-ID:t finns inte i närvarolistan.")
})?;
entry_values.insert(
ATTENDANCE_ID_FIELD_ID.to_string(),
attendance_id.to_string(),
);
lead_participant = Some((attendance_id, person));
}
let (lead_attendance_id, lead_person) = lead_participant.ok_or_else(|| {
ApiError::bad_request("Minst en deltagare med deltagar-ID krävs för anmälan.")
})?;
let entry_label = build_entry_label(lead_attendance_id, &lead_person, &config, &entry_values);
for field in &config.entry_fields { for field in &config.entry_fields {
if !field.unique { if !field.unique {
@ -1725,128 +1602,44 @@ pub async fn update_registration_by_slug(
let mut tx = state.db.begin().await?; let mut tx = state.db.begin().await?;
if !field_map.contains_key(ATTENDANCE_ID_FIELD_ID) { let entry_label = config
.entry_fields
.first()
.and_then(|field| entry_values.get(&field.id))
.and_then(|value| trimmed(Some(value)));
let entry_label_requires_unique = config
.entry_fields
.first()
.map(|field| field.unique)
.unwrap_or(false);
if entry_label_requires_unique {
if let Some(label) = entry_label.clone() {
let is_duplicate = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS (
SELECT 1
FROM tournament_registrations
WHERE tournament_id = $1
AND entry_label = $2
AND id <> $3
)
"#,
)
.bind(info.id)
.bind(&label)
.bind(registration.id)
.fetch_one(&state.db)
.await?;
if is_duplicate {
return Err(ApiError::bad_request( return Err(ApiError::bad_request(
"Turneringen är felkonfigurerad och saknar obligatoriskt deltagar-ID-fält.", "Den här spelaren eller laget är redan anmäld till turneringen.",
)); ));
} }
let is_team = config.mode == "team";
let mut lead_participant: Option<(i32, Person)> = None;
if is_team {
entry_values.remove(ATTENDANCE_ID_FIELD_ID);
let mut seen_attendance_ids: HashSet<i32> = HashSet::new();
for (index, values) in participant_values.iter_mut().enumerate() {
let attendance_raw = values
.get(ATTENDANCE_ID_FIELD_ID)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| {
ApiError::bad_request(format!(
"Spelare #{number}: ange deltagar-ID från närvarolistan.",
number = index + 1
))
})?;
let attendance_id: i32 = attendance_raw.parse().map_err(|_| {
ApiError::bad_request(format!(
"Spelare #{number}: deltagar-ID måste vara ett heltal från närvarolistan.",
number = index + 1
))
})?;
if !seen_attendance_ids.insert(attendance_id) {
return Err(ApiError::bad_request(format!(
"Spelare #{number}: deltagar-ID används redan av en annan spelare i laget.",
number = index + 1
)));
}
let person = 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(attendance_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
ApiError::bad_request(format!(
"Spelare #{number}: deltagar-ID:t finns inte i närvarolistan.",
number = index + 1
))
})?;
values.insert(
ATTENDANCE_ID_FIELD_ID.to_string(),
attendance_id.to_string(),
);
if lead_participant.is_none() {
lead_participant = Some((attendance_id, person.clone()));
} }
} }
} else {
let attendance_id_value = entry_values
.get(ATTENDANCE_ID_FIELD_ID)
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.ok_or_else(|| ApiError::bad_request("Ange ditt deltagar-ID från närvarolistan."))?;
let attendance_id: i32 = attendance_id_value.parse().map_err(|_| {
ApiError::bad_request("Deltagar-ID måste vara ett heltal från närvarolistan.")
})?;
let person = 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(attendance_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
ApiError::bad_request("Det angivna deltagar-ID:t finns inte i närvarolistan.")
})?;
entry_values.insert(
ATTENDANCE_ID_FIELD_ID.to_string(),
attendance_id.to_string(),
);
lead_participant = Some((attendance_id, person));
}
let (lead_attendance_id, lead_person) = lead_participant.ok_or_else(|| {
ApiError::bad_request("Minst en deltagare med deltagar-ID krävs för anmälan.")
})?;
let entry_label = build_entry_label(lead_attendance_id, &lead_person, &config, &entry_values);
for field in &config.entry_fields { for field in &config.entry_fields {
if !field.unique { if !field.unique {

View file

@ -8,7 +8,6 @@
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"dotenv": "^16.4.5",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
@ -211,8 +210,6 @@
"devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="], "devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="],
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], "esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],

View file

@ -5,13 +5,13 @@ export type AppEvent =
| { type: 'tournament_upserted'; tournament: TournamentInfo } | { type: 'tournament_upserted'; tournament: TournamentInfo }
| { type: 'tournament_deleted'; tournament_id: number }; | { type: 'tournament_deleted'; tournament_id: number };
export function listenToEvents(onEvent: (event: AppEvent) => void, endpoint = '/api/events') { export function listenToEvents(onEvent: (event: AppEvent) => void) {
let stopped = false; let stopped = false;
let source: EventSource | null = null; let source: EventSource | null = null;
function connect() { function connect() {
if (stopped) return; if (stopped) return;
source = new EventSource(endpoint); source = new EventSource('/api/events');
source.onmessage = (event) => { source.onmessage = (event) => {
try { try {
const data = JSON.parse(event.data) as AppEvent; const data = JSON.parse(event.data) as AppEvent;

View file

@ -5,16 +5,10 @@ export type TournamentEvent =
| { type: 'tournament_upserted'; tournament: TournamentInfo } | { type: 'tournament_upserted'; tournament: TournamentInfo }
| { type: 'tournament_deleted'; tournament_id: number }; | { type: 'tournament_deleted'; tournament_id: number };
type TournamentEventOptions = {
endpoint?: string;
};
export function listenToTournamentEvents( export function listenToTournamentEvents(
onUpsert: (tournament: TournamentInfo) => void, onUpsert: (tournament: TournamentInfo) => void,
onDelete: (tournamentId: number) => void, onDelete: (tournamentId: number) => void
options: TournamentEventOptions = {}
) { ) {
const endpoint = options.endpoint ?? '/api/events';
return listenToEvents((event) => { return listenToEvents((event) => {
if (event.type === 'tournament_upserted') { if (event.type === 'tournament_upserted') {
onUpsert(event.tournament); onUpsert(event.tournament);
@ -23,5 +17,5 @@ export function listenToTournamentEvents(
if (event.type === 'tournament_deleted') { if (event.type === 'tournament_deleted') {
onDelete(event.tournament_id); onDelete(event.tournament_id);
} }
}, endpoint); });
} }

View file

@ -20,25 +20,6 @@
placeholder: string; placeholder: string;
}; };
const ATTENDANCE_FIELD_ID = 'attendance-id';
const ATTENDANCE_FIELD_LABEL = 'Deltagar-ID';
const ATTENDANCE_FIELD_PLACEHOLDER = 'Ange ditt deltagar-ID';
function isAttendanceField(field: { id: string }): boolean {
return field.id === ATTENDANCE_FIELD_ID;
}
function createAttendanceField(): SignupFieldForm {
return {
id: ATTENDANCE_FIELD_ID,
label: ATTENDANCE_FIELD_LABEL,
field_type: 'text',
required: true,
placeholder: ATTENDANCE_FIELD_PLACEHOLDER,
unique: true
};
}
type SignupConfigForm = { type SignupConfigForm = {
mode: 'solo' | 'team'; mode: 'solo' | 'team';
team_size: { min: number; max: number }; team_size: { min: number; max: number };
@ -93,10 +74,7 @@
return `${base}-${fieldIdCounter.toString(36)}`; return `${base}-${fieldIdCounter.toString(36)}`;
} }
function createSignupField( function createSignupField(label: string, field_type: TournamentFieldType = 'text'): SignupFieldForm {
label: string,
field_type: TournamentFieldType = 'text'
): SignupFieldForm {
return { return {
id: nextFieldId(label), id: nextFieldId(label),
label, label,
@ -108,18 +86,6 @@
} }
function cloneSignupField(field: TournamentSignupField): SignupFieldForm { function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
if (field.id === ATTENDANCE_FIELD_ID) {
const label = field.label?.trim() || ATTENDANCE_FIELD_LABEL;
const placeholder = (field.placeholder ?? '').trim() || ATTENDANCE_FIELD_PLACEHOLDER;
return {
id: ATTENDANCE_FIELD_ID,
label,
field_type: 'text',
required: true,
placeholder,
unique: true
};
}
return { return {
id: field.id || nextFieldId(field.label), id: field.id || nextFieldId(field.label),
label: field.label, label: field.label,
@ -130,126 +96,38 @@
}; };
} }
function sanitizeAttendanceField(field?: SignupFieldForm | null): SignupFieldForm {
const label = field?.label?.trim() || ATTENDANCE_FIELD_LABEL;
const placeholder = field?.placeholder?.trim() || ATTENDANCE_FIELD_PLACEHOLDER;
return {
id: ATTENDANCE_FIELD_ID,
label,
field_type: 'text',
required: true,
placeholder,
unique: true
};
}
function removeAttendanceField(fields: SignupFieldForm[]): {
fields: SignupFieldForm[];
attendance: SignupFieldForm | null;
} {
const index = fields.findIndex(isAttendanceField);
if (index === -1) {
return { fields: [...fields], attendance: null };
}
const next = [...fields];
const [field] = next.splice(index, 1);
return { fields: next, attendance: sanitizeAttendanceField(field) };
}
function ensureAttendancePlacement(signup: SignupConfigForm) {
const entryResult = removeAttendanceField(signup.entry_fields);
const participantResult = removeAttendanceField(signup.participant_fields);
let attendance =
entryResult.attendance ?? participantResult.attendance ?? createAttendanceField();
if (signup.mode === 'team') {
const participantFields = [...participantResult.fields];
if (!participantFields.some((field) => !isAttendanceField(field))) {
participantFields.push(createSignupField('Spelarinfo'));
}
signup.entry_fields = [...entryResult.fields];
signup.participant_fields = [sanitizeAttendanceField(attendance), ...participantFields];
} else {
const entryFields = [...entryResult.fields];
if (!entryFields.some((field) => !isAttendanceField(field))) {
entryFields.push(createSignupField('Spelarnamn'));
}
signup.entry_fields = [sanitizeAttendanceField(attendance), ...entryFields];
signup.participant_fields = participantResult.fields.filter(
(field) => !isAttendanceField(field)
);
}
const firstEntry = signup.entry_fields.find((field) => !isAttendanceField(field));
if (firstEntry) {
const trimmed = firstEntry.label.trim();
firstEntry.label =
trimmed && trimmed.toLowerCase() !== 'spelarnamn' && trimmed.toLowerCase() !== 'lag namn'
? trimmed
: signup.mode === 'team'
? 'Lag namn'
: 'Spelarnamn';
const placeholder = firstEntry.placeholder.trim();
firstEntry.placeholder =
placeholder && placeholder.toLowerCase() !== 'spelarnamn'
? placeholder
: signup.mode === 'team'
? 'Lag namn'
: 'Spelarnamn';
}
}
function createDefaultSignup(): SignupConfigForm { function createDefaultSignup(): SignupConfigForm {
const signup: SignupConfigForm = { return {
mode: 'solo', mode: 'solo',
team_size: { min: 1, max: 1 }, team_size: { min: 1, max: 1 },
entry_fields: [createSignupField('Spelarnamn')], entry_fields: [createSignupField('Lag / spelarnamn')],
participant_fields: [] participant_fields: []
}; };
ensureAttendancePlacement(signup);
return signup;
} }
function cloneSignupConfig(config: TournamentSignupConfig | null | undefined): SignupConfigForm { function cloneSignupConfig(config: TournamentSignupConfig | null | undefined): SignupConfigForm {
if (!config) return createDefaultSignup(); if (!config) return createDefaultSignup();
const entry_fields = (config.entry_fields ?? []).map(cloneSignupField);
const normalizedSize = Math.max( const participant_fields = (config.participant_fields ?? []).map(cloneSignupField);
1, if (entry_fields.length === 0) {
Math.floor( entry_fields.push(createSignupField('Lag/Spelarnamn'));
config.team_size?.max ?? }
config.team_size?.min ?? if (participant_fields.length === 0) {
1 participant_fields.push(createSignupField('Spelare'));
) }
); const mode = config.mode === 'team' ? 'team' : 'solo';
if (mode === 'solo') {
const signup: SignupConfigForm = { participant_fields.length = 0;
mode: config.mode === 'team' ? 'team' : 'solo', }
return {
mode,
team_size: { team_size: {
min: normalizedSize, min: config.team_size?.min ?? 1,
max: normalizedSize max: config.team_size?.max ?? 1
}, },
entry_fields: (config.entry_fields ?? []).map(cloneSignupField), entry_fields,
participant_fields: (config.participant_fields ?? []).map(cloneSignupField) participant_fields
}; };
ensureAttendancePlacement(signup);
if (
signup.mode === 'team' &&
!signup.participant_fields.some((field) => !isAttendanceField(field))
) {
signup.participant_fields = [...signup.participant_fields, createSignupField('Spelarinfo')];
ensureAttendancePlacement(signup);
}
if (signup.mode !== 'team' && !signup.entry_fields.some((field) => !isAttendanceField(field))) {
signup.entry_fields = [...signup.entry_fields, createSignupField('Spelarnamn')];
ensureAttendancePlacement(signup);
}
return signup;
} }
function signupFieldKey(index: number, field?: SignupFieldForm) { function signupFieldKey(index: number, field?: SignupFieldForm) {
@ -259,14 +137,11 @@ function cloneSignupConfig(config: TournamentSignupConfig | null | undefined): S
function addEntryField(form: TournamentForm) { function addEntryField(form: TournamentForm) {
form.signup.entry_fields = [...form.signup.entry_fields, createSignupField('Nytt fält')]; form.signup.entry_fields = [...form.signup.entry_fields, createSignupField('Nytt fält')];
ensureAttendancePlacement(form.signup);
} }
function removeEntryField(form: TournamentForm, index: number) { function removeEntryField(form: TournamentForm, index: number) {
const target = form.signup.entry_fields[index]; if (form.signup.entry_fields.length <= 1) return;
if (!target || isAttendanceField(target)) return;
form.signup.entry_fields = form.signup.entry_fields.filter((_, idx) => idx !== index); form.signup.entry_fields = form.signup.entry_fields.filter((_, idx) => idx !== index);
ensureAttendancePlacement(form.signup);
} }
function addParticipantField(form: TournamentForm) { function addParticipantField(form: TournamentForm) {
@ -274,42 +149,35 @@ function cloneSignupConfig(config: TournamentSignupConfig | null | undefined): S
...form.signup.participant_fields, ...form.signup.participant_fields,
createSignupField('Spelarinfo') createSignupField('Spelarinfo')
]; ];
ensureAttendancePlacement(form.signup);
} }
function removeParticipantField(form: TournamentForm, index: number) { function removeParticipantField(form: TournamentForm, index: number) {
const target = form.signup.participant_fields[index]; if (form.signup.participant_fields.length <= 1) return;
if (!target || isAttendanceField(target)) return; form.signup.participant_fields = form.signup.participant_fields.filter((_, idx) => idx !== index);
form.signup.participant_fields = form.signup.participant_fields.filter(
(_, idx) => idx !== index
);
ensureAttendancePlacement(form.signup);
} }
function setSignupMode(form: TournamentForm, mode: 'solo' | 'team') { function setSignupMode(form: TournamentForm, mode: 'solo' | 'team') {
form.signup.mode = mode; form.signup.mode = mode;
if (mode === 'solo') { if (mode === 'solo') {
setTeamSize(form, '1'); form.signup.team_size.min = 1;
form.signup.team_size.max = 1;
form.signup.participant_fields = []; form.signup.participant_fields = [];
} else { } else {
const currentSize = Math.max( if (form.signup.team_size.min < 1) form.signup.team_size.min = 1;
1, if (form.signup.team_size.max < form.signup.team_size.min) {
Number.isFinite(form.signup.team_size.min) ? form.signup.team_size.min : 1, form.signup.team_size.max = form.signup.team_size.min;
Number.isFinite(form.signup.team_size.max) ? form.signup.team_size.max : 1 }
);
setTeamSize(form, String(currentSize));
if (form.signup.participant_fields.length === 0) { if (form.signup.participant_fields.length === 0) {
form.signup.participant_fields = [createSignupField('Spelare')]; form.signup.participant_fields = [createSignupField('Spelare')];
} }
} }
ensureAttendancePlacement(form.signup);
} }
function setTeamSize(form: TournamentForm, value: string) { function setTeamSize(form: TournamentForm, key: 'min' | 'max', value: string) {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
const numeric = Number.isNaN(parsed) ? 1 : Math.max(1, parsed); const fallback = key === 'min' ? 1 : form.signup.team_size.min;
form.signup.team_size.min = numeric; const numeric = Number.isNaN(parsed) ? fallback : Math.max(1, parsed);
form.signup.team_size.max = numeric; form.signup.team_size[key] = numeric;
} }
function slugify(value: string) { function slugify(value: string) {
@ -377,19 +245,6 @@ function setTeamSize(form: TournamentForm, value: string) {
const allowedTypes: TournamentFieldType[] = ['text', 'email', 'tel', 'discord']; const allowedTypes: TournamentFieldType[] = ['text', 'email', 'tel', 'discord'];
const normalizeField = (field: SignupFieldForm): TournamentSignupField => { const normalizeField = (field: SignupFieldForm): TournamentSignupField => {
if (isAttendanceField(field)) {
const label = field.label.trim() || ATTENDANCE_FIELD_LABEL;
const placeholder = field.placeholder.trim() || ATTENDANCE_FIELD_PLACEHOLDER;
return {
id: ATTENDANCE_FIELD_ID,
label,
field_type: 'text',
required: true,
placeholder,
unique: true
};
}
const baseId = field.id.trim() || field.label; const baseId = field.id.trim() || field.label;
const id = slugify(baseId) || 'field'; const id = slugify(baseId) || 'field';
const label = field.label.trim() || 'Fält'; const label = field.label.trim() || 'Fält';
@ -402,7 +257,7 @@ function setTeamSize(form: TournamentForm, value: string) {
field_type: type, field_type: type,
required: Boolean(field.required), required: Boolean(field.required),
placeholder: placeholder ? placeholder : null, placeholder: placeholder ? placeholder : null,
unique: Boolean(field.unique) unique: Boolean(field.unique),
}; };
}; };
@ -414,28 +269,23 @@ function setTeamSize(form: TournamentForm, value: string) {
}; };
const mode = signup.mode === 'team' ? 'team' : 'solo'; const mode = signup.mode === 'team' ? 'team' : 'solo';
let teamSize = toNumber(signup.team_size.min); let min = toNumber(signup.team_size.min);
if (mode === 'team') { let max = toNumber(signup.team_size.max);
teamSize = Math.max(teamSize, toNumber(signup.team_size.max)); if (max < min) max = min;
} else { if (mode === 'solo') {
teamSize = 1; min = 1;
max = 1;
} }
const draft: SignupConfigForm = { const entry_fields = signup.entry_fields.map(normalizeField);
mode, const participant_fields =
team_size: { min: teamSize, max: teamSize }, mode === 'team'
entry_fields: signup.entry_fields.map((field) => ({ ...field })), ? signup.participant_fields.map(normalizeField)
participant_fields: signup.participant_fields.map((field) => ({ ...field })) : [];
};
ensureAttendancePlacement(draft);
const entry_fields = draft.entry_fields.map(normalizeField);
const participant_fields = mode === 'team' ? draft.participant_fields.map(normalizeField) : [];
return { return {
mode, mode,
team_size: { min: teamSize, max: teamSize }, team_size: { min, max },
entry_fields, entry_fields,
participant_fields participant_fields
}; };
@ -506,7 +356,10 @@ function setTeamSize(form: TournamentForm, value: string) {
await refreshPromise; await refreshPromise;
} }
function setTab(tab: TabKey, options: { slug?: string | null; replace?: boolean } = {}) { function setTab(
tab: TabKey,
options: { slug?: string | null; replace?: boolean } = {}
) {
const currentTab = activeTab(); const currentTab = activeTab();
const currentSlug = slugParam(); const currentSlug = slugParam();
const targetSlug = options.slug ?? null; const targetSlug = options.slug ?? null;
@ -540,7 +393,7 @@ function setTeamSize(form: TournamentForm, value: string) {
manageState.error = ''; manageState.error = '';
setTab('manage', { setTab('manage', {
slug: tournament.slug, slug: tournament.slug,
replace: options.replace ?? false replace: options.replace ?? false,
}); });
} }
@ -595,7 +448,8 @@ function setTeamSize(form: TournamentForm, value: string) {
void refreshTournaments({ preferredId: tournament.id }); void refreshTournaments({ preferredId: tournament.id });
}, },
(tournamentId) => { (tournamentId) => {
const preferredId = manageState.selectedId === tournamentId ? null : manageState.selectedId; const preferredId =
manageState.selectedId === tournamentId ? null : manageState.selectedId;
void refreshTournaments({ preferredId: preferredId ?? undefined }); void refreshTournaments({ preferredId: preferredId ?? undefined });
} }
); );
@ -884,9 +738,7 @@ function setTeamSize(form: TournamentForm, value: string) {
} }
const count = Math.max(totalParticipants, totalRegistrations); const count = Math.max(totalParticipants, totalRegistrations);
return count === 0 return count === 0 ? 'Inga anmälningar ännu' : `${count} ${count === 1 ? 'spelare' : 'spelare'}`;
? 'Inga anmälningar ännu'
: `${count} ${count === 1 ? 'spelare' : 'spelare'}`;
} }
const selectedTournamentInfo = $derived(() => { const selectedTournamentInfo = $derived(() => {
@ -932,21 +784,15 @@ function setTeamSize(form: TournamentForm, value: string) {
{#if filteredOverview().length > 0} {#if filteredOverview().length > 0}
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
{#each filteredOverview() as tournament (tournament.id)} {#each filteredOverview() as tournament (tournament.id)}
<article <article class="flex h-full flex-col justify-between rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
class="flex h-full flex-col justify-between rounded-xl border border-slate-200 bg-white p-5 shadow-sm"
>
<div class="space-y-2"> <div class="space-y-2">
<p class="text-xs font-semibold tracking-[0.3em] text-indigo-400 uppercase"> <p class="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-400">{tournament.game}</p>
{tournament.game}
</p>
<h3 class="text-lg font-semibold text-slate-900">{tournament.title}</h3> <h3 class="text-lg font-semibold text-slate-900">{tournament.title}</h3>
{#if tournament.tagline} {#if tournament.tagline}
<p class="text-sm text-slate-600">{tournament.tagline}</p> <p class="text-sm text-slate-600">{tournament.tagline}</p>
{/if} {/if}
{#if tournament.start_at} {#if tournament.start_at}
<p class="text-xs text-slate-500"> <p class="text-xs text-slate-500">{formatDateTime(tournament.start_at) ?? tournament.start_at}</p>
{formatDateTime(tournament.start_at) ?? tournament.start_at}
</p>
{/if} {/if}
<p class="text-xs text-slate-500">{registrationSummary(tournament)}</p> <p class="text-xs text-slate-500">{registrationSummary(tournament)}</p>
</div> </div>
@ -955,14 +801,14 @@ function setTeamSize(form: TournamentForm, value: string) {
href={`/tournament/${tournament.slug}`} href={`/tournament/${tournament.slug}`}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="rounded-full bg-indigo-600 px-4 py-2 text-xs font-semibold tracking-wide text-white uppercase transition hover:bg-indigo-700" class="rounded-full bg-indigo-600 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-700"
> >
Öppna sida Öppna sida
</a> </a>
{#if tournament.slug} {#if tournament.slug}
<a <a
href={`/admin/tournament/${tournament.slug}/registrations`} href={`/admin/tournament/${tournament.slug}/registrations`}
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold tracking-wide text-slate-600 uppercase transition hover:bg-slate-100" class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-600 transition hover:bg-slate-100"
> >
Visa anmälningar Visa anmälningar
</a> </a>
@ -970,7 +816,7 @@ function setTeamSize(form: TournamentForm, value: string) {
<button <button
type="button" type="button"
onclick={() => selectTournament(tournament)} onclick={() => selectTournament(tournament)}
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold tracking-wide text-slate-600 uppercase transition hover:bg-slate-100" class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-600 transition hover:bg-slate-100"
> >
Redigera Redigera
</button> </button>
@ -979,9 +825,7 @@ function setTeamSize(form: TournamentForm, value: string) {
{/each} {/each}
</div> </div>
{:else} {:else}
<p <p class="rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
class="rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500"
>
Inga turneringar hittades. Skapa en ny via fliken "Skapa ny". Inga turneringar hittades. Skapa en ny via fliken "Skapa ny".
</p> </p>
{/if} {/if}
@ -990,9 +834,7 @@ function setTeamSize(form: TournamentForm, value: string) {
<section class="space-y-6 rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <section class="space-y-6 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<header class="space-y-1"> <header class="space-y-1">
<h2 class="text-xl font-semibold text-slate-900">Skapa turnering</h2> <h2 class="text-xl font-semibold text-slate-900">Skapa turnering</h2>
<p class="text-sm text-slate-600"> <p class="text-sm text-slate-600">Fälten används även för att automatiskt bygga anmälningssidan.</p>
Fälten används även för att automatiskt bygga anmälningssidan.
</p>
</header> </header>
<form class="space-y-5" onsubmit={handleCreate}> <form class="space-y-5" onsubmit={handleCreate}>
@ -1079,9 +921,7 @@ function setTeamSize(form: TournamentForm, value: string) {
<section class="space-y-3"> <section class="space-y-3">
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h3 class="text-sm font-semibold tracking-wide text-slate-500 uppercase"> <h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Sektioner för anmälningssidan</h3>
Sektioner för anmälningssidan
</h3>
<button <button
type="button" type="button"
onclick={() => addSection(createState.form)} onclick={() => addSection(createState.form)}
@ -1092,9 +932,7 @@ function setTeamSize(form: TournamentForm, value: string) {
</header> </header>
{#if createState.form.sections.length === 0} {#if createState.form.sections.length === 0}
<p <p class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500">
class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500"
>
Lägg till sektioner som beskriver regler, format eller andra detaljer. Lägg till sektioner som beskriver regler, format eller andra detaljer.
</p> </p>
{:else} {:else}
@ -1103,8 +941,7 @@ function setTeamSize(form: TournamentForm, value: string) {
<div class="rounded-md border border-slate-200 p-4"> <div class="rounded-md border border-slate-200 p-4">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<label class="flex-1 text-sm font-medium text-slate-700"> <label class="flex-1 text-sm font-medium text-slate-700">
<span class="block text-xs tracking-wide text-slate-500 uppercase">Titel</span <span class="block text-xs uppercase tracking-wide text-slate-500">Titel</span>
>
<input <input
type="text" type="text"
bind:value={section.title} bind:value={section.title}
@ -1120,9 +957,7 @@ function setTeamSize(form: TournamentForm, value: string) {
</button> </button>
</div> </div>
<label class="mt-3 block text-sm font-medium text-slate-700"> <label class="mt-3 block text-sm font-medium text-slate-700">
<span class="block text-xs tracking-wide text-slate-500 uppercase" <span class="block text-xs uppercase tracking-wide text-slate-500">Innehåll</span>
>Innehåll</span
>
<textarea <textarea
rows={3} rows={3}
bind:value={section.body} bind:value={section.body}
@ -1137,12 +972,8 @@ function setTeamSize(form: TournamentForm, value: string) {
<section class="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <section class="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<header class="space-y-1"> <header class="space-y-1">
<h3 class="text-sm font-semibold tracking-wide text-slate-500 uppercase"> <h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Anmälningsinställningar</h3>
Anmälningsinställningar <p class="text-sm text-slate-600">Ställ in vad som krävs när spelare eller lag registrerar sig.</p>
</h3>
<p class="text-sm text-slate-600">
Ställ in vad som krävs när spelare eller lag registrerar sig.
</p>
</header> </header>
<div class="grid gap-4 md:grid-cols-2"> <div class="grid gap-4 md:grid-cols-2">
@ -1151,10 +982,8 @@ function setTeamSize(form: TournamentForm, value: string) {
<select <select
value={createState.form.signup.mode} value={createState.form.signup.mode}
onchange={(event) => onchange={(event) =>
setSignupMode( setSignupMode(createState.form, (event.currentTarget as HTMLSelectElement).value as 'solo' | 'team')
createState.form, }
(event.currentTarget as HTMLSelectElement).value as 'solo' | 'team'
)}
class="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none" class="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
> >
<option value="solo">Individuell</option> <option value="solo">Individuell</option>
@ -1162,18 +991,24 @@ function setTeamSize(form: TournamentForm, value: string) {
</select> </select>
</label> </label>
{#if createState.form.signup.mode === 'team'} {#if createState.form.signup.mode === 'team'}
<div class="grid gap-3"> <div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700"> <label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Spelare per lag</span> <span>Min. spelare</span>
<input <input
type="number" type="number"
min={1} min={1}
value={createState.form.signup.team_size.min} value={createState.form.signup.team_size.min}
oninput={(event) => oninput={(event) => setTeamSize(createState.form, 'min', (event.currentTarget as HTMLInputElement).value)}
setTeamSize( class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
createState.form, />
(event.currentTarget as HTMLInputElement).value </label>
)} <label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Max. spelare</span>
<input
type="number"
min={createState.form.signup.team_size.min}
value={createState.form.signup.team_size.max}
oninput={(event) => setTeamSize(createState.form, 'max', (event.currentTarget as HTMLInputElement).value)}
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none" class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/> />
</label> </label>
@ -1183,9 +1018,7 @@ function setTeamSize(form: TournamentForm, value: string) {
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase"> <h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Anmälningsfält</h4>
Anmälningsfält
</h4>
<button <button
type="button" type="button"
onclick={() => addEntryField(createState.form)} onclick={() => addEntryField(createState.form)}
@ -1196,9 +1029,7 @@ function setTeamSize(form: TournamentForm, value: string) {
</div> </div>
{#if createState.form.signup.entry_fields.length === 0} {#if createState.form.signup.entry_fields.length === 0}
<p <p class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500">
class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500"
>
Ange vilka uppgifter laget eller spelaren ska fylla i vid anmälan. Ange vilka uppgifter laget eller spelaren ska fylla i vid anmälan.
</p> </p>
{:else} {:else}
@ -1215,18 +1046,9 @@ function setTeamSize(form: TournamentForm, value: string) {
/> />
</label> </label>
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700"> <label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span class="flex items-center gap-2"> <span>Typ</span>
Typ
{#if isAttendanceField(field)}
<span
class="rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-600"
>låst</span
>
{/if}
</span>
<select <select
bind:value={field.field_type} bind:value={field.field_type}
disabled={isAttendanceField(field)}
class="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none" class="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
> >
{#each fieldTypeOptions as option} {#each fieldTypeOptions as option}
@ -1235,47 +1057,29 @@ function setTeamSize(form: TournamentForm, value: string) {
</select> </select>
</label> </label>
<label class="flex items-center gap-2 text-sm font-medium text-slate-700"> <label class="flex items-center gap-2 text-sm font-medium text-slate-700">
<input <input type="checkbox" bind:checked={field.required} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
type="checkbox"
bind:checked={field.required}
disabled={isAttendanceField(field)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>Krävs</span> <span>Krävs</span>
</label> </label>
<label class="flex items-center gap-2 text-sm font-medium text-slate-700"> <label class="flex items-center gap-2 text-sm font-medium text-slate-700">
<input <input type="checkbox" bind:checked={field.unique} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
type="checkbox"
bind:checked={field.unique}
disabled={isAttendanceField(field)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>Måste vara unik</span> <span>Måste vara unik</span>
</label> </label>
<button <button
type="button" type="button"
onclick={() => removeEntryField(createState.form, index)} onclick={() => removeEntryField(createState.form, index)}
disabled={isAttendanceField(field) || disabled={createState.form.signup.entry_fields.length <= 1}
createState.form.signup.entry_fields.length <= 1}
class="rounded-full border border-red-200 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-full border border-red-200 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
> >
Ta bort Ta bort
</button> </button>
</div> </div>
<label class="block text-sm font-medium text-slate-700"> <label class="block text-sm font-medium text-slate-700">
<span class="block text-xs tracking-wide text-slate-500 uppercase" <span class="block text-xs uppercase tracking-wide text-slate-500">Plats­hållare (valfritt)</span>
>Plats­hållare (valfritt)</span
>
<input <input
type="text" type="text"
bind:value={field.placeholder} bind:value={field.placeholder}
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none" class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/> />
{#if isAttendanceField(field)}
<p class="mt-2 text-xs text-slate-500">
Detta fält kopplar anmälan till närvarolistan och kan inte tas bort.
</p>
{/if}
</label> </label>
</div> </div>
{/each} {/each}
@ -1286,9 +1090,7 @@ function setTeamSize(form: TournamentForm, value: string) {
{#if createState.form.signup.mode === 'team'} {#if createState.form.signup.mode === 'team'}
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase"> <h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Spelaruppgifter</h4>
Spelaruppgifter
</h4>
<button <button
type="button" type="button"
onclick={() => addParticipantField(createState.form)} onclick={() => addParticipantField(createState.form)}
@ -1299,9 +1101,7 @@ function setTeamSize(form: TournamentForm, value: string) {
</div> </div>
{#if createState.form.signup.participant_fields.length === 0} {#if createState.form.signup.participant_fields.length === 0}
<p <p class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500">
class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500"
>
Lägg till fält för varje spelare, t.ex. nick eller kontaktuppgifter. Lägg till fält för varje spelare, t.ex. nick eller kontaktuppgifter.
</p> </p>
{:else} {:else}
@ -1329,19 +1129,11 @@ function setTeamSize(form: TournamentForm, value: string) {
</select> </select>
</label> </label>
<label class="flex items-center gap-2 text-sm font-medium text-slate-700"> <label class="flex items-center gap-2 text-sm font-medium text-slate-700">
<input <input type="checkbox" bind:checked={field.required} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
type="checkbox"
bind:checked={field.required}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>Krävs</span> <span>Krävs</span>
</label> </label>
<label class="flex items-center gap-2 text-sm font-medium text-slate-700"> <label class="flex items-center gap-2 text-sm font-medium text-slate-700">
<input <input type="checkbox" bind:checked={field.unique} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
type="checkbox"
bind:checked={field.unique}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>Måste vara unik</span> <span>Måste vara unik</span>
</label> </label>
<button <button
@ -1354,9 +1146,7 @@ function setTeamSize(form: TournamentForm, value: string) {
</button> </button>
</div> </div>
<label class="block text-sm font-medium text-slate-700"> <label class="block text-sm font-medium text-slate-700">
<span class="block text-xs tracking-wide text-slate-500 uppercase" <span class="block text-xs uppercase tracking-wide text-slate-500">Plats­hållare (valfritt)</span>
>Plats­hållare (valfritt)</span
>
<input <input
type="text" type="text"
bind:value={field.placeholder} bind:value={field.placeholder}
@ -1396,76 +1186,54 @@ function setTeamSize(form: TournamentForm, value: string) {
{#if createState.form.title.trim()} {#if createState.form.title.trim()}
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h3 class="text-lg font-semibold text-slate-900">Förhandsvisning av anmälningssida</h3> <h3 class="text-lg font-semibold text-slate-900">Förhandsvisning av anmälningssida</h3>
<p class="mt-2 text-sm text-slate-500"> <p class="mt-2 text-sm text-slate-500">Visas för deltagare på /tournament/{createState.form.slug || 'slug'}.</p>
Visas för deltagare på /tournament/{createState.form.slug || 'slug'}.
</p>
<div class="mt-5 space-y-4 rounded-2xl bg-slate-900 p-6 text-slate-100"> <div class="mt-5 space-y-4 rounded-2xl bg-slate-900 p-6 text-slate-100">
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs tracking-[0.4em] text-indigo-300 uppercase"> <p class="text-xs uppercase tracking-[0.4em] text-indigo-300">{createState.form.game || 'Spel'}</p>
{createState.form.game || 'Spel'}
</p>
<h4 class="text-2xl font-bold sm:text-3xl">{createState.form.title || 'Titel'}</h4> <h4 class="text-2xl font-bold sm:text-3xl">{createState.form.title || 'Titel'}</h4>
{#if createState.form.tagline.trim()} {#if createState.form.tagline.trim()}
<p class="text-sm text-slate-300">{createState.form.tagline}</p> <p class="text-sm text-slate-300">{createState.form.tagline}</p>
{/if} {/if}
</div> </div>
{#if createState.form.start_at} {#if createState.form.start_at}
<p class="text-sm text-indigo-200"> <p class="text-sm text-indigo-200">Start {formatDateTime(new Date(createState.form.start_at).toISOString()) ?? ''}</p>
Start {formatDateTime(new Date(createState.form.start_at).toISOString()) ?? ''}
</p>
{/if} {/if}
{#if createState.form.location.trim()} {#if createState.form.location.trim()}
<p class="text-sm text-slate-200">Plats: {createState.form.location}</p> <p class="text-sm text-slate-200">Plats: {createState.form.location}</p>
{/if} {/if}
{#if createState.form.description.trim()} {#if createState.form.description.trim()}
<p class="text-sm whitespace-pre-line text-slate-200"> <p class="whitespace-pre-line text-sm text-slate-200">{createState.form.description}</p>
{createState.form.description}
</p>
{/if} {/if}
{#if createState.form.sections.length > 0} {#if createState.form.sections.length > 0}
<div class="space-y-3"> <div class="space-y-3">
{#each payloadSections(createState.form.sections) as section, index (sectionKey(index))} {#each payloadSections(createState.form.sections) as section, index (sectionKey(index))}
<div> <div>
<p class="text-sm font-semibold text-indigo-200">{section.title}</p> <p class="text-sm font-semibold text-indigo-200">{section.title}</p>
<p class="text-sm whitespace-pre-line text-slate-200">{section.body}</p> <p class="whitespace-pre-line text-sm text-slate-200">{section.body}</p>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if createState.form.contact.trim()} {#if createState.form.contact.trim()}
<p class="text-xs tracking-wide text-indigo-100 uppercase"> <p class="text-xs uppercase tracking-wide text-indigo-100">Kontakt: {createState.form.contact}</p>
Kontakt: {createState.form.contact}
</p>
{/if} {/if}
{#if createState.form.signup.entry_fields.length > 0} {#if createState.form.signup.entry_fields.length > 0}
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/70 p-3"> <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/70 p-3">
<p class="text-xs font-semibold tracking-wide text-indigo-200 uppercase"> <p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Anmälningsfält</p>
Anmälningsfält
</p>
<ul class="space-y-1 text-sm text-slate-200"> <ul class="space-y-1 text-sm text-slate-200">
{#each createState.form.signup.entry_fields as field} {#each createState.form.signup.entry_fields as field}
<li> <li>{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}</li>
{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type}
{field.required ? '• obligatoriskt' : ''}
{field.unique ? '• unikt' : ''}
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
{/if} {/if}
{#if createState.form.signup.mode === 'team' && createState.form.signup.participant_fields.length > 0} {#if createState.form.signup.mode === 'team' && createState.form.signup.participant_fields.length > 0}
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/70 p-3"> <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/70 p-3">
<p class="text-xs font-semibold tracking-wide text-indigo-200 uppercase"> <p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Spelaruppgifter</p>
Spelaruppgifter
</p>
<ul class="space-y-1 text-sm text-slate-200"> <ul class="space-y-1 text-sm text-slate-200">
{#each createState.form.signup.participant_fields as field} {#each createState.form.signup.participant_fields as field}
<li> <li>{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}</li>
{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type}
{field.required ? '• obligatoriskt' : ''}
{field.unique ? '• unikt' : ''}
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
@ -1478,9 +1246,7 @@ function setTeamSize(form: TournamentForm, value: string) {
<section class="space-y-6 rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <section class="space-y-6 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<header class="space-y-1"> <header class="space-y-1">
<h2 class="text-xl font-semibold text-slate-900">Hantera turneringar</h2> <h2 class="text-xl font-semibold text-slate-900">Hantera turneringar</h2>
<p class="text-sm text-slate-600"> <p class="text-sm text-slate-600">Välj en turnering i listan för att uppdatera innehållet.</p>
Välj en turnering i listan för att uppdatera innehållet.
</p>
{#if selectedTournamentInfo()} {#if selectedTournamentInfo()}
{@const selected = selectedTournamentInfo()!} {@const selected = selectedTournamentInfo()!}
<p class="text-sm text-indigo-600">{registrationSummary(selected)}</p> <p class="text-sm text-indigo-600">{registrationSummary(selected)}</p>
@ -1490,9 +1256,7 @@ function setTeamSize(form: TournamentForm, value: string) {
<div class="flex flex-col gap-6 lg:flex-row lg:items-start"> <div class="flex flex-col gap-6 lg:flex-row lg:items-start">
<aside class="w-full max-w-xs space-y-3 rounded-lg border border-slate-200 bg-slate-50 p-4"> <aside class="w-full max-w-xs space-y-3 rounded-lg border border-slate-200 bg-slate-50 p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-sm font-semibold tracking-wide text-slate-500 uppercase"> <h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Turneringar</h3>
Turneringar
</h3>
<button <button
type="button" type="button"
onclick={() => setTab('create')} onclick={() => setTab('create')}
@ -1515,9 +1279,7 @@ function setTeamSize(form: TournamentForm, value: string) {
}`} }`}
> >
{tournament.title} {tournament.title}
<span class="block text-xs font-normal text-indigo-200/90" <span class="block text-xs font-normal text-indigo-200/90">{overviewCardSubtitle(tournament)}</span>
>{overviewCardSubtitle(tournament)}</span
>
</button> </button>
{#if tournament.slug} {#if tournament.slug}
<div class="mt-2 flex flex-wrap gap-2 text-xs"> <div class="mt-2 flex flex-wrap gap-2 text-xs">
@ -1539,9 +1301,7 @@ function setTeamSize(form: TournamentForm, value: string) {
{/if} {/if}
</li> </li>
{:else} {:else}
<li <li class="rounded-md border border-dashed border-slate-300 px-3 py-6 text-center text-sm text-slate-500">
class="rounded-md border border-dashed border-slate-300 px-3 py-6 text-center text-sm text-slate-500"
>
Inga sparade turneringar ännu. Inga sparade turneringar ännu.
</li> </li>
{/each} {/each}
@ -1626,9 +1386,7 @@ function setTeamSize(form: TournamentForm, value: string) {
<section class="space-y-3"> <section class="space-y-3">
<header class="flex items-center justify-between"> <header class="flex items-center justify-between">
<h3 class="text-sm font-semibold tracking-wide text-slate-500 uppercase"> <h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Sektioner</h3>
Sektioner
</h3>
<button <button
type="button" type="button"
onclick={() => addSection(manageState.form)} onclick={() => addSection(manageState.form)}
@ -1639,9 +1397,7 @@ function setTeamSize(form: TournamentForm, value: string) {
</header> </header>
{#if manageState.form.sections.length === 0} {#if manageState.form.sections.length === 0}
<p <p class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500">
class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500"
>
Inga sektioner ännu. Lägg till regler, format eller andra detaljer. Inga sektioner ännu. Lägg till regler, format eller andra detaljer.
</p> </p>
{:else} {:else}
@ -1650,9 +1406,7 @@ function setTeamSize(form: TournamentForm, value: string) {
<div class="rounded-md border border-slate-200 p-4"> <div class="rounded-md border border-slate-200 p-4">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<label class="flex-1 text-sm font-medium text-slate-700"> <label class="flex-1 text-sm font-medium text-slate-700">
<span class="block text-xs tracking-wide text-slate-500 uppercase" <span class="block text-xs uppercase tracking-wide text-slate-500">Titel</span>
>Titel</span
>
<input <input
type="text" type="text"
bind:value={section.title} bind:value={section.title}
@ -1668,9 +1422,7 @@ function setTeamSize(form: TournamentForm, value: string) {
</button> </button>
</div> </div>
<label class="mt-3 block text-sm font-medium text-slate-700"> <label class="mt-3 block text-sm font-medium text-slate-700">
<span class="block text-xs tracking-wide text-slate-500 uppercase" <span class="block text-xs uppercase tracking-wide text-slate-500">Innehåll</span>
>Innehåll</span
>
<textarea <textarea
rows={3} rows={3}
bind:value={section.body} bind:value={section.body}
@ -1711,11 +1463,7 @@ function setTeamSize(form: TournamentForm, value: string) {
disabled={manageState.saving} disabled={manageState.saving}
class="inline-flex items-center justify-center rounded-full bg-indigo-600 px-5 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70" class="inline-flex items-center justify-center rounded-full bg-indigo-600 px-5 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-70"
> >
{manageState.saving {manageState.saving ? 'Sparar…' : manageState.selectedId === null ? 'Skapa turnering' : 'Spara ändringar'}
? 'Sparar…'
: manageState.selectedId === null
? 'Skapa turnering'
: 'Spara ändringar'}
</button> </button>
</div> </div>
</div> </div>
@ -1723,81 +1471,55 @@ function setTeamSize(form: TournamentForm, value: string) {
{#if manageState.form.title.trim()} {#if manageState.form.title.trim()}
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm"> <section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<h3 class="text-lg font-semibold text-slate-900"> <h3 class="text-lg font-semibold text-slate-900">Förhandsvisning av anmälningssida</h3>
Förhandsvisning av anmälningssida <p class="mt-2 text-sm text-slate-500">Visas för deltagare på /tournament/{manageState.form.slug || 'slug'}.</p>
</h3>
<p class="mt-2 text-sm text-slate-500">
Visas för deltagare på /tournament/{manageState.form.slug || 'slug'}.
</p>
<div class="mt-5 space-y-4 rounded-2xl bg-slate-900 p-6 text-slate-100"> <div class="mt-5 space-y-4 rounded-2xl bg-slate-900 p-6 text-slate-100">
<div class="space-y-1"> <div class="space-y-1">
<p class="text-xs tracking-[0.4em] text-indigo-300 uppercase"> <p class="text-xs uppercase tracking-[0.4em] text-indigo-300">{manageState.form.game || 'Spel'}</p>
{manageState.form.game || 'Spel'} <h4 class="text-2xl font-bold sm:text-3xl">{manageState.form.title || 'Titel'}</h4>
</p>
<h4 class="text-2xl font-bold sm:text-3xl">
{manageState.form.title || 'Titel'}
</h4>
{#if manageState.form.tagline.trim()} {#if manageState.form.tagline.trim()}
<p class="text-sm text-slate-300">{manageState.form.tagline}</p> <p class="text-sm text-slate-300">{manageState.form.tagline}</p>
{/if} {/if}
</div> </div>
{#if manageState.form.start_at} {#if manageState.form.start_at}
<p class="text-sm text-indigo-200"> <p class="text-sm text-indigo-200">Start {formatDateTime(new Date(manageState.form.start_at).toISOString()) ?? ''}</p>
Start {formatDateTime(new Date(manageState.form.start_at).toISOString()) ?? ''}
</p>
{/if} {/if}
{#if manageState.form.location.trim()} {#if manageState.form.location.trim()}
<p class="text-sm text-slate-200">Plats: {manageState.form.location}</p> <p class="text-sm text-slate-200">Plats: {manageState.form.location}</p>
{/if} {/if}
{#if manageState.form.description.trim()} {#if manageState.form.description.trim()}
<p class="text-sm whitespace-pre-line text-slate-200"> <p class="whitespace-pre-line text-sm text-slate-200">{manageState.form.description}</p>
{manageState.form.description}
</p>
{/if} {/if}
{#if manageState.form.sections.length > 0} {#if manageState.form.sections.length > 0}
<div class="space-y-3"> <div class="space-y-3">
{#each payloadSections(manageState.form.sections) as section, index (sectionKey(index))} {#each payloadSections(manageState.form.sections) as section, index (sectionKey(index))}
<div> <div>
<p class="text-sm font-semibold text-indigo-200">{section.title}</p> <p class="text-sm font-semibold text-indigo-200">{section.title}</p>
<p class="text-sm whitespace-pre-line text-slate-200">{section.body}</p> <p class="whitespace-pre-line text-sm text-slate-200">{section.body}</p>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if manageState.form.contact.trim()} {#if manageState.form.contact.trim()}
<p class="text-xs tracking-wide text-indigo-100 uppercase"> <p class="text-xs uppercase tracking-wide text-indigo-100">Kontakt: {manageState.form.contact}</p>
Kontakt: {manageState.form.contact}
</p>
{/if} {/if}
{#if manageState.form.signup.entry_fields.length > 0} {#if manageState.form.signup.entry_fields.length > 0}
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/70 p-3"> <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/70 p-3">
<p class="text-xs font-semibold tracking-wide text-indigo-200 uppercase"> <p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Anmälningsfält</p>
Anmälningsfält
</p>
<ul class="space-y-1 text-sm text-slate-200"> <ul class="space-y-1 text-sm text-slate-200">
{#each manageState.form.signup.entry_fields as field} {#each manageState.form.signup.entry_fields as field}
<li> <li>{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}</li>
{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type}
{field.required ? '• obligatoriskt' : ''}
{field.unique ? '• unikt' : ''}
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
{/if} {/if}
{#if manageState.form.signup.mode === 'team' && manageState.form.signup.participant_fields.length > 0} {#if manageState.form.signup.mode === 'team' && manageState.form.signup.participant_fields.length > 0}
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/70 p-3"> <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/70 p-3">
<p class="text-xs font-semibold tracking-wide text-indigo-200 uppercase"> <p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Spelaruppgifter</p>
Spelaruppgifter
</p>
<ul class="space-y-1 text-sm text-slate-200"> <ul class="space-y-1 text-sm text-slate-200">
{#each manageState.form.signup.participant_fields as field} {#each manageState.form.signup.participant_fields as field}
<li> <li>{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}</li>
{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type}
{field.required ? '• obligatoriskt' : ''}
{field.unique ? '• unikt' : ''}
</li>
{/each} {/each}
</ul> </ul>
</div> </div>
@ -1815,7 +1537,7 @@ function setTeamSize(form: TournamentForm, value: string) {
class={`inline-flex items-center justify-center rounded-full border px-4 py-2 text-sm font-semibold transition ${ class={`inline-flex items-center justify-center rounded-full border px-4 py-2 text-sm font-semibold transition ${
manageSlug manageSlug
? 'border-indigo-300 text-indigo-600 hover:border-indigo-400 hover:bg-indigo-50' ? 'border-indigo-300 text-indigo-600 hover:border-indigo-400 hover:bg-indigo-50'
: 'pointer-events-none border-indigo-200 text-indigo-300 opacity-60' : 'border-indigo-200 text-indigo-300 pointer-events-none opacity-60'
}`} }`}
> >
Visa publika sidan Visa publika sidan

View file

@ -1,3 +1,4 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listenToTournamentEvents } from '$lib/client/tournament-events'; import { listenToTournamentEvents } from '$lib/client/tournament-events';
@ -12,11 +13,6 @@
let registrations = $state(props.data.registrations ?? []); let registrations = $state(props.data.registrations ?? []);
let refreshing = $state(false); let refreshing = $state(false);
let loadError = $state(''); let loadError = $state('');
const ATTENDANCE_FIELD_ID = 'attendance-id';
function isAttendanceField(field: TournamentSignupField): boolean {
return field.id === ATTENDANCE_FIELD_ID;
}
type RegistrationResponse = { type RegistrationResponse = {
tournament: TournamentRegistrationList['tournament']; tournament: TournamentRegistrationList['tournament'];
@ -107,22 +103,6 @@
signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.max) : 0 signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.max) : 0
); );
function registrationHeading(registration: TournamentRegistrationItem) {
const entry = registration.entry ?? {};
for (const field of entryFields()) {
if (isAttendanceField(field)) continue;
const value = entry[field.id];
if (value && value.trim()) {
return value.trim();
}
}
const attendanceValue = entry[ATTENDANCE_FIELD_ID];
if (attendanceValue && attendanceValue.trim()) {
return `Anmälan för ID ${attendanceValue.trim()}`;
}
return 'Anmälan';
}
function formatDateTime(value: string | null) { function formatDateTime(value: string | null) {
if (!value) return null; if (!value) return null;
const date = new Date(value); const date = new Date(value);
@ -244,42 +224,10 @@
if (!tournament.slug || editingId !== registration.id) return; if (!tournament.slug || editingId !== registration.id) return;
editSaving = true; editSaving = true;
editError = ''; editError = '';
const entry: Record<string, string> = {};
for (const [key, value] of Object.entries(editEntry)) {
entry[key] = (value ?? '').trim();
}
const participants = editParticipants.map((participant) => {
const map: Record<string, string> = {};
for (const [key, value] of Object.entries(participant)) {
map[key] = (value ?? '').trim();
}
return map;
});
const payload = { const payload = {
entry, entry: { ...editEntry },
participants participants: editParticipants.map((participant) => ({ ...participant }))
}; };
if (entryFields().some(isAttendanceField)) {
const attendanceValue = (payload.entry[ATTENDANCE_FIELD_ID] ?? '').trim();
if (!attendanceValue) {
editSaving = false;
editError = 'Ange deltagar-ID från närvarolistan.';
return;
}
if (!/^\d+$/.test(attendanceValue)) {
editSaving = false;
editError = 'Deltagar-ID får endast innehålla siffror.';
return;
}
const attendanceNumeric = Number.parseInt(attendanceValue, 10);
if (!Number.isFinite(attendanceNumeric) || attendanceNumeric <= 0) {
editSaving = false;
editError = 'Ange ett giltigt deltagar-ID.';
return;
}
payload.entry[ATTENDANCE_FIELD_ID] = String(attendanceNumeric);
}
try { try {
const result = await callEndpoint( const result = await callEndpoint(
'update', 'update',
@ -304,28 +252,42 @@
function exportRegistrations() { function exportRegistrations() {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
const lines: string[] = []; const lines: string[] = [];
const timestamp = formatDateTime(new Date().toISOString()) ?? new Date().toISOString();
lines.push(`${tournament.title} ${registrations.length} anmälningar`); lines.push(`${tournament.title} ${registrations.length} anmälningar`);
lines.push(`Genererad ${timestamp}`);
lines.push(''); lines.push('');
if (registrations.length === 0) { if (registrations.length === 0) {
lines.push('Inga anmälningar.'); lines.push('Inga anmälningar.');
} else { } else {
for (const registration of registrations) { for (const registration of registrations) {
lines.push(registrationHeading(registration)); lines.push(`Anmälan #${registration.id}`);
const created = formatDateTime(registration.created_at) ?? registration.created_at;
lines.push(`Skapad: ${created}`);
if (entryFields().length > 0) {
lines.push('Lag / deltagare:');
for (const field of entryFields()) { for (const field of entryFields()) {
const value = fieldValue(registration.entry, field).trim(); lines.push(` ${field.label}: ${fieldValue(registration.entry, field) || '—'}`);
lines.push(`${field.label}: ${value || '—'}`);
} }
if (participantFields().length > 0 && registration.participants.length > 0) { }
if (participantFields().length === 0) {
if (registration.participants.length > 0) {
lines.push(`Spelare: ${registration.participants.length}`);
}
} else if (registration.participants.length === 0) {
lines.push('Spelare: inga angivna');
} else {
lines.push('Spelare:');
registration.participants.forEach((participant, index) => { registration.participants.forEach((participant, index) => {
lines.push('');
lines.push(` Spelare ${index + 1}`); lines.push(` Spelare ${index + 1}`);
for (const field of participantFields()) { for (const field of participantFields()) {
const value = fieldValue(participant, field).trim(); lines.push(` ${field.label}: ${fieldValue(participant, field) || '—'}`);
lines.push(`${field.label}: ${value || '—'}`);
} }
}); });
} }
lines.push(''); lines.push('');
} }
} }
@ -382,19 +344,16 @@
} }
onMount(() => { onMount(() => {
const stop = listenToTournamentEvents( const stop = listenToTournamentEvents((updated) => {
(updated) => {
if (updated.id === tournament.id && editingId === null) { if (updated.id === tournament.id && editingId === null) {
void refreshRegistrations(); void refreshRegistrations();
} }
}, }, (deletedId) => {
(deletedId) => {
if (deletedId === tournament.id) { if (deletedId === tournament.id) {
registrations = []; registrations = [];
loadError = 'Turneringen har tagits bort.'; loadError = 'Turneringen har tagits bort.';
} }
} });
);
return () => { return () => {
stop(); stop();
}; };
@ -407,11 +366,9 @@
<div class="min-h-screen bg-slate-100 text-slate-900"> <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"> <div class="mx-auto flex min-h-screen max-w-5xl flex-col gap-8 px-3 py-8 sm:px-4">
<header <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">
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"> <div class="space-y-1">
<p class="text-sm tracking-[0.4em] text-indigo-500 uppercase">Admin</p> <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> <h1 class="text-2xl font-semibold text-slate-900">{tournament.title}</h1>
<p class="text-sm text-slate-600">{tournament.game}</p> <p class="text-sm text-slate-600">{tournament.game}</p>
</div> </div>
@ -435,19 +392,17 @@
<h2 class="text-lg font-semibold text-slate-900">Sammanfattning</h2> <h2 class="text-lg font-semibold text-slate-900">Sammanfattning</h2>
<div class="grid gap-3 sm:grid-cols-3"> <div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4"> <div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<p class="text-xs tracking-wide text-slate-500 uppercase">Anmälningar</p> <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> <p class="mt-1 text-2xl font-semibold text-slate-900">{registrations.length}</p>
</div> </div>
{#if tournament.start_at} {#if tournament.start_at}
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4"> <div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<p class="text-xs tracking-wide text-slate-500 uppercase">Start</p> <p class="text-xs uppercase tracking-wide text-slate-500">Start</p>
<p class="mt-1 text-sm text-slate-800"> <p class="mt-1 text-sm text-slate-800">{formatDateTime(tournament.start_at) ?? tournament.start_at}</p>
{formatDateTime(tournament.start_at) ?? tournament.start_at}
</p>
</div> </div>
{/if} {/if}
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4"> <div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<p class="text-xs tracking-wide text-slate-500 uppercase">Format</p> <p class="text-xs uppercase tracking-wide text-slate-500">Format</p>
<p class="mt-1 text-sm text-slate-800"> <p class="mt-1 text-sm text-slate-800">
{signupConfig().mode === 'team' {signupConfig().mode === 'team'
? `Lag (${signupConfig().team_size.min}${signupConfig().team_size.max} spelare)` ? `Lag (${signupConfig().team_size.min}${signupConfig().team_size.max} spelare)`
@ -462,14 +417,12 @@
<h2 class="text-lg font-semibold text-slate-900">Registreringar</h2> <h2 class="text-lg font-semibold text-slate-900">Registreringar</h2>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
{#if registrations.length > 0} {#if registrations.length > 0}
<p class="text-sm text-slate-500"> <p class="text-sm text-slate-500">Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}</p>
Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}
</p>
{/if} {/if}
<button <button
type="button" type="button"
onclick={exportRegistrations} onclick={exportRegistrations}
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold tracking-wide text-slate-600 uppercase transition hover:bg-slate-100" class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-600 transition hover:bg-slate-100"
> >
Exportera .txt Exportera .txt
</button> </button>
@ -477,27 +430,17 @@
</header> </header>
{#if loadError} {#if loadError}
<p <p class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600">{loadError}</p>
class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600"
>
{loadError}
</p>
{/if} {/if}
{#if deleteError} {#if deleteError}
<p <p class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600">{deleteError}</p>
class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600"
>
{deleteError}
</p>
{/if} {/if}
{#if refreshing && !loadError} {#if refreshing && !loadError}
<p class="mt-2 text-xs text-slate-500">Uppdaterar…</p> <p class="mt-2 text-xs text-slate-500">Uppdaterar…</p>
{/if} {/if}
{#if registrations.length === 0} {#if registrations.length === 0}
<p <p class="mt-4 rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
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. Inga anmälningar ännu. Dela länken till /tournament/{tournament.slug} för att samla in registreringar.
</p> </p>
{:else} {:else}
@ -506,10 +449,8 @@
<article class="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4"> <article class="space-y-4 rounded-lg border border-slate-200 bg-slate-50 p-4">
<header class="flex flex-col gap-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-900"> <h3 class="text-base font-semibold text-slate-900">Anmälan #{registration.id}</h3>
{registrationHeading(registration)} <p class="text-xs uppercase tracking-wide text-slate-500">
</h3>
<p class="text-xs tracking-wide text-slate-500 uppercase">
Skapad {formatDateTime(registration.created_at) ?? registration.created_at} Skapad {formatDateTime(registration.created_at) ?? registration.created_at}
</p> </p>
</div> </div>
@ -543,42 +484,25 @@
</header> </header>
{#if editingId === registration.id} {#if editingId === registration.id}
<form <form class="space-y-4" onsubmit={(event) => {
class="space-y-4"
onsubmit={(event) => {
event.preventDefault(); event.preventDefault();
saveEdit(registration); saveEdit(registration);
}} }}>
>
{#if entryFields().length > 0} {#if entryFields().length > 0}
<div class="space-y-3"> <div class="space-y-3">
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase"> <h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Lag / deltagare</h4>
Lag / deltagare
</h4>
<div class="grid gap-3 md:grid-cols-2"> <div class="grid gap-3 md:grid-cols-2">
{#each entryFields() as field} {#each entryFields() as field}
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700"> <label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>{field.label}</span> <span>{field.label}</span>
<input <input
type={fieldInputType(field.field_type)} type={fieldInputType(field.field_type)}
inputmode={isAttendanceField(field) ? 'numeric' : undefined}
pattern={isAttendanceField(field) ? '\\d*' : undefined}
value={editEntry[field.id] ?? ''} value={editEntry[field.id] ?? ''}
oninput={(event) => oninput={(event) => updateEntryField(field.id, (event.currentTarget as HTMLInputElement).value)}
updateEntryField(
field.id,
(event.currentTarget as HTMLInputElement).value
)}
placeholder={field.placeholder ?? ''} placeholder={field.placeholder ?? ''}
disabled={editSaving} disabled={editSaving}
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-500/40 focus:outline-none" class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
/> />
{#if isAttendanceField(field)}
<span class="text-xs text-slate-500">
Koppla registreringen till en deltagare genom att ange ID från
närvarolistan.
</span>
{/if}
</label> </label>
{/each} {/each}
</div> </div>
@ -587,15 +511,12 @@
<section class="space-y-3"> <section class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase"> <h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Spelare</h4>
Spelare
</h4>
{#if signupConfig().mode === 'team'} {#if signupConfig().mode === 'team'}
<button <button
type="button" type="button"
onclick={addParticipant} onclick={addParticipant}
disabled={editSaving || disabled={editSaving || (maxParticipants() > 0 && editParticipants.length >= maxParticipants())}
(maxParticipants() > 0 && editParticipants.length >= maxParticipants())}
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
> >
Lägg till spelare Lägg till spelare
@ -612,17 +533,12 @@
{#each editParticipants as participant, index} {#each editParticipants as participant, index}
<div class="space-y-3 rounded-md border border-slate-200 bg-white p-3"> <div class="space-y-3 rounded-md border border-slate-200 bg-white p-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p <p class="text-xs font-semibold uppercase tracking-wide text-slate-500">{participantLabel(index)}</p>
class="text-xs font-semibold tracking-wide text-slate-500 uppercase"
>
{participantLabel(index)}
</p>
{#if signupConfig().mode === 'team'} {#if signupConfig().mode === 'team'}
<button <button
type="button" type="button"
onclick={() => removeParticipant(index)} onclick={() => removeParticipant(index)}
disabled={editSaving || disabled={editSaving || editParticipants.length <= Math.max(1, minParticipants())}
editParticipants.length <= Math.max(1, minParticipants())}
class="rounded-full border border-red-200 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60" class="rounded-full border border-red-200 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
> >
Ta bort Ta bort
@ -631,22 +547,15 @@
</div> </div>
<div class="grid gap-3 md:grid-cols-2"> <div class="grid gap-3 md:grid-cols-2">
{#each participantFields() as field} {#each participantFields() as field}
<label <label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
class="flex flex-col gap-1 text-sm font-medium text-slate-700"
>
<span>{field.label}</span> <span>{field.label}</span>
<input <input
type={fieldInputType(field.field_type)} type={fieldInputType(field.field_type)}
value={participant[field.id] ?? ''} value={participant[field.id] ?? ''}
oninput={(event) => oninput={(event) => updateParticipantField(index, field.id, (event.currentTarget as HTMLInputElement).value)}
updateParticipantField(
index,
field.id,
(event.currentTarget as HTMLInputElement).value
)}
placeholder={field.placeholder ?? ''} placeholder={field.placeholder ?? ''}
disabled={editSaving} disabled={editSaving}
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-500/40 focus:outline-none" class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
/> />
</label> </label>
{/each} {/each}
@ -658,11 +567,7 @@
</section> </section>
{#if editError} {#if editError}
<p <p class="rounded-md border border-red-200 bg-red-500/10 px-3 py-2 text-sm text-red-600">{editError}</p>
class="rounded-md border border-red-200 bg-red-500/10 px-3 py-2 text-sm text-red-600"
>
{editError}
</p>
{/if} {/if}
<div class="flex flex-wrap items-center gap-3 pt-2"> <div class="flex flex-wrap items-center gap-3 pt-2">
@ -688,27 +593,28 @@
<div class="grid gap-3 md:grid-cols-2"> <div class="grid gap-3 md:grid-cols-2">
{#each entryFields() as field} {#each entryFields() as field}
<div class="rounded-md border border-slate-200 bg-white p-3"> <div class="rounded-md border border-slate-200 bg-white p-3">
<p class="text-xs tracking-wide text-slate-500 uppercase">{field.label}</p> <p class="text-xs uppercase tracking-wide text-slate-500">{field.label}</p>
<p class="mt-1 text-sm text-slate-800"> <p class="mt-1 text-sm text-slate-800">{fieldValue(registration.entry, field) || '—'}</p>
{fieldValue(registration.entry, field) || '—'}
</p>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if participantFields().length > 0}
<section class="space-y-2"> <section class="space-y-2">
<h4 class="text-sm font-semibold text-slate-800">Spelare</h4> <h4 class="text-sm font-semibold text-slate-800">Spelare</h4>
{#if participantFields().length === 0}
{#if registration.participants.length === 0} {#if registration.participants.length === 0}
<p class="text-xs text-slate-500">Inga spelare angivna.</p> <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} {:else}
<div class="space-y-3"> <div class="space-y-3">
{#each registration.participants as participant, index} {#each registration.participants as participant, index}
<div class="rounded-md border border-slate-200 bg-white p-3"> <div class="rounded-md border border-slate-200 bg-white p-3">
<p class="text-xs font-semibold tracking-wide text-slate-500 uppercase"> <p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Spelare {index + 1}</p>
Spelare {index + 1}
</p>
<ul class="mt-2 space-y-1 text-sm text-slate-800"> <ul class="mt-2 space-y-1 text-sm text-slate-800">
{#each participantFields() as field} {#each participantFields() as field}
<li> <li>
@ -723,7 +629,6 @@
{/if} {/if}
</section> </section>
{/if} {/if}
{/if}
</article> </article>
{/each} {/each}
</div> </div>

View file

@ -64,9 +64,7 @@
} }
onMount(() => { onMount(() => {
const stop = listenToTournamentEvents(upsertTournament, removeTournament, { const stop = listenToTournamentEvents(upsertTournament, removeTournament);
endpoint: '/api/public-events'
});
return () => { return () => {
stop(); stop();
}; };

View file

@ -8,7 +8,6 @@
const props = $props<{ data: { tournament: TournamentInfo } }>(); const props = $props<{ data: { tournament: TournamentInfo } }>();
const tournament = props.data.tournament; const tournament = props.data.tournament;
const ATTENDANCE_FIELD_ID = 'attendance-id';
function pickMode(value: string | null | undefined) { function pickMode(value: string | null | undefined) {
return value === 'team' ? 'team' : 'solo'; return value === 'team' ? 'team' : 'solo';
@ -25,9 +24,7 @@
}; };
} }
function normalizeSignupConfig( function normalizeSignupConfig(config: TournamentSignupConfig | null | undefined): TournamentSignupConfig {
config: TournamentSignupConfig | null | undefined
): TournamentSignupConfig {
if (!config) { if (!config) {
return { return {
mode: 'solo', mode: 'solo',
@ -57,7 +54,6 @@
type FieldValueMap = Record<string, string>; type FieldValueMap = Record<string, string>;
const signupConfig = normalizeSignupConfig(tournament.signup_config); const signupConfig = normalizeSignupConfig(tournament.signup_config);
const entrySectionTitle = signupConfig.mode === 'team' ? 'Lag' : 'Spelare';
function formatDateTime(value: string | null) { function formatDateTime(value: string | null) {
if (!value) return null; if (!value) return null;
@ -98,10 +94,6 @@
} }
} }
function isAttendanceField(field: TournamentSignupField): boolean {
return field.id === ATTENDANCE_FIELD_ID;
}
const minParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.min : 1; const minParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.min : 1;
const maxParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.max : 1; const maxParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.max : 1;
@ -117,6 +109,8 @@
showSuccessModal: false showSuccessModal: false
}); });
function initializeParticipants() { function initializeParticipants() {
const initialCount = Math.max(1, signupConfig.mode === 'team' ? signupConfig.team_size.min : 1); const initialCount = Math.max(1, signupConfig.mode === 'team' ? signupConfig.team_size.min : 1);
const list: FieldValueMap[] = []; const list: FieldValueMap[] = [];
@ -165,13 +159,6 @@
entry[field.id] = (signup.entry[field.id] ?? '').trim(); entry[field.id] = (signup.entry[field.id] ?? '').trim();
} }
if (entry[ATTENDANCE_FIELD_ID]) {
const numeric = Number.parseInt(entry[ATTENDANCE_FIELD_ID], 10);
if (Number.isFinite(numeric) && numeric > 0) {
entry[ATTENDANCE_FIELD_ID] = String(numeric);
}
}
const participants = signup.participants.map((participant) => { const participants = signup.participants.map((participant) => {
const map: Record<string, string> = {}; const map: Record<string, string> = {};
for (const field of signupConfig.participant_fields) { for (const field of signupConfig.participant_fields) {
@ -188,58 +175,18 @@
signup.error = ''; signup.error = '';
signup.success = ''; signup.success = '';
const isTeam = signupConfig.mode === 'team';
if (isTeam) {
if (signup.participants.length < minParticipants) {
signup.error =
minParticipants === 1
? 'Lägg till minst en spelare.'
: `Lägg till minst ${minParticipants} spelare.`;
return;
}
for (let index = 0; index < signup.participants.length; index += 1) {
const participant = signup.participants[index];
const raw = (participant[ATTENDANCE_FIELD_ID] ?? '').trim();
if (!raw) {
signup.error = `Spelare ${index + 1}: ange deltagar-ID från närvarolistan.`;
return;
}
if (!/^\d+$/.test(raw)) {
signup.error = `Spelare ${index + 1}: deltagar-ID får endast innehålla siffror.`;
return;
}
const numeric = Number.parseInt(raw, 10);
if (!Number.isFinite(numeric) || numeric <= 0) {
signup.error = `Spelare ${index + 1}: ange ett giltigt deltagar-ID.`;
return;
}
participant[ATTENDANCE_FIELD_ID] = String(numeric);
}
} else {
const attendanceValue = (signup.entry[ATTENDANCE_FIELD_ID] ?? '').trim();
if (!attendanceValue) {
signup.error = 'Ange ditt deltagar-ID från närvarolistan.';
return;
}
if (!/^\d+$/.test(attendanceValue)) {
signup.error = 'Deltagar-ID får endast innehålla siffror.';
return;
}
const attendanceNumeric = Number.parseInt(attendanceValue, 10);
if (!Number.isFinite(attendanceNumeric) || attendanceNumeric <= 0) {
signup.error = 'Ange ett giltigt deltagar-ID.';
return;
}
signup.entry[ATTENDANCE_FIELD_ID] = String(attendanceNumeric);
}
if (signup.participants.length === 0) { if (signup.participants.length === 0) {
signup.error = 'Lägg till minst en spelare.'; signup.error = 'Lägg till minst en spelare.';
return; return;
} }
if (signupConfig.mode === 'team') {
if (signup.participants.length < minParticipants) {
signup.error = `Lägg till minst ${minParticipants} spelare.`;
return;
}
}
signup.submitting = true; signup.submitting = true;
try { try {
const payload = buildSignupPayload(); const payload = buildSignupPayload();
@ -304,12 +251,12 @@
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
<span>Tillbaka till turneringsöversikten</span> <span>Tillbaka till turneringsöversikten</span>
</a> </a>
<span class="tracking-[0.4em] text-indigo-300 uppercase">{tournament.game}</span> <span class="uppercase tracking-[0.4em] text-indigo-300">{tournament.game}</span>
</nav> </nav>
<header class="space-y-4 rounded-2xl bg-slate-900/70 p-6 shadow-lg"> <header class="space-y-4 rounded-2xl bg-slate-900/70 p-6 shadow-lg">
<div class="space-y-2"> <div class="space-y-2">
<p class="text-xs tracking-[0.4em] text-indigo-200 uppercase">VBytes LAN</p> <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> <h1 class="text-3xl font-bold sm:text-4xl">{tournament.title}</h1>
{#if tournament.tagline} {#if tournament.tagline}
<p class="text-base text-slate-300">{tournament.tagline}</p> <p class="text-base text-slate-300">{tournament.tagline}</p>
@ -318,19 +265,19 @@
<div class="grid gap-3 sm:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2">
{#if formattedStart} {#if formattedStart}
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3"> <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
<p class="text-xs tracking-wide text-indigo-200 uppercase">Start</p> <p class="text-xs uppercase tracking-wide text-indigo-200">Start</p>
<p class="text-sm text-slate-100">{formattedStart}</p> <p class="text-sm text-slate-100">{formattedStart}</p>
</div> </div>
{/if} {/if}
{#if tournament.location} {#if tournament.location}
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3"> <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
<p class="text-xs tracking-wide text-indigo-200 uppercase">Plats</p> <p class="text-xs uppercase tracking-wide text-indigo-200">Plats</p>
<p class="text-sm text-slate-100">{tournament.location}</p> <p class="text-sm text-slate-100">{tournament.location}</p>
</div> </div>
{/if} {/if}
{#if tournament.contact} {#if tournament.contact}
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 sm:col-span-2"> <div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 sm:col-span-2">
<p class="text-xs tracking-wide text-indigo-200 uppercase">Kontakt</p> <p class="text-xs uppercase tracking-wide text-indigo-200">Kontakt</p>
<p class="text-sm text-slate-100">{tournament.contact}</p> <p class="text-sm text-slate-100">{tournament.contact}</p>
</div> </div>
{/if} {/if}
@ -340,9 +287,7 @@
{#if tournament.description} {#if tournament.description}
<section class="space-y-3 rounded-2xl border border-slate-800 bg-slate-900/50 p-6"> <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> <h2 class="text-lg font-semibold text-slate-100">Beskrivning</h2>
<p class="text-sm leading-relaxed whitespace-pre-line text-slate-200"> <p class="whitespace-pre-line text-sm leading-relaxed text-slate-200">{tournament.description}</p>
{tournament.description}
</p>
</section> </section>
{/if} {/if}
@ -351,7 +296,7 @@
{#each tournament.sections as section, index (section.title + index)} {#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"> <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> <h3 class="text-base font-semibold text-indigo-200">{section.title}</h3>
<p class="text-sm leading-relaxed whitespace-pre-line text-slate-200">{section.body}</p> <p class="whitespace-pre-line text-sm leading-relaxed text-slate-200">{section.body}</p>
</article> </article>
{/each} {/each}
</section> </section>
@ -361,9 +306,7 @@
<header class="space-y-1"> <header class="space-y-1">
<h2 class="text-lg font-semibold text-slate-100">Anmälan</h2> <h2 class="text-lg font-semibold text-slate-100">Anmälan</h2>
{#if signupConfig.mode === 'team'} {#if signupConfig.mode === 'team'}
<p class="text-sm text-slate-300"> <p class="text-sm text-slate-300">Lagstorlek: {signupConfig.team_size.min}{signupConfig.team_size.max} spelare.</p>
Lagstorlek: {signupConfig.team_size.min}{signupConfig.team_size.max} spelare.
</p>
{:else} {:else}
<p class="text-sm text-slate-300">Individuell anmälan.</p> <p class="text-sm text-slate-300">Individuell anmälan.</p>
{/if} {/if}
@ -372,9 +315,7 @@
<form class="space-y-5" onsubmit={handleSignupSubmit}> <form class="space-y-5" onsubmit={handleSignupSubmit}>
{#if signupConfig.entry_fields.length > 0} {#if signupConfig.entry_fields.length > 0}
<div class="space-y-3"> <div class="space-y-3">
<h3 class="text-sm font-semibold tracking-wide text-slate-400 uppercase"> <h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Lag / deltagare</h3>
{entrySectionTitle}
</h3>
<div class="grid gap-3 md:grid-cols-2"> <div class="grid gap-3 md:grid-cols-2">
{#each signupConfig.entry_fields as field} {#each signupConfig.entry_fields as field}
<label class="flex flex-col gap-1 text-sm font-medium text-slate-200"> <label class="flex flex-col gap-1 text-sm font-medium text-slate-200">
@ -383,19 +324,10 @@
type={fieldInputType(field.field_type)} type={fieldInputType(field.field_type)}
required={field.required} required={field.required}
placeholder={field.placeholder ?? ''} placeholder={field.placeholder ?? ''}
inputmode={isAttendanceField(field) ? 'numeric' : undefined}
pattern={isAttendanceField(field) ? '\\d*' : undefined}
autocomplete={isAttendanceField(field) ? 'off' : undefined}
value={signup.entry[field.id]} value={signup.entry[field.id]}
oninput={(event) => oninput={(event) => (signup.entry[field.id] = (event.currentTarget as HTMLInputElement).value)}
(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"
class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-indigo-500 focus:ring focus:ring-indigo-500/40 focus:outline-none"
/> />
{#if isAttendanceField(field)}
<span class="text-xs text-slate-400"
>Ditt deltagar-ID hittar du i närvarolistan eller på ditt kort.</span
>
{/if}
</label> </label>
{/each} {/each}
</div> </div>
@ -410,32 +342,20 @@
aria-labelledby="signup-success-title" aria-labelledby="signup-success-title"
> >
<header class="space-y-2 text-center"> <header class="space-y-2 text-center">
<p class="text-xs tracking-[0.4em] text-indigo-300 uppercase">VBytes LAN</p> <p class="text-xs uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p>
<h2 <h2 id="signup-success-title" class="text-2xl font-semibold text-slate-100 sm:text-3xl">
id="signup-success-title"
class="text-2xl font-semibold text-slate-100 sm:text-3xl"
>
Anmälan bekräftad Anmälan bekräftad
</h2> </h2>
<p class="text-sm text-slate-300">Du är registrerad till {tournament.title}.</p> <p class="text-sm text-slate-300">Du är registrerad till {tournament.title}.</p>
{#if signup.successRegistrationId} {#if signup.successRegistrationId}
<p class="text-xs tracking-wide text-indigo-200 uppercase"> <p class="text-xs uppercase tracking-wide text-indigo-200">Anmälan #{signup.successRegistrationId}</p>
Anmälan #{signup.successRegistrationId}
</p>
{/if} {/if}
</header> </header>
<section <section class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-2">
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"> <div class="space-y-2 text-left">
<h3 class="text-sm font-semibold tracking-wide text-indigo-200 uppercase"> <h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Turnering</h3>
Turnering <p class="text-sm text-slate-300"><span class="font-medium text-slate-100">Spel:</span> {tournament.game}</p>
</h3>
<p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Spel:</span>
{tournament.game}
</p>
{#if tournament.start_at} {#if tournament.start_at}
<p class="text-sm text-slate-300"> <p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Start:</span> <span class="font-medium text-slate-100">Start:</span>
@ -444,15 +364,12 @@
{/if} {/if}
{#if tournament.location} {#if tournament.location}
<p class="text-sm text-slate-300"> <p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Plats:</span> <span class="font-medium text-slate-100">Plats:</span> {tournament.location}
{tournament.location}
</p> </p>
{/if} {/if}
</div> </div>
<div class="space-y-2 text-left"> <div class="space-y-2 text-left">
<h3 class="text-sm font-semibold tracking-wide text-indigo-200 uppercase"> <h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Format</h3>
Format
</h3>
{#if signupConfig.mode === 'team'} {#if signupConfig.mode === 'team'}
<p class="text-sm text-slate-300"> <p class="text-sm text-slate-300">
Lag {signupConfig.team_size.min}{signupConfig.team_size.max} spelare Lag {signupConfig.team_size.min}{signupConfig.team_size.max} spelare
@ -462,8 +379,7 @@
{/if} {/if}
{#if tournament.contact} {#if tournament.contact}
<p class="text-sm text-slate-300"> <p class="text-sm text-slate-300">
<span class="font-medium text-slate-100">Kontakt:</span> <span class="font-medium text-slate-100">Kontakt:</span> {tournament.contact}
{tournament.contact}
</p> </p>
{/if} {/if}
</div> </div>
@ -472,37 +388,28 @@
<section class="space-y-3"> <section class="space-y-3">
<h3 class="text-base font-semibold text-slate-100">Anmälningsuppgifter</h3> <h3 class="text-base font-semibold text-slate-100">Anmälningsuppgifter</h3>
{#if signupConfig.entry_fields.length === 0} {#if signupConfig.entry_fields.length === 0}
<p <p class="rounded-md border border-dashed border-slate-700 px-4 py-3 text-sm text-slate-300">
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. Den här turneringen kräver inga uppgifter utöver spelare.
</p> </p>
{:else} {:else}
<div class="grid gap-3 md:grid-cols-2"> <div class="grid gap-3 md:grid-cols-2">
{#each signupConfig.entry_fields as field} {#each signupConfig.entry_fields as field}
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3"> <div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
<p class="text-xs tracking-wide text-indigo-200 uppercase"> <p class="text-xs uppercase tracking-wide text-indigo-200">{field.label}</p>
{field.label} <p class="mt-1 text-sm text-slate-100">{signup.submittedEntry[field.id] || '—'}</p>
</p>
<p class="mt-1 text-sm text-slate-100">
{signup.submittedEntry[field.id] || '—'}
</p>
</div> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
</section> </section>
{#if signupConfig.mode === 'team'}
<section class="space-y-3"> <section class="space-y-3">
<h3 class="text-base font-semibold text-slate-100">Spelare</h3> <h3 class="text-base font-semibold text-slate-100">Spelare</h3>
{#if signupConfig.participant_fields.length === 0} {#if signupConfig.participant_fields.length === 0}
{#if signup.submittedParticipants.length === 0} {#if signup.submittedParticipants.length === 0}
<p class="text-sm text-slate-300">Inga spelare angivna.</p> <p class="text-sm text-slate-300">Inga spelare angivna.</p>
{:else} {:else}
<p class="text-sm text-slate-300"> <p class="text-sm text-slate-300">Antal spelare: {signup.submittedParticipants.length}</p>
Antal spelare: {signup.submittedParticipants.length}
</p>
{/if} {/if}
{:else if signup.submittedParticipants.length === 0} {:else if signup.submittedParticipants.length === 0}
<p class="text-sm text-slate-300">Inga spelare angivna.</p> <p class="text-sm text-slate-300">Inga spelare angivna.</p>
@ -510,11 +417,7 @@
<div class="space-y-3"> <div class="space-y-3">
{#each signup.submittedParticipants as participant, index} {#each signup.submittedParticipants as participant, index}
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3"> <div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
<p <p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Spelare {index + 1}</p>
class="text-xs font-semibold tracking-wide text-indigo-200 uppercase"
>
Spelare {index + 1}
</p>
<ul class="mt-2 space-y-1 text-sm text-slate-100"> <ul class="mt-2 space-y-1 text-sm text-slate-100">
{#each signupConfig.participant_fields as field} {#each signupConfig.participant_fields as field}
<li> <li>
@ -528,7 +431,6 @@
</div> </div>
{/if} {/if}
</section> </section>
{/if}
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<a <a
@ -541,21 +443,31 @@
</div> </div>
</div> </div>
{/if} {/if}
{/if} {/if}
{#if signupConfig.mode === 'team'}
<div class="space-y-3"> <div class="space-y-3">
<h3 class="text-sm font-semibold tracking-wide text-slate-400 uppercase">Spelare</h3> <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} {#if signup.participants.length > 0}
<div class="space-y-4"> <div class="space-y-4">
{#each signup.participants as participant, index (index)} {#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="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-sm font-semibold text-slate-200"> <span class="text-sm font-semibold text-slate-200">{participantDisplayName(index)}</span>
{participantDisplayName(index)} {#if signupConfig.mode === 'team' && canRemoveParticipant()}
</span>
{#if canRemoveParticipant()}
<button <button
type="button" type="button"
onclick={() => removeParticipant(index)} onclick={() => removeParticipant(index)}
@ -577,11 +489,8 @@
required={field.required} required={field.required}
placeholder={field.placeholder ?? ''} placeholder={field.placeholder ?? ''}
value={participant[field.id] ?? ''} value={participant[field.id] ?? ''}
oninput={(event) => oninput={(event) => (participant[field.id] = (event.currentTarget as HTMLInputElement).value)}
(participant[field.id] = ( 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"
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:ring focus:ring-indigo-500/40 focus:outline-none"
/> />
</label> </label>
{/each} {/each}
@ -593,25 +502,13 @@
{/each} {/each}
</div> </div>
{/if} {/if}
<p class="text-xs text-slate-400">
Du kan lägga till upp till {signupConfig.team_size.max} spelare per lag. Minst
{signupConfig.team_size.min} krävs.
</p>
</div> </div>
{/if}
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
{#if signup.error} {#if signup.error}
<p class="rounded-md border border-red-400 bg-red-500/10 px-4 py-2 text-red-200"> <p class="rounded-md border border-red-400 bg-red-500/10 px-4 py-2 text-red-200">{signup.error}</p>
{signup.error}
</p>
{:else if signup.success} {:else if signup.success}
<p <p class="rounded-md border border-emerald-400 bg-emerald-500/10 px-4 py-2 text-emerald-200">{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} {:else}
<p class="text-slate-400">Din anmälan skickas direkt till arrangören.</p> <p class="text-slate-400">Din anmälan skickas direkt till arrangören.</p>
{/if} {/if}
@ -629,10 +526,7 @@
<footer class="mt-auto flex items-center justify-between text-xs text-slate-500"> <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> <p>Senast uppdaterad {formatDateTime(tournament.updated_at) ?? tournament.updated_at}</p>
<a <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">
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 Administrera
</a> </a>
</footer> </footer>

View file

@ -1,26 +0,0 @@
import type { RequestHandler } from './$types';
import { proxyRequest } from '$lib/server/backend';
export const GET: RequestHandler = async (event) => {
const { response, setCookies } = await proxyRequest(event, '/events/public', {
method: 'GET'
});
const headers = new Headers();
const contentType = response.headers.get('content-type');
if (contentType) {
headers.set('content-type', contentType);
} else {
headers.set('content-type', 'text/event-stream');
}
for (const cookie of setCookies) {
headers.append('set-cookie', cookie);
}
headers.set('cache-control', 'no-cache');
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers
});
};