adding attendance id check for turnament
This commit is contained in:
parent
687c3943d4
commit
535c285a33
7 changed files with 1275 additions and 747 deletions
2
.env
2
.env
|
|
@ -5,4 +5,4 @@ ADMIN_PASSWORD=admin
|
||||||
JWT_COOKIE_SECURE=false
|
JWT_COOKIE_SECURE=false
|
||||||
ENABLE_HTTPS_REDIRECT=false
|
ENABLE_HTTPS_REDIRECT=false
|
||||||
WEB_PORT=3000
|
WEB_PORT=3000
|
||||||
CSRF_ALLOWED_ORIGINS=http://192.168.1.204:3000
|
CSRF_ALLOWED_ORIGINS=http://localhost:3000
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@ use sqlx::FromRow;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
pub const ATTENDANCE_ID_FIELD_ID: &str = "attendance-id";
|
||||||
|
pub const ATTENDANCE_ID_FIELD_LABEL: &str = "Deltagar-ID";
|
||||||
|
pub const ATTENDANCE_ID_FIELD_PLACEHOLDER: &str = "Ange ditt deltagar-ID från närvarolistan";
|
||||||
|
|
||||||
#[derive(Debug, FromRow, Clone)]
|
#[derive(Debug, FromRow, Clone)]
|
||||||
pub struct TournamentInfo {
|
pub struct TournamentInfo {
|
||||||
pub id: i32,
|
pub id: i32,
|
||||||
|
|
@ -173,6 +177,7 @@ impl TournamentSignupConfig {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.entry_fields = normalize_signup_fields(self.entry_fields);
|
self.entry_fields = normalize_signup_fields(self.entry_fields);
|
||||||
|
ensure_attendance_id_field(&mut self.entry_fields);
|
||||||
self.participant_fields = normalize_signup_fields(self.participant_fields);
|
self.participant_fields = normalize_signup_fields(self.participant_fields);
|
||||||
|
|
||||||
self
|
self
|
||||||
|
|
@ -213,6 +218,65 @@ fn normalize_signup_fields(mut fields: Vec<TournamentSignupField>) -> Vec<Tourna
|
||||||
fields
|
fields
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn ensure_attendance_id_field(fields: &mut Vec<TournamentSignupField>) {
|
||||||
|
let mut attendance_index = None;
|
||||||
|
for (index, field) in fields.iter_mut().enumerate() {
|
||||||
|
if field.id == ATTENDANCE_ID_FIELD_ID {
|
||||||
|
attendance_index = Some(index);
|
||||||
|
sanitize_attendance_id_field(field);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match attendance_index {
|
||||||
|
Some(index) => {
|
||||||
|
if index != 0 {
|
||||||
|
let field = fields.remove(index);
|
||||||
|
fields.insert(0, field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
fields.insert(0, default_attendance_id_field());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_attendance_id_field(field: &mut TournamentSignupField) {
|
||||||
|
field.id = ATTENDANCE_ID_FIELD_ID.to_string();
|
||||||
|
field.label = sanitize_attendance_label(&field.label);
|
||||||
|
field.field_type = TournamentFieldType::Text;
|
||||||
|
field.required = true;
|
||||||
|
field.unique = true;
|
||||||
|
field.placeholder = Some(
|
||||||
|
field
|
||||||
|
.placeholder
|
||||||
|
.as_ref()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or_else(|| ATTENDANCE_ID_FIELD_PLACEHOLDER.to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_attendance_label(label: &str) -> String {
|
||||||
|
let trimmed = label.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
ATTENDANCE_ID_FIELD_LABEL.to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_attendance_id_field() -> TournamentSignupField {
|
||||||
|
TournamentSignupField {
|
||||||
|
id: ATTENDANCE_ID_FIELD_ID.to_string(),
|
||||||
|
label: ATTENDANCE_ID_FIELD_LABEL.to_string(),
|
||||||
|
field_type: TournamentFieldType::Text,
|
||||||
|
required: true,
|
||||||
|
placeholder: Some(ATTENDANCE_ID_FIELD_PLACEHOLDER.to_string()),
|
||||||
|
unique: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_field_id(input: &str) -> String {
|
fn normalize_field_id(input: &str) -> String {
|
||||||
let mut slug = input
|
let mut slug = input
|
||||||
.trim()
|
.trim()
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
use crate::auth::AuthUser;
|
use crate::auth::AuthUser;
|
||||||
use crate::error::ApiError;
|
use crate::error::ApiError;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
AppEvent, CreateTournamentRequest, TournamentFieldType, TournamentInfo, TournamentInfoData,
|
AppEvent, CreateTournamentRequest, Person, TournamentFieldType, TournamentInfo,
|
||||||
TournamentItemResponse, TournamentListResponse, TournamentParticipantRow,
|
TournamentInfoData, TournamentItemResponse, TournamentListResponse, TournamentParticipantRow,
|
||||||
TournamentParticipantValueRow, TournamentRegistrationDetailResponse,
|
TournamentParticipantValueRow, TournamentRegistrationDetailResponse,
|
||||||
TournamentRegistrationItem, TournamentRegistrationListResponse, TournamentRegistrationResponse,
|
TournamentRegistrationItem, TournamentRegistrationListResponse, TournamentRegistrationResponse,
|
||||||
TournamentRegistrationRow, TournamentRegistrationValueRow, TournamentSection,
|
TournamentRegistrationRow, TournamentRegistrationValueRow, TournamentSection,
|
||||||
TournamentSectionRecord, TournamentSignupConfig, TournamentSignupField,
|
TournamentSectionRecord, TournamentSignupConfig, TournamentSignupField,
|
||||||
TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest,
|
TournamentSignupFieldRecord, TournamentSignupSubmission, UpdateTournamentRequest,
|
||||||
|
ATTENDANCE_ID_FIELD_ID,
|
||||||
};
|
};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use rocket::http::Status;
|
use rocket::http::Status;
|
||||||
|
|
@ -107,6 +108,46 @@ fn build_registration_url(slug: &str) -> String {
|
||||||
format!("/tournament/{slug}")
|
format!("/tournament/{slug}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn first_non_attendance_entry_value(
|
||||||
|
fields: &[TournamentSignupField],
|
||||||
|
values: &HashMap<String, String>,
|
||||||
|
) -> Option<String> {
|
||||||
|
for field in fields {
|
||||||
|
if field.id == ATTENDANCE_ID_FIELD_ID {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(value) = values.get(&field.id) {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return Some(trimmed.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_entry_label(
|
||||||
|
attendance_id: i32,
|
||||||
|
person: &Person,
|
||||||
|
config: &TournamentSignupConfig,
|
||||||
|
entry_values: &HashMap<String, String>,
|
||||||
|
) -> Option<String> {
|
||||||
|
let primary = first_non_attendance_entry_value(&config.entry_fields, entry_values)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let first_name = person.first_name.trim();
|
||||||
|
let last_name = person.last_name.trim();
|
||||||
|
let full = format!("{first_name} {last_name}");
|
||||||
|
full.trim().to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
let label = format!("{attendance_id} – {primary}").trim().to_string();
|
||||||
|
if label.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn signup_fields_equal(left: &[TournamentSignupField], right: &[TournamentSignupField]) -> bool {
|
fn signup_fields_equal(left: &[TournamentSignupField], right: &[TournamentSignupField]) -> bool {
|
||||||
if left.len() != right.len() {
|
if left.len() != right.len() {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -219,6 +260,7 @@ fn build_signup_config(
|
||||||
entry_fields,
|
entry_fields,
|
||||||
participant_fields,
|
participant_fields,
|
||||||
}
|
}
|
||||||
|
.normalized()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_tournament_data(
|
async fn load_tournament_data(
|
||||||
|
|
@ -1062,12 +1104,6 @@ fn validate_submission(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn trimmed(value: Option<&String>) -> Option<String> {
|
|
||||||
value
|
|
||||||
.map(|v| v.trim().to_string())
|
|
||||||
.filter(|v| !v.is_empty())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[rocket::post("/slug/<slug>/signup", data = "<payload>")]
|
#[rocket::post("/slug/<slug>/signup", data = "<payload>")]
|
||||||
pub async fn create_registration_by_slug(
|
pub async fn create_registration_by_slug(
|
||||||
state: &rocket::State<AppState>,
|
state: &rocket::State<AppState>,
|
||||||
|
|
@ -1136,42 +1172,54 @@ pub async fn create_registration_by_slug(
|
||||||
|
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
let entry_label = config
|
if !field_map.contains_key(ATTENDANCE_ID_FIELD_ID) {
|
||||||
.entry_fields
|
return Err(ApiError::bad_request(
|
||||||
.first()
|
"Turneringen är felkonfigurerad och saknar obligatoriskt deltagar-ID-fält.",
|
||||||
.and_then(|field| entry_values.get(&field.id))
|
));
|
||||||
.and_then(|value| trimmed(Some(value)));
|
|
||||||
|
|
||||||
let entry_label_requires_unique = config
|
|
||||||
.entry_fields
|
|
||||||
.first()
|
|
||||||
.map(|field| field.unique)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if entry_label_requires_unique {
|
|
||||||
if let Some(label) = entry_label.clone() {
|
|
||||||
let is_duplicate = sqlx::query_scalar::<_, bool>(
|
|
||||||
r#"
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM tournament_registrations
|
|
||||||
WHERE tournament_id = $1 AND entry_label = $2
|
|
||||||
)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(info.id)
|
|
||||||
.bind(&label)
|
|
||||||
.fetch_one(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if is_duplicate {
|
|
||||||
return Err(ApiError::bad_request(
|
|
||||||
"Den här spelaren eller laget är redan anmäld till turneringen.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let canonical_attendance = attendance_id.to_string();
|
||||||
|
entry_values.insert(
|
||||||
|
ATTENDANCE_ID_FIELD_ID.to_string(),
|
||||||
|
canonical_attendance.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let entry_label = build_entry_label(attendance_id, &person, &config, &entry_values);
|
||||||
|
|
||||||
for field in &config.entry_fields {
|
for field in &config.entry_fields {
|
||||||
if !field.unique {
|
if !field.unique {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -1192,7 +1240,7 @@ pub async fn create_registration_by_slug(
|
||||||
WHERE r.tournament_id = $1
|
WHERE r.tournament_id = $1
|
||||||
AND v.signup_field_id = $2
|
AND v.signup_field_id = $2
|
||||||
AND v.value = $3
|
AND v.value = $3
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(info.id)
|
.bind(info.id)
|
||||||
|
|
@ -1602,45 +1650,53 @@ pub async fn update_registration_by_slug(
|
||||||
|
|
||||||
let mut tx = state.db.begin().await?;
|
let mut tx = state.db.begin().await?;
|
||||||
|
|
||||||
let entry_label = config
|
if !field_map.contains_key(ATTENDANCE_ID_FIELD_ID) {
|
||||||
.entry_fields
|
return Err(ApiError::bad_request(
|
||||||
.first()
|
"Turneringen är felkonfigurerad och saknar obligatoriskt deltagar-ID-fält.",
|
||||||
.and_then(|field| entry_values.get(&field.id))
|
));
|
||||||
.and_then(|value| trimmed(Some(value)));
|
|
||||||
|
|
||||||
let entry_label_requires_unique = config
|
|
||||||
.entry_fields
|
|
||||||
.first()
|
|
||||||
.map(|field| field.unique)
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
if entry_label_requires_unique {
|
|
||||||
if let Some(label) = entry_label.clone() {
|
|
||||||
let is_duplicate = sqlx::query_scalar::<_, bool>(
|
|
||||||
r#"
|
|
||||||
SELECT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM tournament_registrations
|
|
||||||
WHERE tournament_id = $1
|
|
||||||
AND entry_label = $2
|
|
||||||
AND id <> $3
|
|
||||||
)
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.bind(info.id)
|
|
||||||
.bind(&label)
|
|
||||||
.bind(registration.id)
|
|
||||||
.fetch_one(&state.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if is_duplicate {
|
|
||||||
return Err(ApiError::bad_request(
|
|
||||||
"Den här spelaren eller laget är redan anmäld till turneringen.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let entry_label = build_entry_label(attendance_id, &person, &config, &entry_values);
|
||||||
|
|
||||||
for field in &config.entry_fields {
|
for field in &config.entry_fields {
|
||||||
if !field.unique {
|
if !field.unique {
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
"@sveltejs/kit": "^2.22.0",
|
"@sveltejs/kit": "^2.22.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
|
@ -210,6 +211,8 @@
|
||||||
|
|
||||||
"devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="],
|
"devalue": ["devalue@5.3.2", "", {}, "sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw=="],
|
||||||
|
|
||||||
|
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
"esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="],
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,3 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { listenToTournamentEvents } from '$lib/client/tournament-events';
|
import { listenToTournamentEvents } from '$lib/client/tournament-events';
|
||||||
|
|
@ -13,92 +12,97 @@
|
||||||
let registrations = $state(props.data.registrations ?? []);
|
let registrations = $state(props.data.registrations ?? []);
|
||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let loadError = $state('');
|
let loadError = $state('');
|
||||||
|
const ATTENDANCE_FIELD_ID = 'attendance-id';
|
||||||
|
|
||||||
type RegistrationResponse = {
|
function isAttendanceField(field: TournamentSignupField): boolean {
|
||||||
tournament: TournamentRegistrationList['tournament'];
|
return field.id === ATTENDANCE_FIELD_ID;
|
||||||
registrations: TournamentRegistrationItem[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type RegistrationPayload = RegistrationResponse & { warning?: string };
|
|
||||||
|
|
||||||
function applyResult(result: RegistrationResponse) {
|
|
||||||
tournament = result.tournament;
|
|
||||||
registrations = result.registrations ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function callEndpoint(
|
|
||||||
endpoint: string,
|
|
||||||
payload: unknown,
|
|
||||||
defaultMessage: string
|
|
||||||
): Promise<RegistrationPayload> {
|
|
||||||
if (!tournament.slug) {
|
|
||||||
throw new Error('Turneringen saknar slug.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const init: RequestInit = { method: 'POST' };
|
type RegistrationResponse = {
|
||||||
if (payload !== undefined) {
|
tournament: TournamentRegistrationList['tournament'];
|
||||||
init.headers = { 'content-type': 'application/json' };
|
registrations: TournamentRegistrationItem[];
|
||||||
init.body = JSON.stringify(payload);
|
};
|
||||||
|
|
||||||
|
type RegistrationPayload = RegistrationResponse & { warning?: string };
|
||||||
|
|
||||||
|
function applyResult(result: RegistrationResponse) {
|
||||||
|
tournament = result.tournament;
|
||||||
|
registrations = result.registrations ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(
|
async function callEndpoint(
|
||||||
`/admin/tournament/${tournament.slug}/registrations/${endpoint}`,
|
endpoint: string,
|
||||||
init
|
payload: unknown,
|
||||||
);
|
defaultMessage: string
|
||||||
const text = await response.text();
|
): Promise<RegistrationPayload> {
|
||||||
const trimmed = text.trim();
|
if (!tournament.slug) {
|
||||||
|
throw new Error('Turneringen saknar slug.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const init: RequestInit = { method: 'POST' };
|
||||||
|
if (payload !== undefined) {
|
||||||
|
init.headers = { 'content-type': 'application/json' };
|
||||||
|
init.body = JSON.stringify(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/admin/tournament/${tournament.slug}/registrations/${endpoint}`,
|
||||||
|
init
|
||||||
|
);
|
||||||
|
const text = await response.text();
|
||||||
|
const trimmed = text.trim();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = defaultMessage;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text);
|
||||||
|
if (parsed?.message) {
|
||||||
|
message = parsed.message;
|
||||||
|
} else if (trimmed) {
|
||||||
|
message = trimmed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (trimmed) {
|
||||||
|
message = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error('Tomt svar från servern.');
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let message = defaultMessage;
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text);
|
const data = JSON.parse(text) as RegistrationPayload;
|
||||||
if (parsed?.message) {
|
if (
|
||||||
message = parsed.message;
|
!data ||
|
||||||
} else if (trimmed) {
|
typeof data !== 'object' ||
|
||||||
message = trimmed;
|
!('tournament' in data) ||
|
||||||
|
!('registrations' in data)
|
||||||
|
) {
|
||||||
|
throw new Error();
|
||||||
}
|
}
|
||||||
|
return data;
|
||||||
} catch {
|
} catch {
|
||||||
if (trimmed) {
|
throw new Error('Kunde inte tolka svaret från servern.');
|
||||||
message = trimmed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
throw new Error(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!trimmed) {
|
let editingId = $state<number | null>(null);
|
||||||
throw new Error('Tomt svar från servern.');
|
let editEntry = $state<Record<string, string>>({});
|
||||||
}
|
let editParticipants = $state<Record<string, string>[]>([]);
|
||||||
|
let editError = $state('');
|
||||||
try {
|
let editSaving = $state(false);
|
||||||
const data = JSON.parse(text) as RegistrationPayload;
|
let deletingId = $state<number | null>(null);
|
||||||
if (
|
let deleteError = $state('');
|
||||||
!data ||
|
|
||||||
typeof data !== 'object' ||
|
|
||||||
!('tournament' in data) ||
|
|
||||||
!('registrations' in data)
|
|
||||||
) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
} catch {
|
|
||||||
throw new Error('Kunde inte tolka svaret från servern.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let editingId = $state<number | null>(null);
|
|
||||||
let editEntry = $state<Record<string, string>>({});
|
|
||||||
let editParticipants = $state<Record<string, string>[]>([]);
|
|
||||||
let editError = $state('');
|
|
||||||
let editSaving = $state(false);
|
|
||||||
let deletingId = $state<number | null>(null);
|
|
||||||
let deleteError = $state('');
|
|
||||||
|
|
||||||
const entryFields = $derived(() => tournament.signup_config.entry_fields ?? []);
|
const entryFields = $derived(() => tournament.signup_config.entry_fields ?? []);
|
||||||
const participantFields = $derived(() => tournament.signup_config.participant_fields ?? []);
|
const participantFields = $derived(() => tournament.signup_config.participant_fields ?? []);
|
||||||
const signupConfig = $derived(() => tournament.signup_config);
|
const signupConfig = $derived(() => tournament.signup_config);
|
||||||
const minParticipants = $derived(() =>
|
const minParticipants = $derived(() =>
|
||||||
signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.min) : 0
|
signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.min) : 0
|
||||||
);
|
);
|
||||||
const maxParticipants = $derived(() =>
|
const maxParticipants = $derived(() =>
|
||||||
signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.max) : 0
|
signupConfig().mode === 'team' ? Math.max(1, signupConfig().team_size.max) : 0
|
||||||
);
|
);
|
||||||
|
|
@ -130,26 +134,26 @@ const minParticipants = $derived(() =>
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
function blankParticipantMap() {
|
function blankParticipantMap() {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
for (const field of participantFields()) {
|
for (const field of participantFields()) {
|
||||||
map[field.id] = '';
|
map[field.id] = '';
|
||||||
|
}
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldInputType(fieldType: string) {
|
function fieldInputType(fieldType: string) {
|
||||||
switch (fieldType) {
|
switch (fieldType) {
|
||||||
case 'email':
|
case 'email':
|
||||||
return 'email';
|
return 'email';
|
||||||
case 'tel':
|
case 'tel':
|
||||||
return 'tel';
|
return 'tel';
|
||||||
default:
|
default:
|
||||||
return 'text';
|
return 'text';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function ensureParticipantBounds(list: Record<string, string>[]) {
|
function ensureParticipantBounds(list: Record<string, string>[]) {
|
||||||
if (signupConfig().mode !== 'team') {
|
if (signupConfig().mode !== 'team') {
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
@ -182,13 +186,13 @@ function ensureParticipantBounds(list: Record<string, string>[]) {
|
||||||
editError = '';
|
editError = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
editingId = null;
|
editingId = null;
|
||||||
editEntry = {};
|
editEntry = {};
|
||||||
editParticipants = [];
|
editParticipants = [];
|
||||||
editError = '';
|
editError = '';
|
||||||
editSaving = false;
|
editSaving = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEntryField(fieldId: string, value: string) {
|
function updateEntryField(fieldId: string, value: string) {
|
||||||
editEntry = { ...editEntry, [fieldId]: value };
|
editEntry = { ...editEntry, [fieldId]: value };
|
||||||
|
|
@ -220,34 +224,66 @@ function cancelEdit() {
|
||||||
return 'Spelare';
|
return 'Spelare';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveEdit(registration: TournamentRegistrationItem) {
|
async function saveEdit(registration: TournamentRegistrationItem) {
|
||||||
if (!tournament.slug || editingId !== registration.id) return;
|
if (!tournament.slug || editingId !== registration.id) return;
|
||||||
editSaving = true;
|
editSaving = true;
|
||||||
editError = '';
|
editError = '';
|
||||||
const payload = {
|
const entry: Record<string, string> = {};
|
||||||
entry: { ...editEntry },
|
for (const [key, value] of Object.entries(editEntry)) {
|
||||||
participants: editParticipants.map((participant) => ({ ...participant }))
|
entry[key] = (value ?? '').trim();
|
||||||
};
|
}
|
||||||
try {
|
const participants = editParticipants.map((participant) => {
|
||||||
const result = await callEndpoint(
|
const map: Record<string, string> = {};
|
||||||
'update',
|
for (const [key, value] of Object.entries(participant)) {
|
||||||
{
|
map[key] = (value ?? '').trim();
|
||||||
registration_id: registration.id,
|
}
|
||||||
entry: payload.entry,
|
return map;
|
||||||
participants: payload.participants
|
});
|
||||||
},
|
const payload = {
|
||||||
'Kunde inte uppdatera anmälan.'
|
entry,
|
||||||
);
|
participants
|
||||||
applyResult(result);
|
};
|
||||||
loadError = result.warning ?? '';
|
|
||||||
cancelEdit();
|
if (entryFields().some(isAttendanceField)) {
|
||||||
} catch (err) {
|
const attendanceValue = (payload.entry[ATTENDANCE_FIELD_ID] ?? '').trim();
|
||||||
console.error('Failed to update registration', err);
|
if (!attendanceValue) {
|
||||||
editError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
editSaving = false;
|
||||||
} finally {
|
editError = 'Ange deltagar-ID från närvarolistan.';
|
||||||
editSaving = false;
|
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',
|
||||||
|
{
|
||||||
|
registration_id: registration.id,
|
||||||
|
entry: payload.entry,
|
||||||
|
participants: payload.participants
|
||||||
|
},
|
||||||
|
'Kunde inte uppdatera anmälan.'
|
||||||
|
);
|
||||||
|
applyResult(result);
|
||||||
|
loadError = result.warning ?? '';
|
||||||
|
cancelEdit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update registration', err);
|
||||||
|
editError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
||||||
|
} finally {
|
||||||
|
editSaving = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function exportRegistrations() {
|
function exportRegistrations() {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
@ -301,59 +337,62 @@ async function saveEdit(registration: TournamentRegistrationItem) {
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeRegistration(registration: TournamentRegistrationItem) {
|
async function removeRegistration(registration: TournamentRegistrationItem) {
|
||||||
if (!tournament.slug) return;
|
if (!tournament.slug) return;
|
||||||
if (!window.confirm('Ta bort denna anmälan? Det går inte att ångra.')) return;
|
if (!window.confirm('Ta bort denna anmälan? Det går inte att ångra.')) return;
|
||||||
deletingId = registration.id;
|
deletingId = registration.id;
|
||||||
deleteError = '';
|
deleteError = '';
|
||||||
try {
|
try {
|
||||||
const result = await callEndpoint(
|
const result = await callEndpoint(
|
||||||
'delete',
|
'delete',
|
||||||
{ registration_id: registration.id },
|
{ registration_id: registration.id },
|
||||||
'Kunde inte ta bort anmälan.'
|
'Kunde inte ta bort anmälan.'
|
||||||
|
);
|
||||||
|
applyResult(result);
|
||||||
|
loadError = result.warning ?? '';
|
||||||
|
if (editingId === registration.id) {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete registration', err);
|
||||||
|
deleteError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
||||||
|
} finally {
|
||||||
|
deletingId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRegistrations() {
|
||||||
|
if (!tournament.slug) return;
|
||||||
|
refreshing = true;
|
||||||
|
loadError = '';
|
||||||
|
try {
|
||||||
|
const result = await callEndpoint('refresh', undefined, 'Kunde inte hämta anmälningarna.');
|
||||||
|
applyResult(result);
|
||||||
|
loadError = result.warning ?? '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to refresh registrations', err);
|
||||||
|
loadError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const stop = listenToTournamentEvents(
|
||||||
|
(updated) => {
|
||||||
|
if (updated.id === tournament.id && editingId === null) {
|
||||||
|
void refreshRegistrations();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(deletedId) => {
|
||||||
|
if (deletedId === tournament.id) {
|
||||||
|
registrations = [];
|
||||||
|
loadError = 'Turneringen har tagits bort.';
|
||||||
|
}
|
||||||
|
}
|
||||||
);
|
);
|
||||||
applyResult(result);
|
|
||||||
loadError = result.warning ?? '';
|
|
||||||
if (editingId === registration.id) {
|
|
||||||
cancelEdit();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to delete registration', err);
|
|
||||||
deleteError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
|
||||||
} finally {
|
|
||||||
deletingId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshRegistrations() {
|
|
||||||
if (!tournament.slug) return;
|
|
||||||
refreshing = true;
|
|
||||||
loadError = '';
|
|
||||||
try {
|
|
||||||
const result = await callEndpoint('refresh', undefined, 'Kunde inte hämta anmälningarna.');
|
|
||||||
applyResult(result);
|
|
||||||
loadError = result.warning ?? '';
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to refresh registrations', err);
|
|
||||||
loadError = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
|
||||||
} finally {
|
|
||||||
refreshing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
const stop = listenToTournamentEvents((updated) => {
|
|
||||||
if (updated.id === tournament.id && editingId === null) {
|
|
||||||
void refreshRegistrations();
|
|
||||||
}
|
|
||||||
}, (deletedId) => {
|
|
||||||
if (deletedId === tournament.id) {
|
|
||||||
registrations = [];
|
|
||||||
loadError = 'Turneringen har tagits bort.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
return () => {
|
||||||
stop();
|
stop();
|
||||||
};
|
};
|
||||||
|
|
@ -366,9 +405,11 @@ onMount(() => {
|
||||||
|
|
||||||
<div class="min-h-screen bg-slate-100 text-slate-900">
|
<div class="min-h-screen bg-slate-100 text-slate-900">
|
||||||
<div class="mx-auto flex min-h-screen max-w-5xl flex-col gap-8 px-3 py-8 sm:px-4">
|
<div class="mx-auto flex min-h-screen max-w-5xl flex-col gap-8 px-3 py-8 sm:px-4">
|
||||||
<header 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">
|
<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>
|
<h1 class="text-2xl font-semibold text-slate-900">{tournament.title}</h1>
|
||||||
<p class="text-sm text-slate-600">{tournament.game}</p>
|
<p class="text-sm text-slate-600">{tournament.game}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -392,17 +433,19 @@ onMount(() => {
|
||||||
<h2 class="text-lg font-semibold text-slate-900">Sammanfattning</h2>
|
<h2 class="text-lg font-semibold text-slate-900">Sammanfattning</h2>
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
<p class="text-xs 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>
|
<p class="mt-1 text-2xl font-semibold text-slate-900">{registrations.length}</p>
|
||||||
</div>
|
</div>
|
||||||
{#if tournament.start_at}
|
{#if tournament.start_at}
|
||||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
<p class="text-xs uppercase tracking-wide text-slate-500">Start</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>
|
<p class="mt-1 text-sm text-slate-800">
|
||||||
|
{formatDateTime(tournament.start_at) ?? tournament.start_at}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
|
||||||
<p class="text-xs 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">
|
<p class="mt-1 text-sm text-slate-800">
|
||||||
{signupConfig().mode === 'team'
|
{signupConfig().mode === 'team'
|
||||||
? `Lag (${signupConfig().team_size.min}–${signupConfig().team_size.max} spelare)`
|
? `Lag (${signupConfig().team_size.min}–${signupConfig().team_size.max} spelare)`
|
||||||
|
|
@ -412,35 +455,47 @@ onMount(() => {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
<section class="rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<h2 class="text-lg font-semibold text-slate-900">Registreringar</h2>
|
<h2 class="text-lg font-semibold text-slate-900">Registreringar</h2>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
{#if registrations.length > 0}
|
{#if registrations.length > 0}
|
||||||
<p class="text-sm text-slate-500">Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}</p>
|
<p class="text-sm text-slate-500">
|
||||||
{/if}
|
Senaste: {formatDateTime(registrations[0].created_at) ?? registrations[0].created_at}
|
||||||
<button
|
</p>
|
||||||
type="button"
|
{/if}
|
||||||
onclick={exportRegistrations}
|
<button
|
||||||
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"
|
type="button"
|
||||||
>
|
onclick={exportRegistrations}
|
||||||
Exportera .txt
|
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"
|
||||||
</button>
|
>
|
||||||
</div>
|
Exportera .txt
|
||||||
</header>
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
{#if loadError}
|
{#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
|
||||||
{/if}
|
class="mt-2 rounded-md border border-red-200 bg-red-500/10 px-4 py-2 text-sm text-red-600"
|
||||||
{#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>
|
{loadError}
|
||||||
{/if}
|
</p>
|
||||||
{#if refreshing && !loadError}
|
{/if}
|
||||||
<p class="mt-2 text-xs text-slate-500">Uppdaterar…</p>
|
{#if deleteError}
|
||||||
{/if}
|
<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}
|
{#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.
|
Inga anmälningar ännu. Dela länken till /tournament/{tournament.slug} för att samla in registreringar.
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -450,7 +505,7 @@ onMount(() => {
|
||||||
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<header class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-base font-semibold text-slate-900">Anmälan #{registration.id}</h3>
|
<h3 class="text-base font-semibold text-slate-900">Anmälan #{registration.id}</h3>
|
||||||
<p class="text-xs uppercase tracking-wide text-slate-500">
|
<p class="text-xs tracking-wide text-slate-500 uppercase">
|
||||||
Skapad {formatDateTime(registration.created_at) ?? registration.created_at}
|
Skapad {formatDateTime(registration.created_at) ?? registration.created_at}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -484,25 +539,42 @@ onMount(() => {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if editingId === registration.id}
|
{#if editingId === registration.id}
|
||||||
<form class="space-y-4" onsubmit={(event) => {
|
<form
|
||||||
event.preventDefault();
|
class="space-y-4"
|
||||||
saveEdit(registration);
|
onsubmit={(event) => {
|
||||||
}}>
|
event.preventDefault();
|
||||||
|
saveEdit(registration);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{#if entryFields().length > 0}
|
{#if entryFields().length > 0}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-semibold 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">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
{#each entryFields() as field}
|
{#each entryFields() as field}
|
||||||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
|
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
<input
|
<input
|
||||||
type={fieldInputType(field.field_type)}
|
type={fieldInputType(field.field_type)}
|
||||||
|
inputmode={isAttendanceField(field) ? 'numeric' : undefined}
|
||||||
|
pattern={isAttendanceField(field) ? '\\d*' : undefined}
|
||||||
value={editEntry[field.id] ?? ''}
|
value={editEntry[field.id] ?? ''}
|
||||||
oninput={(event) => updateEntryField(field.id, (event.currentTarget as HTMLInputElement).value)}
|
oninput={(event) =>
|
||||||
|
updateEntryField(
|
||||||
|
field.id,
|
||||||
|
(event.currentTarget as HTMLInputElement).value
|
||||||
|
)}
|
||||||
placeholder={field.placeholder ?? ''}
|
placeholder={field.placeholder ?? ''}
|
||||||
disabled={editSaving}
|
disabled={editSaving}
|
||||||
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus: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>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -511,12 +583,15 @@ onMount(() => {
|
||||||
|
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h4 class="text-sm font-semibold 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'}
|
{#if signupConfig().mode === 'team'}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={addParticipant}
|
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"
|
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
Lägg till spelare
|
Lägg till spelare
|
||||||
|
|
@ -533,12 +608,17 @@ onMount(() => {
|
||||||
{#each editParticipants as participant, index}
|
{#each editParticipants as participant, index}
|
||||||
<div class="space-y-3 rounded-md border border-slate-200 bg-white p-3">
|
<div class="space-y-3 rounded-md border border-slate-200 bg-white p-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p 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'}
|
{#if signupConfig().mode === 'team'}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => removeParticipant(index)}
|
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"
|
class="rounded-full border border-red-200 px-3 py-1 text-xs font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
>
|
>
|
||||||
Ta bort
|
Ta bort
|
||||||
|
|
@ -547,27 +627,38 @@ onMount(() => {
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
{#each participantFields() as field}
|
{#each participantFields() as field}
|
||||||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
|
<label
|
||||||
|
class="flex flex-col gap-1 text-sm font-medium text-slate-700"
|
||||||
|
>
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
<input
|
<input
|
||||||
type={fieldInputType(field.field_type)}
|
type={fieldInputType(field.field_type)}
|
||||||
value={participant[field.id] ?? ''}
|
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 ?? ''}
|
placeholder={field.placeholder ?? ''}
|
||||||
disabled={editSaving}
|
disabled={editSaving}
|
||||||
class="rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus: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>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{#if editError}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-3 pt-2">
|
<div class="flex flex-wrap items-center gap-3 pt-2">
|
||||||
|
|
@ -593,8 +684,10 @@ onMount(() => {
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
{#each entryFields() as field}
|
{#each entryFields() as field}
|
||||||
<div class="rounded-md border border-slate-200 bg-white p-3">
|
<div class="rounded-md border border-slate-200 bg-white p-3">
|
||||||
<p class="text-xs uppercase tracking-wide text-slate-500">{field.label}</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>
|
<p class="mt-1 text-sm text-slate-800">
|
||||||
|
{fieldValue(registration.entry, field) || '—'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -606,7 +699,9 @@ onMount(() => {
|
||||||
{#if registration.participants.length === 0}
|
{#if registration.participants.length === 0}
|
||||||
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-xs text-slate-500">Antal spelare: {registration.participants.length}</p>
|
<p class="text-xs text-slate-500">
|
||||||
|
Antal spelare: {registration.participants.length}
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if registration.participants.length === 0}
|
{:else if registration.participants.length === 0}
|
||||||
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
<p class="text-xs text-slate-500">Inga spelare angivna.</p>
|
||||||
|
|
@ -614,7 +709,9 @@ onMount(() => {
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each registration.participants as participant, index}
|
{#each registration.participants as participant, index}
|
||||||
<div class="rounded-md border border-slate-200 bg-white p-3">
|
<div class="rounded-md border border-slate-200 bg-white p-3">
|
||||||
<p class="text-xs font-semibold 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">
|
<ul class="mt-2 space-y-1 text-sm text-slate-800">
|
||||||
{#each participantFields() as field}
|
{#each participantFields() as field}
|
||||||
<li>
|
<li>
|
||||||
|
|
@ -624,8 +721,8 @@ onMount(() => {
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
|
|
||||||
const props = $props<{ data: { tournament: TournamentInfo } }>();
|
const props = $props<{ data: { tournament: TournamentInfo } }>();
|
||||||
const tournament = props.data.tournament;
|
const tournament = props.data.tournament;
|
||||||
|
const ATTENDANCE_FIELD_ID = 'attendance-id';
|
||||||
|
|
||||||
function pickMode(value: string | null | undefined) {
|
function pickMode(value: string | null | undefined) {
|
||||||
return value === 'team' ? 'team' : 'solo';
|
return value === 'team' ? 'team' : 'solo';
|
||||||
|
|
@ -24,7 +25,9 @@
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSignupConfig(config: TournamentSignupConfig | null | undefined): TournamentSignupConfig {
|
function normalizeSignupConfig(
|
||||||
|
config: TournamentSignupConfig | null | undefined
|
||||||
|
): TournamentSignupConfig {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return {
|
return {
|
||||||
mode: 'solo',
|
mode: 'solo',
|
||||||
|
|
@ -94,6 +97,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAttendanceField(field: TournamentSignupField): boolean {
|
||||||
|
return field.id === ATTENDANCE_FIELD_ID;
|
||||||
|
}
|
||||||
|
|
||||||
const minParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.min : 1;
|
const minParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.min : 1;
|
||||||
const maxParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.max : 1;
|
const maxParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.max : 1;
|
||||||
|
|
||||||
|
|
@ -109,8 +116,6 @@
|
||||||
showSuccessModal: false
|
showSuccessModal: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function initializeParticipants() {
|
function initializeParticipants() {
|
||||||
const initialCount = Math.max(1, signupConfig.mode === 'team' ? signupConfig.team_size.min : 1);
|
const initialCount = Math.max(1, signupConfig.mode === 'team' ? signupConfig.team_size.min : 1);
|
||||||
const list: FieldValueMap[] = [];
|
const list: FieldValueMap[] = [];
|
||||||
|
|
@ -159,6 +164,13 @@
|
||||||
entry[field.id] = (signup.entry[field.id] ?? '').trim();
|
entry[field.id] = (signup.entry[field.id] ?? '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (entry[ATTENDANCE_FIELD_ID]) {
|
||||||
|
const numeric = Number.parseInt(entry[ATTENDANCE_FIELD_ID], 10);
|
||||||
|
if (Number.isFinite(numeric) && numeric > 0) {
|
||||||
|
entry[ATTENDANCE_FIELD_ID] = String(numeric);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const participants = signup.participants.map((participant) => {
|
const participants = signup.participants.map((participant) => {
|
||||||
const map: Record<string, string> = {};
|
const map: Record<string, string> = {};
|
||||||
for (const field of signupConfig.participant_fields) {
|
for (const field of signupConfig.participant_fields) {
|
||||||
|
|
@ -175,6 +187,22 @@
|
||||||
signup.error = '';
|
signup.error = '';
|
||||||
signup.success = '';
|
signup.success = '';
|
||||||
|
|
||||||
|
const attendanceValue = (signup.entry[ATTENDANCE_FIELD_ID] ?? '').trim();
|
||||||
|
if (!attendanceValue) {
|
||||||
|
signup.error = 'Ange ditt deltagar-ID från närvarolistan.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^\d+$/.test(attendanceValue)) {
|
||||||
|
signup.error = 'Deltagar-ID får endast innehålla siffror.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attendanceNumeric = Number.parseInt(attendanceValue, 10);
|
||||||
|
if (!Number.isFinite(attendanceNumeric) || attendanceNumeric <= 0) {
|
||||||
|
signup.error = 'Ange ett giltigt deltagar-ID.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signup.entry[ATTENDANCE_FIELD_ID] = String(attendanceNumeric);
|
||||||
|
|
||||||
if (signup.participants.length === 0) {
|
if (signup.participants.length === 0) {
|
||||||
signup.error = 'Lägg till minst en spelare.';
|
signup.error = 'Lägg till minst en spelare.';
|
||||||
return;
|
return;
|
||||||
|
|
@ -251,12 +279,12 @@
|
||||||
<span aria-hidden="true">←</span>
|
<span aria-hidden="true">←</span>
|
||||||
<span>Tillbaka till turneringsöversikten</span>
|
<span>Tillbaka till turneringsöversikten</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="uppercase tracking-[0.4em] text-indigo-300">{tournament.game}</span>
|
<span class="tracking-[0.4em] text-indigo-300 uppercase">{tournament.game}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<header class="space-y-4 rounded-2xl bg-slate-900/70 p-6 shadow-lg">
|
<header class="space-y-4 rounded-2xl bg-slate-900/70 p-6 shadow-lg">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<p class="text-xs 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>
|
<h1 class="text-3xl font-bold sm:text-4xl">{tournament.title}</h1>
|
||||||
{#if tournament.tagline}
|
{#if tournament.tagline}
|
||||||
<p class="text-base text-slate-300">{tournament.tagline}</p>
|
<p class="text-base text-slate-300">{tournament.tagline}</p>
|
||||||
|
|
@ -265,19 +293,19 @@
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
{#if formattedStart}
|
{#if formattedStart}
|
||||||
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
||||||
<p class="text-xs 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>
|
<p class="text-sm text-slate-100">{formattedStart}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tournament.location}
|
{#if tournament.location}
|
||||||
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
||||||
<p class="text-xs 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>
|
<p class="text-sm text-slate-100">{tournament.location}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if tournament.contact}
|
{#if tournament.contact}
|
||||||
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 sm:col-span-2">
|
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 sm:col-span-2">
|
||||||
<p class="text-xs 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>
|
<p class="text-sm text-slate-100">{tournament.contact}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -287,7 +315,9 @@
|
||||||
{#if tournament.description}
|
{#if tournament.description}
|
||||||
<section class="space-y-3 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
|
<section class="space-y-3 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
|
||||||
<h2 class="text-lg font-semibold text-slate-100">Beskrivning</h2>
|
<h2 class="text-lg font-semibold text-slate-100">Beskrivning</h2>
|
||||||
<p class="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>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -296,7 +326,7 @@
|
||||||
{#each tournament.sections as section, index (section.title + index)}
|
{#each tournament.sections as section, index (section.title + index)}
|
||||||
<article class="space-y-2 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
|
<article class="space-y-2 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
|
||||||
<h3 class="text-base font-semibold text-indigo-200">{section.title}</h3>
|
<h3 class="text-base font-semibold text-indigo-200">{section.title}</h3>
|
||||||
<p class="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>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -306,7 +336,9 @@
|
||||||
<header class="space-y-1">
|
<header class="space-y-1">
|
||||||
<h2 class="text-lg font-semibold text-slate-100">Anmälan</h2>
|
<h2 class="text-lg font-semibold text-slate-100">Anmälan</h2>
|
||||||
{#if signupConfig.mode === 'team'}
|
{#if signupConfig.mode === 'team'}
|
||||||
<p class="text-sm text-slate-300">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}
|
{:else}
|
||||||
<p class="text-sm text-slate-300">Individuell anmälan.</p>
|
<p class="text-sm text-slate-300">Individuell anmälan.</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -315,7 +347,9 @@
|
||||||
<form class="space-y-5" onsubmit={handleSignupSubmit}>
|
<form class="space-y-5" onsubmit={handleSignupSubmit}>
|
||||||
{#if signupConfig.entry_fields.length > 0}
|
{#if signupConfig.entry_fields.length > 0}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Lag / deltagare</h3>
|
<h3 class="text-sm font-semibold tracking-wide text-slate-400 uppercase">
|
||||||
|
Lag / deltagare
|
||||||
|
</h3>
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
{#each signupConfig.entry_fields as field}
|
{#each signupConfig.entry_fields as field}
|
||||||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-200">
|
<label class="flex flex-col gap-1 text-sm font-medium text-slate-200">
|
||||||
|
|
@ -324,131 +358,165 @@
|
||||||
type={fieldInputType(field.field_type)}
|
type={fieldInputType(field.field_type)}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
placeholder={field.placeholder ?? ''}
|
placeholder={field.placeholder ?? ''}
|
||||||
|
inputmode={isAttendanceField(field) ? 'numeric' : undefined}
|
||||||
|
pattern={isAttendanceField(field) ? '\\d*' : undefined}
|
||||||
|
autocomplete={isAttendanceField(field) ? 'off' : undefined}
|
||||||
value={signup.entry[field.id]}
|
value={signup.entry[field.id]}
|
||||||
oninput={(event) => (signup.entry[field.id] = (event.currentTarget as HTMLInputElement).value)}
|
oninput={(event) =>
|
||||||
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"
|
(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>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if signup.showSuccessModal}
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 px-4">
|
|
||||||
<div
|
|
||||||
class="w-full max-w-2xl space-y-6 rounded-2xl border border-slate-800 bg-slate-900 p-6 shadow-2xl"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="signup-success-title"
|
|
||||||
>
|
|
||||||
<header class="space-y-2 text-center">
|
|
||||||
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p>
|
|
||||||
<h2 id="signup-success-title" class="text-2xl font-semibold text-slate-100 sm:text-3xl">
|
|
||||||
Anmälan bekräftad
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-slate-300">Du är registrerad till {tournament.title}.</p>
|
|
||||||
{#if signup.successRegistrationId}
|
|
||||||
<p class="text-xs uppercase tracking-wide text-indigo-200">Anmälan #{signup.successRegistrationId}</p>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-2">
|
|
||||||
<div class="space-y-2 text-left">
|
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Turnering</h3>
|
|
||||||
<p class="text-sm text-slate-300"><span class="font-medium text-slate-100">Spel:</span> {tournament.game}</p>
|
|
||||||
{#if tournament.start_at}
|
|
||||||
<p class="text-sm text-slate-300">
|
|
||||||
<span class="font-medium text-slate-100">Start:</span>
|
|
||||||
{formatDateTime(tournament.start_at) ?? tournament.start_at}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
{#if tournament.location}
|
|
||||||
<p class="text-sm text-slate-300">
|
|
||||||
<span class="font-medium text-slate-100">Plats:</span> {tournament.location}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2 text-left">
|
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Format</h3>
|
|
||||||
{#if signupConfig.mode === 'team'}
|
|
||||||
<p class="text-sm text-slate-300">
|
|
||||||
Lag {signupConfig.team_size.min}–{signupConfig.team_size.max} spelare
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-slate-300">Individuell anmälan</p>
|
|
||||||
{/if}
|
|
||||||
{#if tournament.contact}
|
|
||||||
<p class="text-sm text-slate-300">
|
|
||||||
<span class="font-medium text-slate-100">Kontakt:</span> {tournament.contact}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-3">
|
|
||||||
<h3 class="text-base font-semibold text-slate-100">Anmälningsuppgifter</h3>
|
|
||||||
{#if signupConfig.entry_fields.length === 0}
|
|
||||||
<p class="rounded-md border border-dashed border-slate-700 px-4 py-3 text-sm text-slate-300">
|
|
||||||
Den här turneringen kräver inga uppgifter utöver spelare.
|
|
||||||
</p>
|
|
||||||
{:else}
|
|
||||||
<div class="grid gap-3 md:grid-cols-2">
|
|
||||||
{#each signupConfig.entry_fields as field}
|
|
||||||
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
|
|
||||||
<p class="text-xs uppercase tracking-wide text-indigo-200">{field.label}</p>
|
|
||||||
<p class="mt-1 text-sm text-slate-100">{signup.submittedEntry[field.id] || '—'}</p>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="space-y-3">
|
{#if signup.showSuccessModal}
|
||||||
<h3 class="text-base font-semibold text-slate-100">Spelare</h3>
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 px-4">
|
||||||
{#if signupConfig.participant_fields.length === 0}
|
<div
|
||||||
{#if signup.submittedParticipants.length === 0}
|
class="w-full max-w-2xl space-y-6 rounded-2xl border border-slate-800 bg-slate-900 p-6 shadow-2xl"
|
||||||
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
|
role="dialog"
|
||||||
{:else}
|
aria-modal="true"
|
||||||
<p class="text-sm text-slate-300">Antal spelare: {signup.submittedParticipants.length}</p>
|
aria-labelledby="signup-success-title"
|
||||||
{/if}
|
>
|
||||||
{:else if signup.submittedParticipants.length === 0}
|
<header class="space-y-2 text-center">
|
||||||
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
|
<p class="text-xs tracking-[0.4em] text-indigo-300 uppercase">VBytes LAN</p>
|
||||||
{:else}
|
<h2
|
||||||
<div class="space-y-3">
|
id="signup-success-title"
|
||||||
{#each signup.submittedParticipants as participant, index}
|
class="text-2xl font-semibold text-slate-100 sm:text-3xl"
|
||||||
<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>
|
Anmälan bekräftad
|
||||||
<ul class="mt-2 space-y-1 text-sm text-slate-100">
|
</h2>
|
||||||
{#each signupConfig.participant_fields as field}
|
<p class="text-sm text-slate-300">Du är registrerad till {tournament.title}.</p>
|
||||||
<li>
|
{#if signup.successRegistrationId}
|
||||||
<span class="font-medium text-slate-300">{field.label}:</span>
|
<p class="text-xs tracking-wide text-indigo-200 uppercase">
|
||||||
<span class="ml-1">{participant[field.id] || '—'}</span>
|
Anmälan #{signup.successRegistrationId}
|
||||||
</li>
|
</p>
|
||||||
{/each}
|
{/if}
|
||||||
</ul>
|
</header>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-2"
|
||||||
|
>
|
||||||
|
<div class="space-y-2 text-left">
|
||||||
|
<h3 class="text-sm font-semibold 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>
|
||||||
|
{formatDateTime(tournament.start_at) ?? tournament.start_at}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if tournament.location}
|
||||||
|
<p class="text-sm text-slate-300">
|
||||||
|
<span class="font-medium text-slate-100">Plats:</span>
|
||||||
|
{tournament.location}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 text-left">
|
||||||
|
<h3 class="text-sm font-semibold 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
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-slate-300">Individuell anmälan</p>
|
||||||
|
{/if}
|
||||||
|
{#if tournament.contact}
|
||||||
|
<p class="text-sm text-slate-300">
|
||||||
|
<span class="font-medium text-slate-100">Kontakt:</span>
|
||||||
|
{tournament.contact}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h3 class="text-base font-semibold text-slate-100">Anmälningsuppgifter</h3>
|
||||||
|
{#if signupConfig.entry_fields.length === 0}
|
||||||
|
<p
|
||||||
|
class="rounded-md border border-dashed border-slate-700 px-4 py-3 text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
Den här turneringen kräver inga uppgifter utöver spelare.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#each signupConfig.entry_fields as field}
|
||||||
|
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
|
||||||
|
<p class="text-xs 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>
|
||||||
|
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h3 class="text-base font-semibold text-slate-100">Spelare</h3>
|
||||||
|
{#if signupConfig.participant_fields.length === 0}
|
||||||
|
{#if signup.submittedParticipants.length === 0}
|
||||||
|
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-slate-300">
|
||||||
|
Antal spelare: {signup.submittedParticipants.length}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{:else if signup.submittedParticipants.length === 0}
|
||||||
|
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each signup.submittedParticipants as participant, index}
|
||||||
|
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
|
||||||
|
<p class="text-xs font-semibold 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>
|
||||||
|
<span class="font-medium text-slate-300">{field.label}:</span>
|
||||||
|
<span class="ml-1">{participant[field.id] || '—'}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex justify-center pt-2">
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center justify-center rounded-full border border-emerald-300 px-5 py-2 text-sm font-semibold text-emerald-200 transition hover:border-emerald-400 hover:bg-emerald-500/10"
|
||||||
|
>
|
||||||
|
Stäng
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="flex justify-center pt-2">
|
|
||||||
<a
|
|
||||||
href="/"
|
|
||||||
class="inline-flex items-center justify-center rounded-full border border-emerald-300 px-5 py-2 text-sm font-semibold text-emerald-200 transition hover:border-emerald-400 hover:bg-emerald-500/10"
|
|
||||||
>
|
|
||||||
Stäng
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Spelare</h3>
|
<h3 class="text-sm font-semibold tracking-wide text-slate-400 uppercase">Spelare</h3>
|
||||||
{#if signupConfig.mode === 'team'}
|
{#if signupConfig.mode === 'team'}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -466,7 +534,9 @@
|
||||||
{#each signup.participants as participant, index (index)}
|
{#each signup.participants as participant, index (index)}
|
||||||
<div class="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4">
|
<div class="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm font-semibold text-slate-200">{participantDisplayName(index)}</span>
|
<span class="text-sm font-semibold text-slate-200"
|
||||||
|
>{participantDisplayName(index)}</span
|
||||||
|
>
|
||||||
{#if signupConfig.mode === 'team' && canRemoveParticipant()}
|
{#if signupConfig.mode === 'team' && canRemoveParticipant()}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -489,8 +559,11 @@
|
||||||
required={field.required}
|
required={field.required}
|
||||||
placeholder={field.placeholder ?? ''}
|
placeholder={field.placeholder ?? ''}
|
||||||
value={participant[field.id] ?? ''}
|
value={participant[field.id] ?? ''}
|
||||||
oninput={(event) => (participant[field.id] = (event.currentTarget as HTMLInputElement).value)}
|
oninput={(event) =>
|
||||||
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"
|
(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>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
@ -504,15 +577,21 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2 text-sm">
|
<div class="space-y-2 text-sm">
|
||||||
{#if signup.error}
|
{#if signup.error}
|
||||||
<p class="rounded-md border border-red-400 bg-red-500/10 px-4 py-2 text-red-200">{signup.error}</p>
|
<p class="rounded-md border border-red-400 bg-red-500/10 px-4 py-2 text-red-200">
|
||||||
{:else if signup.success}
|
{signup.error}
|
||||||
<p class="rounded-md border border-emerald-400 bg-emerald-500/10 px-4 py-2 text-emerald-200">{signup.success}</p>
|
</p>
|
||||||
{:else}
|
{:else if signup.success}
|
||||||
<p class="text-slate-400">Din anmälan skickas direkt till arrangören.</p>
|
<p
|
||||||
{/if}
|
class="rounded-md border border-emerald-400 bg-emerald-500/10 px-4 py-2 text-emerald-200"
|
||||||
</div>
|
>
|
||||||
|
{signup.success}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-slate-400">Din anmälan skickas direkt till arrangören.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -526,7 +605,10 @@
|
||||||
|
|
||||||
<footer class="mt-auto flex items-center justify-between text-xs text-slate-500">
|
<footer class="mt-auto flex items-center justify-between text-xs text-slate-500">
|
||||||
<p>Senast uppdaterad {formatDateTime(tournament.updated_at) ?? tournament.updated_at}</p>
|
<p>Senast uppdaterad {formatDateTime(tournament.updated_at) ?? tournament.updated_at}</p>
|
||||||
<a 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
|
Administrera
|
||||||
</a>
|
</a>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue