Compare commits
9 commits
687c3943d4
...
af4dbdea91
| Author | SHA1 | Date | |
|---|---|---|---|
| af4dbdea91 | |||
| c61bcedb3a | |||
| a19b7883d6 | |||
| 73aea84e8e | |||
| 619b51e990 | |||
| bfd4aa10fb | |||
| 1a4d1cf73c | |||
| 06ba7fe85a | |||
| 535c285a33 |
12 changed files with 1711 additions and 875 deletions
2
.env
2
.env
|
|
@ -5,4 +5,4 @@ ADMIN_PASSWORD=admin
|
|||
JWT_COOKIE_SECURE=false
|
||||
ENABLE_HTTPS_REDIRECT=false
|
||||
WEB_PORT=3000
|
||||
CSRF_ALLOWED_ORIGINS=http://192.168.1.204:3000
|
||||
CSRF_ALLOWED_ORIGINS=http://localhost:3000
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ async fn main() -> Result<(), rocket::Error> {
|
|||
|
||||
let rocket = rocket::build()
|
||||
.manage(state)
|
||||
.mount("/", routes![healthz, login, logout, events])
|
||||
.mount("/", routes![healthz, login, logout, events, public_events])
|
||||
.mount("/persons", routes::persons::routes())
|
||||
.mount("/tournament", routes::tournaments::routes());
|
||||
|
||||
|
|
@ -157,3 +157,25 @@ 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@ use sqlx::FromRow;
|
|||
use std::collections::HashMap;
|
||||
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)]
|
||||
pub struct TournamentInfo {
|
||||
pub id: i32,
|
||||
|
|
@ -174,6 +178,7 @@ impl TournamentSignupConfig {
|
|||
|
||||
self.entry_fields = normalize_signup_fields(self.entry_fields);
|
||||
self.participant_fields = normalize_signup_fields(self.participant_fields);
|
||||
ensure_attendance_field_for_mode(&mut self);
|
||||
|
||||
self
|
||||
}
|
||||
|
|
@ -213,6 +218,92 @@ fn normalize_signup_fields(mut fields: Vec<TournamentSignupField>) -> Vec<Tourna
|
|||
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 {
|
||||
let mut slug = input
|
||||
.trim()
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
use crate::auth::AuthUser;
|
||||
use crate::error::ApiError;
|
||||
use crate::models::{
|
||||
AppEvent, CreateTournamentRequest, TournamentFieldType, TournamentInfo, TournamentInfoData,
|
||||
TournamentItemResponse, TournamentListResponse, TournamentParticipantRow,
|
||||
AppEvent, CreateTournamentRequest, Person, TournamentFieldType, TournamentInfo,
|
||||
TournamentInfoData, TournamentItemResponse, TournamentListResponse, TournamentParticipantRow,
|
||||
TournamentParticipantValueRow, TournamentRegistrationDetailResponse,
|
||||
TournamentRegistrationItem, TournamentRegistrationListResponse, TournamentRegistrationResponse,
|
||||
TournamentRegistrationRow, TournamentRegistrationValueRow, TournamentSection,
|
||||
TournamentSectionRecord, TournamentSignupConfig, TournamentSignupField,
|
||||
TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest,
|
||||
ATTENDANCE_ID_FIELD_ID,
|
||||
};
|
||||
use crate::AppState;
|
||||
use rocket::http::Status;
|
||||
|
|
@ -15,7 +16,7 @@ use rocket::serde::json::Json;
|
|||
use rocket::Route;
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::{Postgres, Transaction};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
rocket::routes![
|
||||
|
|
@ -107,6 +108,46 @@ fn build_registration_url(slug: &str) -> String {
|
|||
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 {
|
||||
if left.len() != right.len() {
|
||||
return false;
|
||||
|
|
@ -219,6 +260,7 @@ fn build_signup_config(
|
|||
entry_fields,
|
||||
participant_fields,
|
||||
}
|
||||
.normalized()
|
||||
}
|
||||
|
||||
async fn load_tournament_data(
|
||||
|
|
@ -1062,12 +1104,6 @@ fn validate_submission(
|
|||
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>")]
|
||||
pub async fn create_registration_by_slug(
|
||||
state: &rocket::State<AppState>,
|
||||
|
|
@ -1136,41 +1172,128 @@ pub async fn create_registration_by_slug(
|
|||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
let entry_label = config
|
||||
.entry_fields
|
||||
.first()
|
||||
.and_then(|field| entry_values.get(&field.id))
|
||||
.and_then(|value| trimmed(Some(value)));
|
||||
|
||||
let entry_label_requires_unique = config
|
||||
.entry_fields
|
||||
.first()
|
||||
.map(|field| field.unique)
|
||||
.unwrap_or(false);
|
||||
|
||||
if entry_label_requires_unique {
|
||||
if let Some(label) = entry_label.clone() {
|
||||
let is_duplicate = sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tournament_registrations
|
||||
WHERE tournament_id = $1 AND entry_label = $2
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(info.id)
|
||||
.bind(&label)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if is_duplicate {
|
||||
if !field_map.contains_key(ATTENDANCE_ID_FIELD_ID) {
|
||||
return Err(ApiError::bad_request(
|
||||
"Den här spelaren eller laget är redan anmäld till turneringen.",
|
||||
"Turneringen är felkonfigurerad och saknar obligatoriskt deltagar-ID-fält.",
|
||||
));
|
||||
}
|
||||
|
||||
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 {
|
||||
if !field.unique {
|
||||
|
|
@ -1602,44 +1725,128 @@ pub async fn update_registration_by_slug(
|
|||
|
||||
let mut tx = state.db.begin().await?;
|
||||
|
||||
let entry_label = config
|
||||
.entry_fields
|
||||
.first()
|
||||
.and_then(|field| entry_values.get(&field.id))
|
||||
.and_then(|value| trimmed(Some(value)));
|
||||
|
||||
let entry_label_requires_unique = config
|
||||
.entry_fields
|
||||
.first()
|
||||
.map(|field| field.unique)
|
||||
.unwrap_or(false);
|
||||
|
||||
if entry_label_requires_unique {
|
||||
if let Some(label) = entry_label.clone() {
|
||||
let is_duplicate = sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM tournament_registrations
|
||||
WHERE tournament_id = $1
|
||||
AND entry_label = $2
|
||||
AND id <> $3
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(info.id)
|
||||
.bind(&label)
|
||||
.bind(registration.id)
|
||||
.fetch_one(&state.db)
|
||||
.await?;
|
||||
|
||||
if is_duplicate {
|
||||
if !field_map.contains_key(ATTENDANCE_ID_FIELD_ID) {
|
||||
return Err(ApiError::bad_request(
|
||||
"Den här spelaren eller laget är redan anmäld till turneringen.",
|
||||
"Turneringen är felkonfigurerad och saknar obligatoriskt deltagar-ID-fält.",
|
||||
));
|
||||
}
|
||||
|
||||
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 {
|
||||
if !field.unique {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"@sveltejs/kit": "^2.22.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
|
|
@ -210,6 +211,8 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ export type AppEvent =
|
|||
| { type: 'tournament_upserted'; tournament: TournamentInfo }
|
||||
| { type: 'tournament_deleted'; tournament_id: number };
|
||||
|
||||
export function listenToEvents(onEvent: (event: AppEvent) => void) {
|
||||
export function listenToEvents(onEvent: (event: AppEvent) => void, endpoint = '/api/events') {
|
||||
let stopped = false;
|
||||
let source: EventSource | null = null;
|
||||
|
||||
function connect() {
|
||||
if (stopped) return;
|
||||
source = new EventSource('/api/events');
|
||||
source = new EventSource(endpoint);
|
||||
source.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as AppEvent;
|
||||
|
|
|
|||
|
|
@ -5,10 +5,16 @@ export type TournamentEvent =
|
|||
| { type: 'tournament_upserted'; tournament: TournamentInfo }
|
||||
| { type: 'tournament_deleted'; tournament_id: number };
|
||||
|
||||
type TournamentEventOptions = {
|
||||
endpoint?: string;
|
||||
};
|
||||
|
||||
export function listenToTournamentEvents(
|
||||
onUpsert: (tournament: TournamentInfo) => void,
|
||||
onDelete: (tournamentId: number) => void
|
||||
onDelete: (tournamentId: number) => void,
|
||||
options: TournamentEventOptions = {}
|
||||
) {
|
||||
const endpoint = options.endpoint ?? '/api/events';
|
||||
return listenToEvents((event) => {
|
||||
if (event.type === 'tournament_upserted') {
|
||||
onUpsert(event.tournament);
|
||||
|
|
@ -17,5 +23,5 @@ export function listenToTournamentEvents(
|
|||
if (event.type === 'tournament_deleted') {
|
||||
onDelete(event.tournament_id);
|
||||
}
|
||||
});
|
||||
}, endpoint);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,25 @@ type SignupFieldForm = TournamentSignupField & {
|
|||
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 = {
|
||||
mode: 'solo' | 'team';
|
||||
team_size: { min: number; max: number };
|
||||
|
|
@ -74,7 +93,10 @@ function nextFieldId(label: string) {
|
|||
return `${base}-${fieldIdCounter.toString(36)}`;
|
||||
}
|
||||
|
||||
function createSignupField(label: string, field_type: TournamentFieldType = 'text'): SignupFieldForm {
|
||||
function createSignupField(
|
||||
label: string,
|
||||
field_type: TournamentFieldType = 'text'
|
||||
): SignupFieldForm {
|
||||
return {
|
||||
id: nextFieldId(label),
|
||||
label,
|
||||
|
|
@ -86,6 +108,18 @@ function createSignupField(label: string, field_type: TournamentFieldType = 'tex
|
|||
}
|
||||
|
||||
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 {
|
||||
id: field.id || nextFieldId(field.label),
|
||||
label: field.label,
|
||||
|
|
@ -96,38 +130,126 @@ function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
|
|||
};
|
||||
}
|
||||
|
||||
function createDefaultSignup(): SignupConfigForm {
|
||||
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 {
|
||||
const signup: SignupConfigForm = {
|
||||
mode: 'solo',
|
||||
team_size: { min: 1, max: 1 },
|
||||
entry_fields: [createSignupField('Lag / spelarnamn')],
|
||||
entry_fields: [createSignupField('Spelarnamn')],
|
||||
participant_fields: []
|
||||
};
|
||||
ensureAttendancePlacement(signup);
|
||||
return signup;
|
||||
}
|
||||
|
||||
function cloneSignupConfig(config: TournamentSignupConfig | null | undefined): SignupConfigForm {
|
||||
if (!config) return createDefaultSignup();
|
||||
const entry_fields = (config.entry_fields ?? []).map(cloneSignupField);
|
||||
const participant_fields = (config.participant_fields ?? []).map(cloneSignupField);
|
||||
if (entry_fields.length === 0) {
|
||||
entry_fields.push(createSignupField('Lag/Spelarnamn'));
|
||||
}
|
||||
if (participant_fields.length === 0) {
|
||||
participant_fields.push(createSignupField('Spelare'));
|
||||
}
|
||||
const mode = config.mode === 'team' ? 'team' : 'solo';
|
||||
if (mode === 'solo') {
|
||||
participant_fields.length = 0;
|
||||
}
|
||||
return {
|
||||
mode,
|
||||
|
||||
const normalizedSize = Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
config.team_size?.max ??
|
||||
config.team_size?.min ??
|
||||
1
|
||||
)
|
||||
);
|
||||
|
||||
const signup: SignupConfigForm = {
|
||||
mode: config.mode === 'team' ? 'team' : 'solo',
|
||||
team_size: {
|
||||
min: config.team_size?.min ?? 1,
|
||||
max: config.team_size?.max ?? 1
|
||||
min: normalizedSize,
|
||||
max: normalizedSize
|
||||
},
|
||||
entry_fields,
|
||||
participant_fields
|
||||
entry_fields: (config.entry_fields ?? []).map(cloneSignupField),
|
||||
participant_fields: (config.participant_fields ?? []).map(cloneSignupField)
|
||||
};
|
||||
|
||||
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) {
|
||||
|
|
@ -137,11 +259,14 @@ function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
|
|||
|
||||
function addEntryField(form: TournamentForm) {
|
||||
form.signup.entry_fields = [...form.signup.entry_fields, createSignupField('Nytt fält')];
|
||||
ensureAttendancePlacement(form.signup);
|
||||
}
|
||||
|
||||
function removeEntryField(form: TournamentForm, index: number) {
|
||||
if (form.signup.entry_fields.length <= 1) return;
|
||||
const target = form.signup.entry_fields[index];
|
||||
if (!target || isAttendanceField(target)) return;
|
||||
form.signup.entry_fields = form.signup.entry_fields.filter((_, idx) => idx !== index);
|
||||
ensureAttendancePlacement(form.signup);
|
||||
}
|
||||
|
||||
function addParticipantField(form: TournamentForm) {
|
||||
|
|
@ -149,35 +274,42 @@ function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
|
|||
...form.signup.participant_fields,
|
||||
createSignupField('Spelarinfo')
|
||||
];
|
||||
ensureAttendancePlacement(form.signup);
|
||||
}
|
||||
|
||||
function removeParticipantField(form: TournamentForm, index: number) {
|
||||
if (form.signup.participant_fields.length <= 1) return;
|
||||
form.signup.participant_fields = form.signup.participant_fields.filter((_, idx) => idx !== index);
|
||||
const target = form.signup.participant_fields[index];
|
||||
if (!target || isAttendanceField(target)) return;
|
||||
form.signup.participant_fields = form.signup.participant_fields.filter(
|
||||
(_, idx) => idx !== index
|
||||
);
|
||||
ensureAttendancePlacement(form.signup);
|
||||
}
|
||||
|
||||
function setSignupMode(form: TournamentForm, mode: 'solo' | 'team') {
|
||||
form.signup.mode = mode;
|
||||
if (mode === 'solo') {
|
||||
form.signup.team_size.min = 1;
|
||||
form.signup.team_size.max = 1;
|
||||
setTeamSize(form, '1');
|
||||
form.signup.participant_fields = [];
|
||||
} else {
|
||||
if (form.signup.team_size.min < 1) form.signup.team_size.min = 1;
|
||||
if (form.signup.team_size.max < form.signup.team_size.min) {
|
||||
form.signup.team_size.max = form.signup.team_size.min;
|
||||
}
|
||||
const currentSize = Math.max(
|
||||
1,
|
||||
Number.isFinite(form.signup.team_size.min) ? form.signup.team_size.min : 1,
|
||||
Number.isFinite(form.signup.team_size.max) ? form.signup.team_size.max : 1
|
||||
);
|
||||
setTeamSize(form, String(currentSize));
|
||||
if (form.signup.participant_fields.length === 0) {
|
||||
form.signup.participant_fields = [createSignupField('Spelare')];
|
||||
}
|
||||
}
|
||||
ensureAttendancePlacement(form.signup);
|
||||
}
|
||||
|
||||
function setTeamSize(form: TournamentForm, key: 'min' | 'max', value: string) {
|
||||
function setTeamSize(form: TournamentForm, value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
const fallback = key === 'min' ? 1 : form.signup.team_size.min;
|
||||
const numeric = Number.isNaN(parsed) ? fallback : Math.max(1, parsed);
|
||||
form.signup.team_size[key] = numeric;
|
||||
const numeric = Number.isNaN(parsed) ? 1 : Math.max(1, parsed);
|
||||
form.signup.team_size.min = numeric;
|
||||
form.signup.team_size.max = numeric;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
|
|
@ -245,6 +377,19 @@ function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
|
|||
const allowedTypes: TournamentFieldType[] = ['text', 'email', 'tel', 'discord'];
|
||||
|
||||
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 id = slugify(baseId) || 'field';
|
||||
const label = field.label.trim() || 'Fält';
|
||||
|
|
@ -257,7 +402,7 @@ function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
|
|||
field_type: type,
|
||||
required: Boolean(field.required),
|
||||
placeholder: placeholder ? placeholder : null,
|
||||
unique: Boolean(field.unique),
|
||||
unique: Boolean(field.unique)
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -269,23 +414,28 @@ function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
|
|||
};
|
||||
|
||||
const mode = signup.mode === 'team' ? 'team' : 'solo';
|
||||
let min = toNumber(signup.team_size.min);
|
||||
let max = toNumber(signup.team_size.max);
|
||||
if (max < min) max = min;
|
||||
if (mode === 'solo') {
|
||||
min = 1;
|
||||
max = 1;
|
||||
let teamSize = toNumber(signup.team_size.min);
|
||||
if (mode === 'team') {
|
||||
teamSize = Math.max(teamSize, toNumber(signup.team_size.max));
|
||||
} else {
|
||||
teamSize = 1;
|
||||
}
|
||||
|
||||
const entry_fields = signup.entry_fields.map(normalizeField);
|
||||
const participant_fields =
|
||||
mode === 'team'
|
||||
? signup.participant_fields.map(normalizeField)
|
||||
: [];
|
||||
const draft: SignupConfigForm = {
|
||||
mode,
|
||||
team_size: { min: teamSize, max: teamSize },
|
||||
entry_fields: signup.entry_fields.map((field) => ({ ...field })),
|
||||
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 {
|
||||
mode,
|
||||
team_size: { min, max },
|
||||
team_size: { min: teamSize, max: teamSize },
|
||||
entry_fields,
|
||||
participant_fields
|
||||
};
|
||||
|
|
@ -356,10 +506,7 @@ function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
|
|||
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 currentSlug = slugParam();
|
||||
const targetSlug = options.slug ?? null;
|
||||
|
|
@ -393,7 +540,7 @@ function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
|
|||
manageState.error = '';
|
||||
setTab('manage', {
|
||||
slug: tournament.slug,
|
||||
replace: options.replace ?? false,
|
||||
replace: options.replace ?? false
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -448,8 +595,7 @@ function cloneSignupField(field: TournamentSignupField): SignupFieldForm {
|
|||
void refreshTournaments({ preferredId: tournament.id });
|
||||
},
|
||||
(tournamentId) => {
|
||||
const preferredId =
|
||||
manageState.selectedId === tournamentId ? null : manageState.selectedId;
|
||||
const preferredId = manageState.selectedId === tournamentId ? null : manageState.selectedId;
|
||||
void refreshTournaments({ preferredId: preferredId ?? undefined });
|
||||
}
|
||||
);
|
||||
|
|
@ -738,7 +884,9 @@ function registrationSummary(tournament: TournamentInfo) {
|
|||
}
|
||||
|
||||
const count = Math.max(totalParticipants, totalRegistrations);
|
||||
return count === 0 ? 'Inga anmälningar ännu' : `${count} ${count === 1 ? 'spelare' : 'spelare'}`;
|
||||
return count === 0
|
||||
? 'Inga anmälningar ännu'
|
||||
: `${count} ${count === 1 ? 'spelare' : 'spelare'}`;
|
||||
}
|
||||
|
||||
const selectedTournamentInfo = $derived(() => {
|
||||
|
|
@ -784,15 +932,21 @@ function sectionKey(index: number) {
|
|||
{#if filteredOverview().length > 0}
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
{#each filteredOverview() as tournament (tournament.id)}
|
||||
<article class="flex h-full flex-col justify-between rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<article
|
||||
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">
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.3em] text-indigo-400">{tournament.game}</p>
|
||||
<p class="text-xs font-semibold tracking-[0.3em] text-indigo-400 uppercase">
|
||||
{tournament.game}
|
||||
</p>
|
||||
<h3 class="text-lg font-semibold text-slate-900">{tournament.title}</h3>
|
||||
{#if tournament.tagline}
|
||||
<p class="text-sm text-slate-600">{tournament.tagline}</p>
|
||||
{/if}
|
||||
{#if tournament.start_at}
|
||||
<p class="text-xs text-slate-500">{formatDateTime(tournament.start_at) ?? tournament.start_at}</p>
|
||||
<p class="text-xs text-slate-500">
|
||||
{formatDateTime(tournament.start_at) ?? tournament.start_at}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-slate-500">{registrationSummary(tournament)}</p>
|
||||
</div>
|
||||
|
|
@ -801,14 +955,14 @@ function sectionKey(index: number) {
|
|||
href={`/tournament/${tournament.slug}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="rounded-full bg-indigo-600 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-indigo-700"
|
||||
class="rounded-full bg-indigo-600 px-4 py-2 text-xs font-semibold tracking-wide text-white uppercase transition hover:bg-indigo-700"
|
||||
>
|
||||
Öppna sida
|
||||
</a>
|
||||
{#if tournament.slug}
|
||||
<a
|
||||
href={`/admin/tournament/${tournament.slug}/registrations`}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Visa anmälningar
|
||||
</a>
|
||||
|
|
@ -816,7 +970,7 @@ function sectionKey(index: number) {
|
|||
<button
|
||||
type="button"
|
||||
onclick={() => selectTournament(tournament)}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Redigera
|
||||
</button>
|
||||
|
|
@ -825,7 +979,9 @@ function sectionKey(index: number) {
|
|||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
|
||||
<p
|
||||
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".
|
||||
</p>
|
||||
{/if}
|
||||
|
|
@ -834,7 +990,9 @@ function sectionKey(index: number) {
|
|||
<section class="space-y-6 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<header class="space-y-1">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Skapa turnering</h2>
|
||||
<p class="text-sm text-slate-600">Fälten används även för att automatiskt bygga anmälningssidan.</p>
|
||||
<p class="text-sm text-slate-600">
|
||||
Fälten används även för att automatiskt bygga anmälningssidan.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form class="space-y-5" onsubmit={handleCreate}>
|
||||
|
|
@ -921,7 +1079,9 @@ function sectionKey(index: number) {
|
|||
|
||||
<section class="space-y-3">
|
||||
<header class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Sektioner för anmälningssidan</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Sektioner för anmälningssidan
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => addSection(createState.form)}
|
||||
|
|
@ -932,7 +1092,9 @@ function sectionKey(index: number) {
|
|||
</header>
|
||||
|
||||
{#if createState.form.sections.length === 0}
|
||||
<p class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500">
|
||||
<p
|
||||
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.
|
||||
</p>
|
||||
{:else}
|
||||
|
|
@ -941,7 +1103,8 @@ function sectionKey(index: number) {
|
|||
<div class="rounded-md border border-slate-200 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<label class="flex-1 text-sm font-medium text-slate-700">
|
||||
<span class="block text-xs uppercase tracking-wide text-slate-500">Titel</span>
|
||||
<span class="block text-xs tracking-wide text-slate-500 uppercase">Titel</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={section.title}
|
||||
|
|
@ -957,7 +1120,9 @@ function sectionKey(index: number) {
|
|||
</button>
|
||||
</div>
|
||||
<label class="mt-3 block text-sm font-medium text-slate-700">
|
||||
<span class="block text-xs uppercase tracking-wide text-slate-500">Innehåll</span>
|
||||
<span class="block text-xs tracking-wide text-slate-500 uppercase"
|
||||
>Innehåll</span
|
||||
>
|
||||
<textarea
|
||||
rows={3}
|
||||
bind:value={section.body}
|
||||
|
|
@ -972,8 +1137,12 @@ function sectionKey(index: number) {
|
|||
|
||||
<section class="space-y-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<header class="space-y-1">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Anmälningsinställningar</h3>
|
||||
<p class="text-sm text-slate-600">Ställ in vad som krävs när spelare eller lag registrerar sig.</p>
|
||||
<h3 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Anmälningsinställningar
|
||||
</h3>
|
||||
<p class="text-sm text-slate-600">
|
||||
Ställ in vad som krävs när spelare eller lag registrerar sig.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
|
|
@ -982,8 +1151,10 @@ function sectionKey(index: number) {
|
|||
<select
|
||||
value={createState.form.signup.mode}
|
||||
onchange={(event) =>
|
||||
setSignupMode(createState.form, (event.currentTarget as HTMLSelectElement).value as 'solo' | 'team')
|
||||
}
|
||||
setSignupMode(
|
||||
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"
|
||||
>
|
||||
<option value="solo">Individuell</option>
|
||||
|
|
@ -991,24 +1162,18 @@ function sectionKey(index: number) {
|
|||
</select>
|
||||
</label>
|
||||
{#if createState.form.signup.mode === 'team'}
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="grid gap-3">
|
||||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
|
||||
<span>Min. spelare</span>
|
||||
<span>Spelare per lag</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={createState.form.signup.team_size.min}
|
||||
oninput={(event) => setTeamSize(createState.form, 'min', (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"
|
||||
/>
|
||||
</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)}
|
||||
oninput={(event) =>
|
||||
setTeamSize(
|
||||
createState.form,
|
||||
(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"
|
||||
/>
|
||||
</label>
|
||||
|
|
@ -1018,7 +1183,9 @@ function sectionKey(index: number) {
|
|||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Anmälningsfält</h4>
|
||||
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Anmälningsfält
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => addEntryField(createState.form)}
|
||||
|
|
@ -1029,7 +1196,9 @@ function sectionKey(index: number) {
|
|||
</div>
|
||||
|
||||
{#if createState.form.signup.entry_fields.length === 0}
|
||||
<p class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500">
|
||||
<p
|
||||
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.
|
||||
</p>
|
||||
{:else}
|
||||
|
|
@ -1046,9 +1215,18 @@ function sectionKey(index: number) {
|
|||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
|
||||
<span>Typ</span>
|
||||
<span class="flex items-center gap-2">
|
||||
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
|
||||
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"
|
||||
>
|
||||
{#each fieldTypeOptions as option}
|
||||
|
|
@ -1057,29 +1235,47 @@ function sectionKey(index: number) {
|
|||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" bind:checked={field.required} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<input
|
||||
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>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" bind:checked={field.unique} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<input
|
||||
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>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeEntryField(createState.form, index)}
|
||||
disabled={createState.form.signup.entry_fields.length <= 1}
|
||||
disabled={isAttendanceField(field) ||
|
||||
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"
|
||||
>
|
||||
Ta bort
|
||||
</button>
|
||||
</div>
|
||||
<label class="block text-sm font-medium text-slate-700">
|
||||
<span class="block text-xs uppercase tracking-wide text-slate-500">Platshållare (valfritt)</span>
|
||||
<span class="block text-xs tracking-wide text-slate-500 uppercase"
|
||||
>Platshållare (valfritt)</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
{#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>
|
||||
</div>
|
||||
{/each}
|
||||
|
|
@ -1090,7 +1286,9 @@ function sectionKey(index: number) {
|
|||
{#if createState.form.signup.mode === 'team'}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Spelaruppgifter</h4>
|
||||
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Spelaruppgifter
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => addParticipantField(createState.form)}
|
||||
|
|
@ -1101,7 +1299,9 @@ function sectionKey(index: number) {
|
|||
</div>
|
||||
|
||||
{#if createState.form.signup.participant_fields.length === 0}
|
||||
<p class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500">
|
||||
<p
|
||||
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.
|
||||
</p>
|
||||
{:else}
|
||||
|
|
@ -1129,11 +1329,19 @@ function sectionKey(index: number) {
|
|||
</select>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" bind:checked={field.required} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<input
|
||||
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>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||
<input type="checkbox" bind:checked={field.unique} class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500" />
|
||||
<input
|
||||
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>
|
||||
</label>
|
||||
<button
|
||||
|
|
@ -1146,7 +1354,9 @@ function sectionKey(index: number) {
|
|||
</button>
|
||||
</div>
|
||||
<label class="block text-sm font-medium text-slate-700">
|
||||
<span class="block text-xs uppercase tracking-wide text-slate-500">Platshållare (valfritt)</span>
|
||||
<span class="block text-xs tracking-wide text-slate-500 uppercase"
|
||||
>Platshållare (valfritt)</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={field.placeholder}
|
||||
|
|
@ -1186,54 +1396,76 @@ function sectionKey(index: number) {
|
|||
{#if createState.form.title.trim()}
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-slate-500">Visas för deltagare på /tournament/{createState.form.slug || 'slug'}.</p>
|
||||
<p class="mt-2 text-sm text-slate-500">
|
||||
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="space-y-1">
|
||||
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">{createState.form.game || 'Spel'}</p>
|
||||
<p class="text-xs tracking-[0.4em] text-indigo-300 uppercase">
|
||||
{createState.form.game || 'Spel'}
|
||||
</p>
|
||||
<h4 class="text-2xl font-bold sm:text-3xl">{createState.form.title || 'Titel'}</h4>
|
||||
{#if createState.form.tagline.trim()}
|
||||
<p class="text-sm text-slate-300">{createState.form.tagline}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if createState.form.start_at}
|
||||
<p class="text-sm text-indigo-200">Start {formatDateTime(new Date(createState.form.start_at).toISOString()) ?? ''}</p>
|
||||
<p class="text-sm text-indigo-200">
|
||||
Start {formatDateTime(new Date(createState.form.start_at).toISOString()) ?? ''}
|
||||
</p>
|
||||
{/if}
|
||||
{#if createState.form.location.trim()}
|
||||
<p class="text-sm text-slate-200">Plats: {createState.form.location}</p>
|
||||
{/if}
|
||||
{#if createState.form.description.trim()}
|
||||
<p class="whitespace-pre-line text-sm text-slate-200">{createState.form.description}</p>
|
||||
<p class="text-sm whitespace-pre-line text-slate-200">
|
||||
{createState.form.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if createState.form.sections.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each payloadSections(createState.form.sections) as section, index (sectionKey(index))}
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-indigo-200">{section.title}</p>
|
||||
<p class="whitespace-pre-line text-sm text-slate-200">{section.body}</p>
|
||||
<p class="text-sm whitespace-pre-line text-slate-200">{section.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if createState.form.contact.trim()}
|
||||
<p class="text-xs uppercase tracking-wide text-indigo-100">Kontakt: {createState.form.contact}</p>
|
||||
<p class="text-xs tracking-wide text-indigo-100 uppercase">
|
||||
Kontakt: {createState.form.contact}
|
||||
</p>
|
||||
{/if}
|
||||
{#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">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Anmälningsfält</p>
|
||||
<p class="text-xs font-semibold tracking-wide text-indigo-200 uppercase">
|
||||
Anmälningsfält
|
||||
</p>
|
||||
<ul class="space-y-1 text-sm text-slate-200">
|
||||
{#each createState.form.signup.entry_fields as field}
|
||||
<li>{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}</li>
|
||||
<li>
|
||||
{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type}
|
||||
{field.required ? '• obligatoriskt' : ''}
|
||||
{field.unique ? '• unikt' : ''}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#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">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Spelaruppgifter</p>
|
||||
<p class="text-xs font-semibold tracking-wide text-indigo-200 uppercase">
|
||||
Spelaruppgifter
|
||||
</p>
|
||||
<ul class="space-y-1 text-sm text-slate-200">
|
||||
{#each createState.form.signup.participant_fields as field}
|
||||
<li>{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}</li>
|
||||
<li>
|
||||
{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type}
|
||||
{field.required ? '• obligatoriskt' : ''}
|
||||
{field.unique ? '• unikt' : ''}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -1246,7 +1478,9 @@ function sectionKey(index: number) {
|
|||
<section class="space-y-6 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<header class="space-y-1">
|
||||
<h2 class="text-xl font-semibold text-slate-900">Hantera turneringar</h2>
|
||||
<p class="text-sm text-slate-600">Välj en turnering i listan för att uppdatera innehållet.</p>
|
||||
<p class="text-sm text-slate-600">
|
||||
Välj en turnering i listan för att uppdatera innehållet.
|
||||
</p>
|
||||
{#if selectedTournamentInfo()}
|
||||
{@const selected = selectedTournamentInfo()!}
|
||||
<p class="text-sm text-indigo-600">{registrationSummary(selected)}</p>
|
||||
|
|
@ -1256,7 +1490,9 @@ function sectionKey(index: number) {
|
|||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Turneringar</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Turneringar
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => setTab('create')}
|
||||
|
|
@ -1279,7 +1515,9 @@ function sectionKey(index: number) {
|
|||
}`}
|
||||
>
|
||||
{tournament.title}
|
||||
<span class="block text-xs font-normal text-indigo-200/90">{overviewCardSubtitle(tournament)}</span>
|
||||
<span class="block text-xs font-normal text-indigo-200/90"
|
||||
>{overviewCardSubtitle(tournament)}</span
|
||||
>
|
||||
</button>
|
||||
{#if tournament.slug}
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
|
|
@ -1301,7 +1539,9 @@ function sectionKey(index: number) {
|
|||
{/if}
|
||||
</li>
|
||||
{:else}
|
||||
<li class="rounded-md border border-dashed border-slate-300 px-3 py-6 text-center text-sm text-slate-500">
|
||||
<li
|
||||
class="rounded-md border border-dashed border-slate-300 px-3 py-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Inga sparade turneringar ännu.
|
||||
</li>
|
||||
{/each}
|
||||
|
|
@ -1386,7 +1626,9 @@ function sectionKey(index: number) {
|
|||
|
||||
<section class="space-y-3">
|
||||
<header class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Sektioner</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Sektioner
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => addSection(manageState.form)}
|
||||
|
|
@ -1397,7 +1639,9 @@ function sectionKey(index: number) {
|
|||
</header>
|
||||
|
||||
{#if manageState.form.sections.length === 0}
|
||||
<p class="rounded-md border border-dashed border-slate-300 px-4 py-4 text-sm text-slate-500">
|
||||
<p
|
||||
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.
|
||||
</p>
|
||||
{:else}
|
||||
|
|
@ -1406,7 +1650,9 @@ function sectionKey(index: number) {
|
|||
<div class="rounded-md border border-slate-200 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<label class="flex-1 text-sm font-medium text-slate-700">
|
||||
<span class="block text-xs uppercase tracking-wide text-slate-500">Titel</span>
|
||||
<span class="block text-xs tracking-wide text-slate-500 uppercase"
|
||||
>Titel</span
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={section.title}
|
||||
|
|
@ -1422,7 +1668,9 @@ function sectionKey(index: number) {
|
|||
</button>
|
||||
</div>
|
||||
<label class="mt-3 block text-sm font-medium text-slate-700">
|
||||
<span class="block text-xs uppercase tracking-wide text-slate-500">Innehåll</span>
|
||||
<span class="block text-xs tracking-wide text-slate-500 uppercase"
|
||||
>Innehåll</span
|
||||
>
|
||||
<textarea
|
||||
rows={3}
|
||||
bind:value={section.body}
|
||||
|
|
@ -1463,7 +1711,11 @@ function sectionKey(index: number) {
|
|||
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"
|
||||
>
|
||||
{manageState.saving ? 'Sparar…' : manageState.selectedId === null ? 'Skapa turnering' : 'Spara ändringar'}
|
||||
{manageState.saving
|
||||
? 'Sparar…'
|
||||
: manageState.selectedId === null
|
||||
? 'Skapa turnering'
|
||||
: 'Spara ändringar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1471,55 +1723,81 @@ function sectionKey(index: number) {
|
|||
|
||||
{#if manageState.form.title.trim()}
|
||||
<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>
|
||||
<p class="mt-2 text-sm text-slate-500">Visas för deltagare på /tournament/{manageState.form.slug || 'slug'}.</p>
|
||||
<h3 class="text-lg font-semibold text-slate-900">
|
||||
Förhandsvisning av anmälningssida
|
||||
</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="space-y-1">
|
||||
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">{manageState.form.game || 'Spel'}</p>
|
||||
<h4 class="text-2xl font-bold sm:text-3xl">{manageState.form.title || 'Titel'}</h4>
|
||||
<p class="text-xs tracking-[0.4em] text-indigo-300 uppercase">
|
||||
{manageState.form.game || 'Spel'}
|
||||
</p>
|
||||
<h4 class="text-2xl font-bold sm:text-3xl">
|
||||
{manageState.form.title || 'Titel'}
|
||||
</h4>
|
||||
{#if manageState.form.tagline.trim()}
|
||||
<p class="text-sm text-slate-300">{manageState.form.tagline}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if manageState.form.start_at}
|
||||
<p class="text-sm text-indigo-200">Start {formatDateTime(new Date(manageState.form.start_at).toISOString()) ?? ''}</p>
|
||||
<p class="text-sm text-indigo-200">
|
||||
Start {formatDateTime(new Date(manageState.form.start_at).toISOString()) ?? ''}
|
||||
</p>
|
||||
{/if}
|
||||
{#if manageState.form.location.trim()}
|
||||
<p class="text-sm text-slate-200">Plats: {manageState.form.location}</p>
|
||||
{/if}
|
||||
{#if manageState.form.description.trim()}
|
||||
<p class="whitespace-pre-line text-sm text-slate-200">{manageState.form.description}</p>
|
||||
<p class="text-sm whitespace-pre-line text-slate-200">
|
||||
{manageState.form.description}
|
||||
</p>
|
||||
{/if}
|
||||
{#if manageState.form.sections.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each payloadSections(manageState.form.sections) as section, index (sectionKey(index))}
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-indigo-200">{section.title}</p>
|
||||
<p class="whitespace-pre-line text-sm text-slate-200">{section.body}</p>
|
||||
<p class="text-sm whitespace-pre-line text-slate-200">{section.body}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if manageState.form.contact.trim()}
|
||||
<p class="text-xs uppercase tracking-wide text-indigo-100">Kontakt: {manageState.form.contact}</p>
|
||||
<p class="text-xs tracking-wide text-indigo-100 uppercase">
|
||||
Kontakt: {manageState.form.contact}
|
||||
</p>
|
||||
{/if}
|
||||
{#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">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Anmälningsfält</p>
|
||||
<p class="text-xs font-semibold tracking-wide text-indigo-200 uppercase">
|
||||
Anmälningsfält
|
||||
</p>
|
||||
<ul class="space-y-1 text-sm text-slate-200">
|
||||
{#each manageState.form.signup.entry_fields as field}
|
||||
<li>{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}</li>
|
||||
<li>
|
||||
{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type}
|
||||
{field.required ? '• obligatoriskt' : ''}
|
||||
{field.unique ? '• unikt' : ''}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{#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">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Spelaruppgifter</p>
|
||||
<p class="text-xs font-semibold tracking-wide text-indigo-200 uppercase">
|
||||
Spelaruppgifter
|
||||
</p>
|
||||
<ul class="space-y-1 text-sm text-slate-200">
|
||||
{#each manageState.form.signup.participant_fields as field}
|
||||
<li>{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type} {field.required ? '• obligatoriskt' : ''} {field.unique ? '• unikt' : ''}</li>
|
||||
<li>
|
||||
{field.label} · {field.field_type === 'text' ? 'Text' : field.field_type}
|
||||
{field.required ? '• obligatoriskt' : ''}
|
||||
{field.unique ? '• unikt' : ''}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -1537,7 +1815,7 @@ function sectionKey(index: number) {
|
|||
class={`inline-flex items-center justify-center rounded-full border px-4 py-2 text-sm font-semibold transition ${
|
||||
manageSlug
|
||||
? 'border-indigo-300 text-indigo-600 hover:border-indigo-400 hover:bg-indigo-50'
|
||||
: 'border-indigo-200 text-indigo-300 pointer-events-none opacity-60'
|
||||
: 'pointer-events-none border-indigo-200 text-indigo-300 opacity-60'
|
||||
}`}
|
||||
>
|
||||
Visa publika sidan
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listenToTournamentEvents } from '$lib/client/tournament-events';
|
||||
|
|
@ -13,6 +12,11 @@
|
|||
let registrations = $state(props.data.registrations ?? []);
|
||||
let refreshing = $state(false);
|
||||
let loadError = $state('');
|
||||
const ATTENDANCE_FIELD_ID = 'attendance-id';
|
||||
|
||||
function isAttendanceField(field: TournamentSignupField): boolean {
|
||||
return field.id === ATTENDANCE_FIELD_ID;
|
||||
}
|
||||
|
||||
type RegistrationResponse = {
|
||||
tournament: TournamentRegistrationList['tournament'];
|
||||
|
|
@ -103,6 +107,22 @@ const minParticipants = $derived(() =>
|
|||
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) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
|
|
@ -224,10 +244,42 @@ async function saveEdit(registration: TournamentRegistrationItem) {
|
|||
if (!tournament.slug || editingId !== registration.id) return;
|
||||
editSaving = true;
|
||||
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 = {
|
||||
entry: { ...editEntry },
|
||||
participants: editParticipants.map((participant) => ({ ...participant }))
|
||||
entry,
|
||||
participants
|
||||
};
|
||||
|
||||
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 {
|
||||
const result = await callEndpoint(
|
||||
'update',
|
||||
|
|
@ -252,42 +304,28 @@ async function saveEdit(registration: TournamentRegistrationItem) {
|
|||
function exportRegistrations() {
|
||||
if (typeof window === 'undefined') return;
|
||||
const lines: string[] = [];
|
||||
const timestamp = formatDateTime(new Date().toISOString()) ?? new Date().toISOString();
|
||||
lines.push(`${tournament.title} – ${registrations.length} anmälningar`);
|
||||
lines.push(`Genererad ${timestamp}`);
|
||||
lines.push('');
|
||||
|
||||
if (registrations.length === 0) {
|
||||
lines.push('Inga anmälningar.');
|
||||
} else {
|
||||
for (const registration of registrations) {
|
||||
lines.push(`Anmälan #${registration.id}`);
|
||||
const created = formatDateTime(registration.created_at) ?? registration.created_at;
|
||||
lines.push(`Skapad: ${created}`);
|
||||
|
||||
if (entryFields().length > 0) {
|
||||
lines.push('Lag / deltagare:');
|
||||
lines.push(registrationHeading(registration));
|
||||
for (const field of entryFields()) {
|
||||
lines.push(` ${field.label}: ${fieldValue(registration.entry, field) || '—'}`);
|
||||
const value = fieldValue(registration.entry, field).trim();
|
||||
lines.push(`${field.label}: ${value || '—'}`);
|
||||
}
|
||||
}
|
||||
|
||||
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:');
|
||||
if (participantFields().length > 0 && registration.participants.length > 0) {
|
||||
registration.participants.forEach((participant, index) => {
|
||||
lines.push('');
|
||||
lines.push(`Spelare ${index + 1}`);
|
||||
for (const field of participantFields()) {
|
||||
lines.push(` ${field.label}: ${fieldValue(participant, field) || '—'}`);
|
||||
const value = fieldValue(participant, field).trim();
|
||||
lines.push(`${field.label}: ${value || '—'}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
|
@ -344,16 +382,19 @@ async function refreshRegistrations() {
|
|||
}
|
||||
|
||||
onMount(() => {
|
||||
const stop = listenToTournamentEvents((updated) => {
|
||||
const stop = listenToTournamentEvents(
|
||||
(updated) => {
|
||||
if (updated.id === tournament.id && editingId === null) {
|
||||
void refreshRegistrations();
|
||||
}
|
||||
}, (deletedId) => {
|
||||
},
|
||||
(deletedId) => {
|
||||
if (deletedId === tournament.id) {
|
||||
registrations = [];
|
||||
loadError = 'Turneringen har tagits bort.';
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
|
|
@ -366,9 +407,11 @@ onMount(() => {
|
|||
|
||||
<div class="min-h-screen bg-slate-100 text-slate-900">
|
||||
<div class="mx-auto flex min-h-screen max-w-5xl flex-col gap-8 px-3 py-8 sm:px-4">
|
||||
<header class="flex flex-col gap-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<header
|
||||
class="flex flex-col gap-4 rounded-lg border border-slate-200 bg-white p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm uppercase tracking-[0.4em] text-indigo-500">Admin</p>
|
||||
<p class="text-sm tracking-[0.4em] text-indigo-500 uppercase">Admin</p>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{tournament.title}</h1>
|
||||
<p class="text-sm text-slate-600">{tournament.game}</p>
|
||||
</div>
|
||||
|
|
@ -392,17 +435,19 @@ onMount(() => {
|
|||
<h2 class="text-lg font-semibold text-slate-900">Sammanfattning</h2>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500">Anmälningar</p>
|
||||
<p class="text-xs tracking-wide text-slate-500 uppercase">Anmälningar</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-slate-900">{registrations.length}</p>
|
||||
</div>
|
||||
{#if tournament.start_at}
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500">Start</p>
|
||||
<p class="mt-1 text-sm text-slate-800">{formatDateTime(tournament.start_at) ?? tournament.start_at}</p>
|
||||
<p class="text-xs tracking-wide text-slate-500 uppercase">Start</p>
|
||||
<p class="mt-1 text-sm text-slate-800">
|
||||
{formatDateTime(tournament.start_at) ?? tournament.start_at}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500">Format</p>
|
||||
<p class="text-xs tracking-wide text-slate-500 uppercase">Format</p>
|
||||
<p class="mt-1 text-sm text-slate-800">
|
||||
{signupConfig().mode === 'team'
|
||||
? `Lag (${signupConfig().team_size.min}–${signupConfig().team_size.max} spelare)`
|
||||
|
|
@ -417,12 +462,14 @@ onMount(() => {
|
|||
<h2 class="text-lg font-semibold text-slate-900">Registreringar</h2>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
{#if registrations.length > 0}
|
||||
<p class="text-sm text-slate-500">Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}</p>
|
||||
<p class="text-sm text-slate-500">
|
||||
Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}
|
||||
</p>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={exportRegistrations}
|
||||
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-slate-600 transition hover:bg-slate-100"
|
||||
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"
|
||||
>
|
||||
Exportera .txt
|
||||
</button>
|
||||
|
|
@ -430,17 +477,27 @@ onMount(() => {
|
|||
</header>
|
||||
|
||||
{#if loadError}
|
||||
<p class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600">{loadError}</p>
|
||||
<p
|
||||
class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600"
|
||||
>
|
||||
{loadError}
|
||||
</p>
|
||||
{/if}
|
||||
{#if deleteError}
|
||||
<p class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600">{deleteError}</p>
|
||||
<p
|
||||
class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600"
|
||||
>
|
||||
{deleteError}
|
||||
</p>
|
||||
{/if}
|
||||
{#if refreshing && !loadError}
|
||||
<p class="mt-2 text-xs text-slate-500">Uppdaterar…</p>
|
||||
{/if}
|
||||
|
||||
{#if registrations.length === 0}
|
||||
<p class="mt-4 rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500">
|
||||
<p
|
||||
class="mt-4 rounded-md border border-dashed border-slate-300 px-4 py-6 text-center text-sm text-slate-500"
|
||||
>
|
||||
Inga anmälningar ännu. Dela länken till /tournament/{tournament.slug} för att samla in registreringar.
|
||||
</p>
|
||||
{:else}
|
||||
|
|
@ -449,8 +506,10 @@ onMount(() => {
|
|||
<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">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-slate-900">Anmälan #{registration.id}</h3>
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500">
|
||||
<h3 class="text-base font-semibold text-slate-900">
|
||||
{registrationHeading(registration)}
|
||||
</h3>
|
||||
<p class="text-xs tracking-wide text-slate-500 uppercase">
|
||||
Skapad {formatDateTime(registration.created_at) ?? registration.created_at}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -484,25 +543,42 @@ onMount(() => {
|
|||
</header>
|
||||
|
||||
{#if editingId === registration.id}
|
||||
<form class="space-y-4" onsubmit={(event) => {
|
||||
<form
|
||||
class="space-y-4"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
saveEdit(registration);
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
{#if entryFields().length > 0}
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Lag / deltagare</h4>
|
||||
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Lag / deltagare
|
||||
</h4>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each entryFields() as field}
|
||||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type={fieldInputType(field.field_type)}
|
||||
inputmode={isAttendanceField(field) ? 'numeric' : undefined}
|
||||
pattern={isAttendanceField(field) ? '\\d*' : undefined}
|
||||
value={editEntry[field.id] ?? ''}
|
||||
oninput={(event) => updateEntryField(field.id, (event.currentTarget as HTMLInputElement).value)}
|
||||
oninput={(event) =>
|
||||
updateEntryField(
|
||||
field.id,
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
disabled={editSaving}
|
||||
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
|
||||
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"
|
||||
/>
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -511,12 +587,15 @@ onMount(() => {
|
|||
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold uppercase tracking-wide text-slate-500">Spelare</h4>
|
||||
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Spelare
|
||||
</h4>
|
||||
{#if signupConfig().mode === 'team'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={addParticipant}
|
||||
disabled={editSaving || (maxParticipants() > 0 && editParticipants.length >= maxParticipants())}
|
||||
disabled={editSaving ||
|
||||
(maxParticipants() > 0 && editParticipants.length >= maxParticipants())}
|
||||
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Lägg till spelare
|
||||
|
|
@ -533,12 +612,17 @@ onMount(() => {
|
|||
{#each editParticipants as participant, index}
|
||||
<div class="space-y-3 rounded-md border border-slate-200 bg-white p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">{participantLabel(index)}</p>
|
||||
<p
|
||||
class="text-xs font-semibold tracking-wide text-slate-500 uppercase"
|
||||
>
|
||||
{participantLabel(index)}
|
||||
</p>
|
||||
{#if signupConfig().mode === 'team'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeParticipant(index)}
|
||||
disabled={editSaving || editParticipants.length <= Math.max(1, minParticipants())}
|
||||
disabled={editSaving ||
|
||||
editParticipants.length <= Math.max(1, minParticipants())}
|
||||
class="rounded-full border border-red-200 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Ta bort
|
||||
|
|
@ -547,15 +631,22 @@ onMount(() => {
|
|||
</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each participantFields() as field}
|
||||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
|
||||
<label
|
||||
class="flex flex-col gap-1 text-sm font-medium text-slate-700"
|
||||
>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
type={fieldInputType(field.field_type)}
|
||||
value={participant[field.id] ?? ''}
|
||||
oninput={(event) => updateParticipantField(index, field.id, (event.currentTarget as HTMLInputElement).value)}
|
||||
oninput={(event) =>
|
||||
updateParticipantField(
|
||||
index,
|
||||
field.id,
|
||||
(event.currentTarget as HTMLInputElement).value
|
||||
)}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
disabled={editSaving}
|
||||
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
|
||||
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"
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
|
|
@ -567,7 +658,11 @@ onMount(() => {
|
|||
</section>
|
||||
|
||||
{#if editError}
|
||||
<p class="rounded-md border border-red-200 bg-red-500/10 px-3 py-2 text-sm text-red-600">{editError}</p>
|
||||
<p
|
||||
class="rounded-md border border-red-200 bg-red-500/10 px-3 py-2 text-sm text-red-600"
|
||||
>
|
||||
{editError}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 pt-2">
|
||||
|
|
@ -593,28 +688,27 @@ onMount(() => {
|
|||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each entryFields() as field}
|
||||
<div class="rounded-md border border-slate-200 bg-white p-3">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500">{field.label}</p>
|
||||
<p class="mt-1 text-sm text-slate-800">{fieldValue(registration.entry, field) || '—'}</p>
|
||||
<p class="text-xs tracking-wide text-slate-500 uppercase">{field.label}</p>
|
||||
<p class="mt-1 text-sm text-slate-800">
|
||||
{fieldValue(registration.entry, field) || '—'}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if participantFields().length > 0}
|
||||
<section class="space-y-2">
|
||||
<h4 class="text-sm font-semibold text-slate-800">Spelare</h4>
|
||||
{#if participantFields().length === 0}
|
||||
{#if registration.participants.length === 0}
|
||||
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
||||
{:else}
|
||||
<p class="text-xs text-slate-500">Antal spelare: {registration.participants.length}</p>
|
||||
{/if}
|
||||
{:else if registration.participants.length === 0}
|
||||
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each registration.participants as participant, index}
|
||||
<div class="rounded-md border border-slate-200 bg-white p-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Spelare {index + 1}</p>
|
||||
<p class="text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Spelare {index + 1}
|
||||
</p>
|
||||
<ul class="mt-2 space-y-1 text-sm text-slate-800">
|
||||
{#each participantFields() as field}
|
||||
<li>
|
||||
|
|
@ -629,6 +723,7 @@ onMount(() => {
|
|||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -64,7 +64,9 @@
|
|||
}
|
||||
|
||||
onMount(() => {
|
||||
const stop = listenToTournamentEvents(upsertTournament, removeTournament);
|
||||
const stop = listenToTournamentEvents(upsertTournament, removeTournament, {
|
||||
endpoint: '/api/public-events'
|
||||
});
|
||||
return () => {
|
||||
stop();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
|
||||
const props = $props<{ data: { tournament: TournamentInfo } }>();
|
||||
const tournament = props.data.tournament;
|
||||
const ATTENDANCE_FIELD_ID = 'attendance-id';
|
||||
|
||||
function pickMode(value: string | null | undefined) {
|
||||
return value === 'team' ? 'team' : 'solo';
|
||||
|
|
@ -24,7 +25,9 @@
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeSignupConfig(config: TournamentSignupConfig | null | undefined): TournamentSignupConfig {
|
||||
function normalizeSignupConfig(
|
||||
config: TournamentSignupConfig | null | undefined
|
||||
): TournamentSignupConfig {
|
||||
if (!config) {
|
||||
return {
|
||||
mode: 'solo',
|
||||
|
|
@ -54,6 +57,7 @@
|
|||
type FieldValueMap = Record<string, string>;
|
||||
|
||||
const signupConfig = normalizeSignupConfig(tournament.signup_config);
|
||||
const entrySectionTitle = signupConfig.mode === 'team' ? 'Lag' : 'Spelare';
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return null;
|
||||
|
|
@ -94,6 +98,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
function isAttendanceField(field: TournamentSignupField): boolean {
|
||||
return field.id === ATTENDANCE_FIELD_ID;
|
||||
}
|
||||
|
||||
const minParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.min : 1;
|
||||
const maxParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.max : 1;
|
||||
|
||||
|
|
@ -109,8 +117,6 @@
|
|||
showSuccessModal: false
|
||||
});
|
||||
|
||||
|
||||
|
||||
function initializeParticipants() {
|
||||
const initialCount = Math.max(1, signupConfig.mode === 'team' ? signupConfig.team_size.min : 1);
|
||||
const list: FieldValueMap[] = [];
|
||||
|
|
@ -159,6 +165,13 @@
|
|||
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 map: Record<string, string> = {};
|
||||
for (const field of signupConfig.participant_fields) {
|
||||
|
|
@ -175,16 +188,56 @@
|
|||
signup.error = '';
|
||||
signup.success = '';
|
||||
|
||||
if (signup.participants.length === 0) {
|
||||
signup.error = 'Lägg till minst en spelare.';
|
||||
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;
|
||||
}
|
||||
|
||||
if (signupConfig.mode === 'team') {
|
||||
if (signup.participants.length < minParticipants) {
|
||||
signup.error = `Lägg till minst ${minParticipants} spelare.`;
|
||||
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) {
|
||||
signup.error = 'Lägg till minst en spelare.';
|
||||
return;
|
||||
}
|
||||
|
||||
signup.submitting = true;
|
||||
|
|
@ -251,12 +304,12 @@
|
|||
<span aria-hidden="true">←</span>
|
||||
<span>Tillbaka till turneringsöversikten</span>
|
||||
</a>
|
||||
<span class="uppercase tracking-[0.4em] text-indigo-300">{tournament.game}</span>
|
||||
<span class="tracking-[0.4em] text-indigo-300 uppercase">{tournament.game}</span>
|
||||
</nav>
|
||||
|
||||
<header class="space-y-4 rounded-2xl bg-slate-900/70 p-6 shadow-lg">
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs uppercase tracking-[0.4em] text-indigo-200">VBytes LAN</p>
|
||||
<p class="text-xs tracking-[0.4em] text-indigo-200 uppercase">VBytes LAN</p>
|
||||
<h1 class="text-3xl font-bold sm:text-4xl">{tournament.title}</h1>
|
||||
{#if tournament.tagline}
|
||||
<p class="text-base text-slate-300">{tournament.tagline}</p>
|
||||
|
|
@ -265,19 +318,19 @@
|
|||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
{#if formattedStart}
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
||||
<p class="text-xs uppercase tracking-wide text-indigo-200">Start</p>
|
||||
<p class="text-xs tracking-wide text-indigo-200 uppercase">Start</p>
|
||||
<p class="text-sm text-slate-100">{formattedStart}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tournament.location}
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
||||
<p class="text-xs uppercase tracking-wide text-indigo-200">Plats</p>
|
||||
<p class="text-xs tracking-wide text-indigo-200 uppercase">Plats</p>
|
||||
<p class="text-sm text-slate-100">{tournament.location}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if tournament.contact}
|
||||
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 sm:col-span-2">
|
||||
<p class="text-xs uppercase tracking-wide text-indigo-200">Kontakt</p>
|
||||
<p class="text-xs tracking-wide text-indigo-200 uppercase">Kontakt</p>
|
||||
<p class="text-sm text-slate-100">{tournament.contact}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -287,7 +340,9 @@
|
|||
{#if tournament.description}
|
||||
<section class="space-y-3 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
|
||||
<h2 class="text-lg font-semibold text-slate-100">Beskrivning</h2>
|
||||
<p class="whitespace-pre-line text-sm leading-relaxed text-slate-200">{tournament.description}</p>
|
||||
<p class="text-sm leading-relaxed whitespace-pre-line text-slate-200">
|
||||
{tournament.description}
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
|
|
@ -296,7 +351,7 @@
|
|||
{#each tournament.sections as section, index (section.title + index)}
|
||||
<article class="space-y-2 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
|
||||
<h3 class="text-base font-semibold text-indigo-200">{section.title}</h3>
|
||||
<p class="whitespace-pre-line text-sm leading-relaxed text-slate-200">{section.body}</p>
|
||||
<p class="text-sm leading-relaxed whitespace-pre-line text-slate-200">{section.body}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</section>
|
||||
|
|
@ -306,7 +361,9 @@
|
|||
<header class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-slate-100">Anmälan</h2>
|
||||
{#if signupConfig.mode === 'team'}
|
||||
<p class="text-sm text-slate-300">Lagstorlek: {signupConfig.team_size.min}–{signupConfig.team_size.max} spelare.</p>
|
||||
<p class="text-sm text-slate-300">
|
||||
Lagstorlek: {signupConfig.team_size.min}–{signupConfig.team_size.max} spelare.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm text-slate-300">Individuell anmälan.</p>
|
||||
{/if}
|
||||
|
|
@ -315,7 +372,9 @@
|
|||
<form class="space-y-5" onsubmit={handleSignupSubmit}>
|
||||
{#if signupConfig.entry_fields.length > 0}
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Lag / deltagare</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wide text-slate-400 uppercase">
|
||||
{entrySectionTitle}
|
||||
</h3>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each signupConfig.entry_fields as field}
|
||||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-200">
|
||||
|
|
@ -324,10 +383,19 @@
|
|||
type={fieldInputType(field.field_type)}
|
||||
required={field.required}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
inputmode={isAttendanceField(field) ? 'numeric' : undefined}
|
||||
pattern={isAttendanceField(field) ? '\\d*' : undefined}
|
||||
autocomplete={isAttendanceField(field) ? 'off' : undefined}
|
||||
value={signup.entry[field.id]}
|
||||
oninput={(event) => (signup.entry[field.id] = (event.currentTarget as HTMLInputElement).value)}
|
||||
class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
|
||||
oninput={(event) =>
|
||||
(signup.entry[field.id] = (event.currentTarget as HTMLInputElement).value)}
|
||||
class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-indigo-500 focus: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>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -342,20 +410,32 @@
|
|||
aria-labelledby="signup-success-title"
|
||||
>
|
||||
<header class="space-y-2 text-center">
|
||||
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p>
|
||||
<h2 id="signup-success-title" class="text-2xl font-semibold text-slate-100 sm:text-3xl">
|
||||
<p class="text-xs tracking-[0.4em] text-indigo-300 uppercase">VBytes LAN</p>
|
||||
<h2
|
||||
id="signup-success-title"
|
||||
class="text-2xl font-semibold text-slate-100 sm:text-3xl"
|
||||
>
|
||||
Anmälan bekräftad
|
||||
</h2>
|
||||
<p class="text-sm text-slate-300">Du är registrerad till {tournament.title}.</p>
|
||||
{#if signup.successRegistrationId}
|
||||
<p class="text-xs uppercase tracking-wide text-indigo-200">Anmälan #{signup.successRegistrationId}</p>
|
||||
<p class="text-xs tracking-wide text-indigo-200 uppercase">
|
||||
Anmälan #{signup.successRegistrationId}
|
||||
</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<section class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-2">
|
||||
<section
|
||||
class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-2"
|
||||
>
|
||||
<div class="space-y-2 text-left">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Turnering</h3>
|
||||
<p class="text-sm text-slate-300"><span class="font-medium text-slate-100">Spel:</span> {tournament.game}</p>
|
||||
<h3 class="text-sm font-semibold tracking-wide text-indigo-200 uppercase">
|
||||
Turnering
|
||||
</h3>
|
||||
<p class="text-sm text-slate-300">
|
||||
<span class="font-medium text-slate-100">Spel:</span>
|
||||
{tournament.game}
|
||||
</p>
|
||||
{#if tournament.start_at}
|
||||
<p class="text-sm text-slate-300">
|
||||
<span class="font-medium text-slate-100">Start:</span>
|
||||
|
|
@ -364,12 +444,15 @@
|
|||
{/if}
|
||||
{#if tournament.location}
|
||||
<p class="text-sm text-slate-300">
|
||||
<span class="font-medium text-slate-100">Plats:</span> {tournament.location}
|
||||
<span class="font-medium text-slate-100">Plats:</span>
|
||||
{tournament.location}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2 text-left">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Format</h3>
|
||||
<h3 class="text-sm font-semibold tracking-wide text-indigo-200 uppercase">
|
||||
Format
|
||||
</h3>
|
||||
{#if signupConfig.mode === 'team'}
|
||||
<p class="text-sm text-slate-300">
|
||||
Lag {signupConfig.team_size.min}–{signupConfig.team_size.max} spelare
|
||||
|
|
@ -379,7 +462,8 @@
|
|||
{/if}
|
||||
{#if tournament.contact}
|
||||
<p class="text-sm text-slate-300">
|
||||
<span class="font-medium text-slate-100">Kontakt:</span> {tournament.contact}
|
||||
<span class="font-medium text-slate-100">Kontakt:</span>
|
||||
{tournament.contact}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -388,28 +472,37 @@
|
|||
<section class="space-y-3">
|
||||
<h3 class="text-base font-semibold text-slate-100">Anmälningsuppgifter</h3>
|
||||
{#if signupConfig.entry_fields.length === 0}
|
||||
<p class="rounded-md border border-dashed border-slate-700 px-4 py-3 text-sm text-slate-300">
|
||||
<p
|
||||
class="rounded-md border border-dashed border-slate-700 px-4 py-3 text-sm text-slate-300"
|
||||
>
|
||||
Den här turneringen kräver inga uppgifter utöver spelare.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
{#each signupConfig.entry_fields as field}
|
||||
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
|
||||
<p class="text-xs uppercase tracking-wide text-indigo-200">{field.label}</p>
|
||||
<p class="mt-1 text-sm text-slate-100">{signup.submittedEntry[field.id] || '—'}</p>
|
||||
<p class="text-xs tracking-wide text-indigo-200 uppercase">
|
||||
{field.label}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-slate-100">
|
||||
{signup.submittedEntry[field.id] || '—'}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if signupConfig.mode === 'team'}
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-base font-semibold text-slate-100">Spelare</h3>
|
||||
{#if signupConfig.participant_fields.length === 0}
|
||||
{#if signup.submittedParticipants.length === 0}
|
||||
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
|
||||
{:else}
|
||||
<p class="text-sm text-slate-300">Antal spelare: {signup.submittedParticipants.length}</p>
|
||||
<p class="text-sm text-slate-300">
|
||||
Antal spelare: {signup.submittedParticipants.length}
|
||||
</p>
|
||||
{/if}
|
||||
{:else if signup.submittedParticipants.length === 0}
|
||||
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
|
||||
|
|
@ -417,7 +510,11 @@
|
|||
<div class="space-y-3">
|
||||
{#each signup.submittedParticipants as participant, index}
|
||||
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Spelare {index + 1}</p>
|
||||
<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">
|
||||
{#each signupConfig.participant_fields as field}
|
||||
<li>
|
||||
|
|
@ -431,6 +528,7 @@
|
|||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<a
|
||||
|
|
@ -443,31 +541,21 @@
|
|||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Spelare</h3>
|
||||
{#if signupConfig.mode === 'team'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={addParticipant}
|
||||
disabled={!canAddParticipant() || signup.submitting}
|
||||
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-200 transition hover:border-indigo-400 hover:bg-indigo-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Lägg till spelare
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-slate-400 uppercase">Spelare</h3>
|
||||
|
||||
{#if signup.participants.length > 0}
|
||||
<div class="space-y-4">
|
||||
{#each signup.participants as participant, index (index)}
|
||||
<div class="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-semibold text-slate-200">{participantDisplayName(index)}</span>
|
||||
{#if signupConfig.mode === 'team' && canRemoveParticipant()}
|
||||
<span class="text-sm font-semibold text-slate-200">
|
||||
{participantDisplayName(index)}
|
||||
</span>
|
||||
{#if canRemoveParticipant()}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeParticipant(index)}
|
||||
|
|
@ -489,8 +577,11 @@
|
|||
required={field.required}
|
||||
placeholder={field.placeholder ?? ''}
|
||||
value={participant[field.id] ?? ''}
|
||||
oninput={(event) => (participant[field.id] = (event.currentTarget as HTMLInputElement).value)}
|
||||
class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
|
||||
oninput={(event) =>
|
||||
(participant[field.id] = (
|
||||
event.currentTarget as HTMLInputElement
|
||||
).value)}
|
||||
class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-indigo-500 focus:ring focus:ring-indigo-500/40 focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
{/each}
|
||||
|
|
@ -502,13 +593,25 @@
|
|||
{/each}
|
||||
</div>
|
||||
{/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>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
{#if signup.error}
|
||||
<p class="rounded-md border border-red-400 bg-red-500/10 px-4 py-2 text-red-200">{signup.error}</p>
|
||||
<p class="rounded-md border border-red-400 bg-red-500/10 px-4 py-2 text-red-200">
|
||||
{signup.error}
|
||||
</p>
|
||||
{:else if signup.success}
|
||||
<p class="rounded-md border border-emerald-400 bg-emerald-500/10 px-4 py-2 text-emerald-200">{signup.success}</p>
|
||||
<p
|
||||
class="rounded-md border border-emerald-400 bg-emerald-500/10 px-4 py-2 text-emerald-200"
|
||||
>
|
||||
{signup.success}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-slate-400">Din anmälan skickas direkt till arrangören.</p>
|
||||
{/if}
|
||||
|
|
@ -526,7 +629,10 @@
|
|||
|
||||
<footer class="mt-auto flex items-center justify-between text-xs text-slate-500">
|
||||
<p>Senast uppdaterad {formatDateTime(tournament.updated_at) ?? tournament.updated_at}</p>
|
||||
<a href="/admin/tournament" class="rounded-full border border-indigo-300 px-4 py-2 font-semibold text-indigo-300 transition hover:border-indigo-400 hover:bg-indigo-50/5">
|
||||
<a
|
||||
href="/admin/tournament"
|
||||
class="rounded-full border border-indigo-300 px-4 py-2 font-semibold text-indigo-300 transition hover:border-indigo-400 hover:bg-indigo-50/5"
|
||||
>
|
||||
Administrera
|
||||
</a>
|
||||
</footer>
|
||||
|
|
|
|||
26
web/src/routes/api/public-events/+server.ts
Normal file
26
web/src/routes/api/public-events/+server.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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
|
||||
});
|
||||
};
|
||||
Loading…
Reference in a new issue