vbytes-lan-attendence/web/src/routes/(admin)/admin/tournament/+page.svelte

1777 lines
60 KiB
Svelte
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import type {
TournamentFieldType,
TournamentInfo,
TournamentSection,
TournamentSignupConfig,
TournamentSignupField
} from '$lib/types';
import { listenToTournamentEvents } from '$lib/client/tournament-events';
const props = $props<{ data: { tournaments: TournamentInfo[] } }>();
const initialTournaments: TournamentInfo[] = props.data.tournaments ?? [];
type TabKey = 'overview' | 'create' | 'manage';
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 från närvarolistan';
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 };
entry_fields: SignupFieldForm[];
participant_fields: SignupFieldForm[];
};
type TournamentForm = {
title: string;
game: string;
slug: string;
tagline: string;
start_at: string;
location: string;
description: string;
contact: string;
sections: TournamentSection[];
signup: SignupConfigForm;
};
function createEmptyForm(): TournamentForm {
return {
title: '',
game: '',
slug: '',
tagline: '',
start_at: '',
location: '',
description: '',
contact: '',
sections: [],
signup: createDefaultSignup()
};
}
function cloneSections(sections: TournamentSection[]): TournamentSection[] {
return sections.map((section) => ({ title: section.title, body: section.body }));
}
const fieldTypeOptions: { value: TournamentFieldType; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'email', label: 'E-post' },
{ value: 'tel', label: 'Telefon' },
{ value: 'discord', label: 'Discord' }
];
let fieldIdCounter = 0;
function nextFieldId(label: string) {
const base = slugify(label) || 'field';
fieldIdCounter += 1;
return `${base}-${fieldIdCounter.toString(36)}`;
}
function createSignupField(
label: string,
field_type: TournamentFieldType = 'text'
): SignupFieldForm {
return {
id: nextFieldId(label),
label,
field_type,
required: true,
placeholder: '',
unique: false
};
}
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,
field_type: field.field_type ?? 'text',
required: Boolean(field.required),
placeholder: field.placeholder ?? '',
unique: Boolean(field.unique)
};
}
function ensureAttendanceField(fields: SignupFieldForm[]): SignupFieldForm[] {
let attendanceField: SignupFieldForm | null = null;
const others: SignupFieldForm[] = [];
for (const field of fields) {
if (isAttendanceField(field)) {
const label = field.label.trim() || ATTENDANCE_FIELD_LABEL;
const placeholder = field.placeholder.trim() || ATTENDANCE_FIELD_PLACEHOLDER;
attendanceField = {
id: ATTENDANCE_FIELD_ID,
label,
field_type: 'text',
required: true,
placeholder,
unique: true
};
} else {
others.push(field);
}
}
if (!attendanceField) {
attendanceField = createAttendanceField();
}
return [attendanceField, ...others];
}
function createDefaultSignup(): SignupConfigForm {
return {
mode: 'solo',
team_size: { min: 1, max: 1 },
entry_fields: ensureAttendanceField([createSignupField('Lag / spelarnamn')]),
participant_fields: []
};
}
function cloneSignupConfig(config: TournamentSignupConfig | null | undefined): SignupConfigForm {
if (!config) return createDefaultSignup();
let entry_fields = ensureAttendanceField((config.entry_fields ?? []).map(cloneSignupField));
let participant_fields = (config.participant_fields ?? []).map(cloneSignupField);
if (entry_fields.length === 1) {
entry_fields = ensureAttendanceField([
...entry_fields,
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 = [];
}
return {
mode,
team_size: {
min: config.team_size?.min ?? 1,
max: config.team_size?.max ?? 1
},
entry_fields,
participant_fields
};
}
function signupFieldKey(index: number, field?: SignupFieldForm) {
const suffix = field?.id ?? index;
return `signup-${suffix}`;
}
function addEntryField(form: TournamentForm) {
form.signup.entry_fields = ensureAttendanceField([
...form.signup.entry_fields,
createSignupField('Nytt fält')
]);
}
function removeEntryField(form: TournamentForm, index: number) {
const target = form.signup.entry_fields[index];
if (!target || isAttendanceField(target)) return;
const remaining = form.signup.entry_fields.filter((_, idx) => idx !== index);
form.signup.entry_fields = ensureAttendanceField(remaining);
}
function addParticipantField(form: TournamentForm) {
form.signup.participant_fields = [
...form.signup.participant_fields,
createSignupField('Spelarinfo')
];
}
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
);
}
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;
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;
}
if (form.signup.participant_fields.length === 0) {
form.signup.participant_fields = [createSignupField('Spelare')];
}
}
}
function setTeamSize(form: TournamentForm, key: 'min' | 'max', 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;
}
function slugify(value: string) {
let slug = value
.trim()
.toLowerCase()
.split('')
.map((char) => (/[a-z0-9]/.test(char) ? char : '-'))
.join('');
while (slug.includes('--')) {
slug = slug.replace('--', '-');
}
slug = slug.replace(/^[-]+|[-]+$/g, '');
return slug;
}
function normalizedSlug(value: string, fallbackTitle: string) {
const source = value.trim() ? value : fallbackTitle;
const slug = slugify(source);
return slug || 'turnering';
}
function toLocalInput(value: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const pad = (num: number) => `${num}`.padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function toIsoString(value: string) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
function formatDateTime(value: string | null) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleString('sv-SE', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
}
function payloadSections(sections: TournamentSection[]) {
return sections
.map((section) => ({
title: section.title.trim(),
body: section.body.trim()
}))
.filter((section) => section.title || section.body);
}
function payloadSignup(signup: SignupConfigForm): TournamentSignupConfig {
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';
const placeholder = field.placeholder.trim();
const type = allowedTypes.includes(field.field_type) ? field.field_type : 'text';
return {
id,
label,
field_type: type,
required: Boolean(field.required),
placeholder: placeholder ? placeholder : null,
unique: Boolean(field.unique)
};
};
const toNumber = (value: number) => {
if (Number.isFinite(value)) {
return Math.max(1, Math.floor(value));
}
return 1;
};
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;
}
const entry_fields = ensureAttendanceField(signup.entry_fields).map(normalizeField);
const participant_fields = mode === 'team' ? signup.participant_fields.map(normalizeField) : [];
return {
mode,
team_size: { min, max },
entry_fields,
participant_fields
};
}
function buildPayload(form: TournamentForm) {
const slug = normalizedSlug(form.slug, form.title);
return {
title: form.title.trim(),
game: form.game.trim(),
slug,
tagline: form.tagline.trim() || null,
start_at: toIsoString(form.start_at) ?? null,
location: form.location.trim() || null,
description: form.description.trim() || null,
registration_url: null,
contact: form.contact.trim() || null,
sections: payloadSections(form.sections),
signup_config: payloadSignup(form.signup)
};
}
let tournaments = $state<TournamentInfo[]>([]);
let overviewGameFilter = $state('all');
const activeTab = $derived((): TabKey => {
const param = $page.url.searchParams.get('tab');
return param === 'create' || param === 'manage' ? param : 'overview';
});
const slugParam = $derived(() => {
const value = $page.url.searchParams.get('slug');
if (!value) return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
});
let refreshPromise: Promise<void> | null = null;
async function refreshTournaments(options: { preferredId?: number | null } = {}) {
if (refreshPromise) {
await refreshPromise;
return;
}
const preferredId = options.preferredId ?? null;
refreshPromise = (async () => {
try {
const response = await fetch('/api/tournament');
if (!response.ok) {
console.error('Failed to refresh tournaments', await response.text());
return;
}
const data = await response.json();
const list = (data?.tournaments ?? []) as TournamentInfo[];
tournaments = list;
if (preferredId !== null) {
const match = list.find((item) => item.id === preferredId);
if (match) {
manageState.selectedId = match.id;
}
}
} catch (err) {
console.error('Failed to refresh tournaments', err);
} finally {
refreshPromise = null;
}
})();
await refreshPromise;
}
function setTab(tab: TabKey, options: { slug?: string | null; replace?: boolean } = {}) {
const currentTab = activeTab();
const currentSlug = slugParam();
const targetSlug = options.slug ?? null;
if (currentTab === tab && (currentSlug ?? null) === (targetSlug ?? null)) {
return;
}
const url = new URL($page.url);
if (tab === 'overview') {
url.searchParams.delete('tab');
} else {
url.searchParams.set('tab', tab);
}
if (targetSlug) {
url.searchParams.set('slug', targetSlug);
} else {
url.searchParams.delete('slug');
}
void goto(`${url.pathname}${url.search}`, {
replaceState: options.replace ?? false,
keepFocus: true,
noScroll: true
});
}
function selectTournament(tournament: TournamentInfo, options: { replace?: boolean } = {}) {
manageState.selectedId = tournament.id;
manageState.success = '';
manageState.error = '';
setTab('manage', {
slug: tournament.slug,
replace: options.replace ?? false
});
}
$effect(() => {
if (tournaments.length === 0 && initialTournaments.length > 0) {
tournaments = [...initialTournaments];
}
});
$effect(() => {
const slug = slugParam();
if (!slug) return;
if (activeTab() !== 'manage') {
setTab('manage', { slug, replace: true });
return;
}
if (tournaments.length === 0) return;
if (manageState.selectedId !== null) return;
const match = tournaments.find((item) => item.slug === slug);
if (match) {
manageState.selectedId = match.id;
}
});
const availableGames = $derived(() => {
const set = new Set<string>();
for (const tournament of tournaments) {
if (tournament.game) set.add(tournament.game);
}
return Array.from(set).sort((a, b) => a.localeCompare(b, 'sv')) satisfies string[];
});
$effect(() => {
const games = availableGames();
if (overviewGameFilter !== 'all' && !games.includes(overviewGameFilter)) {
overviewGameFilter = 'all';
}
});
const filteredOverview = $derived(() => {
return tournaments.filter((tournament) => {
if (overviewGameFilter !== 'all' && tournament.game !== overviewGameFilter) {
return false;
}
return true;
}) satisfies TournamentInfo[];
});
onMount(() => {
const stop = listenToTournamentEvents(
(tournament) => {
void refreshTournaments({ preferredId: tournament.id });
},
(tournamentId) => {
const preferredId = manageState.selectedId === tournamentId ? null : manageState.selectedId;
void refreshTournaments({ preferredId: preferredId ?? undefined });
}
);
return () => {
stop();
};
});
$effect(() => {
if (tournaments.length === 0 && activeTab() === 'manage') {
setTab('overview', { replace: true });
}
});
type CreateState = {
form: TournamentForm;
saving: boolean;
success: string;
error: string;
autoSlug: boolean;
};
type ManageState = {
form: TournamentForm;
saving: boolean;
success: string;
error: string;
autoSlug: boolean;
selectedId: number | null;
};
let createState = $state<CreateState>({
form: createEmptyForm(),
saving: false,
success: '',
error: '',
autoSlug: true
});
$effect(() => {
const title = createState.form.title;
if (!createState.autoSlug) return;
if (!title.trim()) {
createState.form.slug = '';
return;
}
const next = slugify(title);
if (next && createState.form.slug !== next) {
createState.form.slug = next;
}
});
let manageState = $state<ManageState>({
form: createEmptyForm(),
saving: false,
success: '',
error: '',
autoSlug: false,
selectedId: null
});
let manageSlug = $state('');
$effect(() => {
manageSlug = manageState.form.slug.trim();
});
function populateManageForm(tournament: TournamentInfo) {
manageState.form.title = tournament.title;
manageState.form.game = tournament.game;
manageState.form.slug = tournament.slug;
manageState.form.tagline = tournament.tagline ?? '';
manageState.form.start_at = toLocalInput(tournament.start_at);
manageState.form.location = tournament.location ?? '';
manageState.form.description = tournament.description ?? '';
manageState.form.contact = tournament.contact ?? '';
manageState.form.sections = cloneSections(tournament.sections ?? []);
manageState.form.signup = cloneSignupConfig(tournament.signup_config);
}
$effect(() => {
if (manageState.selectedId === null && tournaments.length > 0) {
manageState.selectedId = tournaments[0].id;
}
});
$effect(() => {
const selected = tournaments.find((item) => item.id === manageState.selectedId);
if (!selected) {
if (tournaments.length === 0) {
manageState.selectedId = null;
manageState.form = createEmptyForm();
} else {
manageState.selectedId = tournaments[0].id;
}
return;
}
populateManageForm(selected);
});
$effect(() => {
if (activeTab() !== 'manage') return;
const selected = tournaments.find((item) => item.id === manageState.selectedId);
const currentSlug = slugParam();
const desiredSlug = selected?.slug ?? null;
if ((currentSlug ?? null) === (desiredSlug ?? null)) {
return;
}
setTab('manage', { slug: desiredSlug, replace: true });
});
function addSection(target: TournamentForm) {
target.sections = [...target.sections, { title: '', body: '' }];
}
function removeSection(target: TournamentForm, index: number) {
target.sections = target.sections.filter((_, idx) => idx !== index);
}
async function handleCreate(event: SubmitEvent) {
event.preventDefault();
createState.error = '';
createState.success = '';
if (!createState.form.title.trim()) {
createState.error = 'Ange en titel för turneringen.';
return;
}
if (!createState.form.game.trim()) {
createState.error = 'Ange vilket spel som spelas.';
return;
}
const payload = buildPayload(createState.form);
if (!payload.slug) {
createState.error = 'Ange en giltig slug.';
return;
}
createState.form.slug = payload.slug;
createState.saving = true;
try {
const response = await fetch('/api/tournament', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
const text = await response.text();
if (!response.ok) {
let message = 'Kunde inte skapa turneringen.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw new Error(message);
}
const body = JSON.parse(text) as { tournament: TournamentInfo };
if (body?.tournament) {
createState.success = 'Turneringen skapades.';
createState.form = createEmptyForm();
createState.autoSlug = true;
await refreshTournaments();
}
} catch (err) {
console.error('Failed to create tournament', err);
createState.error = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
} finally {
createState.saving = false;
}
}
async function handleUpdate(event: SubmitEvent) {
event.preventDefault();
manageState.error = '';
manageState.success = '';
if (manageState.selectedId === null) {
manageState.error = 'Ingen turnering vald.';
return;
}
if (!manageState.form.title.trim()) {
manageState.error = 'Ange en titel för turneringen.';
return;
}
if (!manageState.form.game.trim()) {
manageState.error = 'Ange vilket spel som spelas.';
return;
}
const payload = buildPayload(manageState.form);
if (!payload.slug) {
manageState.error = 'Ange en giltig slug.';
return;
}
manageState.form.slug = payload.slug;
manageState.saving = true;
try {
const response = await fetch(`/api/tournament/${manageState.selectedId}`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload)
});
const text = await response.text();
if (!response.ok) {
let message = 'Kunde inte uppdatera turneringen.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw new Error(message);
}
const body = JSON.parse(text) as { tournament: TournamentInfo };
if (body?.tournament) {
manageState.success = 'Turneringen uppdaterades.';
await refreshTournaments({ preferredId: body.tournament.id });
}
} catch (err) {
console.error('Failed to update tournament', err);
manageState.error = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
} finally {
manageState.saving = false;
}
}
async function handleDelete() {
if (manageState.selectedId === null) {
return;
}
manageState.error = '';
manageState.success = '';
manageState.saving = true;
try {
const response = await fetch(`/api/tournament/${manageState.selectedId}`, {
method: 'DELETE'
});
const text = await response.text();
if (!response.ok) {
let message = 'Kunde inte ta bort turneringen.';
try {
const body = JSON.parse(text);
message = body.message ?? message;
} catch {
if (text) message = text;
}
throw new Error(message);
}
const deletedId = manageState.selectedId;
tournaments = tournaments.filter((item) => item.id !== deletedId);
manageState.selectedId = tournaments[0]?.id ?? null;
manageState.success = 'Turneringen togs bort.';
await refreshTournaments();
} catch (err) {
console.error('Failed to delete tournament', err);
manageState.error = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
} finally {
manageState.saving = false;
}
}
function overviewCardSubtitle(tournament: TournamentInfo) {
const start = formatDateTime(tournament.start_at);
return start ? `${tournament.game}${start}` : tournament.game;
}
function registrationSummary(tournament: TournamentInfo) {
const totalRegistrations = tournament.total_registrations ?? 0;
const totalParticipants = tournament.total_participants ?? 0;
if (tournament.signup_config.mode === 'team') {
if (totalRegistrations === 0) {
return 'Inga anmälningar ännu';
}
const teamLabel = `${totalRegistrations} ${totalRegistrations === 1 ? 'lag' : 'lag'}`;
const playerLabel = `${totalParticipants} ${totalParticipants === 1 ? 'spelare' : 'spelare'}`;
return `${teamLabel} · ${playerLabel}`;
}
const count = Math.max(totalParticipants, totalRegistrations);
return count === 0
? 'Inga anmälningar ännu'
: `${count} ${count === 1 ? 'spelare' : 'spelare'}`;
}
const selectedTournamentInfo = $derived(() => {
if (manageState.selectedId === null) {
return null;
}
return tournaments.find((item) => item.id === manageState.selectedId) ?? null;
});
function sectionKey(index: number) {
return `section-${index}`;
}
</script>
<svelte:head>
<title>Turneringsadmin</title>
</svelte:head>
<div class="space-y-6">
{#if activeTab() === 'overview'}
<section class="space-y-5 rounded-lg border border-slate-200 bg-white p-6 shadow-sm">
<header class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-xl font-semibold text-slate-900">Översikt</h2>
<p class="text-sm text-slate-600">Filtrera turneringar och öppna deras publika sidor.</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<label class="flex items-center gap-2 text-sm text-slate-600">
<span>Spel</span>
<select
bind:value={overviewGameFilter}
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="all">Alla spel</option>
{#each availableGames() as game}
<option value={game}>{game}</option>
{/each}
</select>
</label>
</div>
</header>
{#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"
>
<div class="space-y-2">
<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>
{/if}
<p class="text-xs text-slate-500">{registrationSummary(tournament)}</p>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<a
href={`/tournament/${tournament.slug}`}
target="_blank"
rel="noreferrer"
class="rounded-full bg-indigo-600 px-4 py-2 text-xs font-semibold tracking-wide text-white uppercase transition hover:bg-indigo-700"
>
Ö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 tracking-wide text-slate-600 uppercase transition hover:bg-slate-100"
>
Visa anmälningar
</a>
{/if}
<button
type="button"
onclick={() => selectTournament(tournament)}
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold tracking-wide text-slate-600 uppercase transition hover:bg-slate-100"
>
Redigera
</button>
</div>
</article>
{/each}
</div>
{:else}
<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}
</section>
{:else if activeTab() === 'create'}
<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>
</header>
<form class="space-y-5" onsubmit={handleCreate}>
<div class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Titel</span>
<input
required
type="text"
bind:value={createState.form.title}
class="rounded-md border border-slate-300 px-3 py-2 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>Spel</span>
<input
required
type="text"
bind:value={createState.form.game}
placeholder="Counter-Strike 2, Valorant, Rocket League …"
class="rounded-md border border-slate-300 px-3 py-2 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>Slug (URL)</span>
<input
required
type="text"
value={createState.form.slug}
oninput={(event) => {
const value = (event.currentTarget as HTMLInputElement).value;
createState.autoSlug = false;
createState.form.slug = slugify(value);
}}
placeholder="ex. valorant-final"
class="rounded-md border border-slate-300 px-3 py-2 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>Starttid</span>
<input
type="datetime-local"
bind:value={createState.form.start_at}
class="rounded-md border border-slate-300 px-3 py-2 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>Tagline</span>
<input
type="text"
bind:value={createState.form.tagline}
placeholder="En kort slogan"
class="rounded-md border border-slate-300 px-3 py-2 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>Plats</span>
<input
type="text"
bind:value={createState.form.location}
placeholder="Arena, adress eller digital länk"
class="rounded-md border border-slate-300 px-3 py-2 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 md:col-span-2">
<span>Beskrivning</span>
<textarea
rows={4}
bind:value={createState.form.description}
placeholder="Berätta vad turneringen handlar om, format, regler och mer."
class="rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
></textarea>
</label>
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Kontakt</span>
<input
type="text"
bind:value={createState.form.contact}
placeholder="E-post, Discord eller telefon"
class="rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
</label>
</div>
<section class="space-y-3">
<header class="flex items-center justify-between">
<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)}
class="rounded-full border border-indigo-200 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50"
>
Lägg till sektion
</button>
</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"
>
Lägg till sektioner som beskriver regler, format eller andra detaljer.
</p>
{:else}
<div class="space-y-4">
{#each createState.form.sections as section, index (sectionKey(index))}
<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 tracking-wide text-slate-500 uppercase">Titel</span
>
<input
type="text"
bind:value={section.title}
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
</label>
<button
type="button"
onclick={() => removeSection(createState.form, index)}
class="mt-1 shrink-0 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"
>
Ta bort
</button>
</div>
<label class="mt-3 block text-sm font-medium text-slate-700">
<span class="block text-xs tracking-wide text-slate-500 uppercase"
>Innehåll</span
>
<textarea
rows={3}
bind:value={section.body}
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
></textarea>
</label>
</div>
{/each}
</div>
{/if}
</section>
<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 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">
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Format</span>
<select
value={createState.form.signup.mode}
onchange={(event) =>
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>
<option value="team">Lag</option>
</select>
</label>
{#if createState.form.signup.mode === 'team'}
<div class="grid grid-cols-2 gap-3">
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Min. spelare</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
)}
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>
</div>
{/if}
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
Anmälningsfält
</h4>
<button
type="button"
onclick={() => addEntryField(createState.form)}
class="rounded-full border border-indigo-200 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50"
>
Lägg till fält
</button>
</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"
>
Ange vilka uppgifter laget eller spelaren ska fylla i vid anmälan.
</p>
{:else}
<div class="space-y-4">
{#each createState.form.signup.entry_fields as field, index (signupFieldKey(index, field))}
<div class="space-y-3 rounded-md border border-slate-200 p-4">
<div class="grid gap-3 md:grid-cols-[1fr,160px,120px,auto] md:items-end">
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Etikett</span>
<input
type="text"
bind:value={field.label}
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 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}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
<label class="flex items-center gap-2 text-sm font-medium text-slate-700">
<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}
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={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 tracking-wide text-slate-500 uppercase"
>Plats­hå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}
</div>
{/if}
</div>
{#if createState.form.signup.mode === 'team'}
<div class="space-y-3">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
Spelaruppgifter
</h4>
<button
type="button"
onclick={() => addParticipantField(createState.form)}
class="rounded-full border border-indigo-200 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50"
>
Lägg till fält
</button>
</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"
>
Lägg till fält för varje spelare, t.ex. nick eller kontaktuppgifter.
</p>
{:else}
<div class="space-y-4">
{#each createState.form.signup.participant_fields as field, index (signupFieldKey(index, field))}
<div class="space-y-3 rounded-md border border-slate-200 p-4">
<div class="grid gap-3 md:grid-cols-[1fr,160px,120px,auto] md:items-end">
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Etikett</span>
<input
type="text"
bind:value={field.label}
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>Typ</span>
<select
bind:value={field.field_type}
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}
<option value={option.value}>{option.label}</option>
{/each}
</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"
/>
<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"
/>
<span>Måste vara unik</span>
</label>
<button
type="button"
onclick={() => removeParticipantField(createState.form, index)}
disabled={createState.form.signup.participant_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 tracking-wide text-slate-500 uppercase"
>Plats­hå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"
/>
</label>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</section>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm text-slate-500">
{#if createState.success}
<span class="text-emerald-600">{createState.success}</span>
{:else if createState.error}
<span class="text-red-600">{createState.error}</span>
{:else if createState.saving}
<span>Sparar…</span>
{:else}
<span>Förhandsgranska och spara turneringen.</span>
{/if}
</div>
<button
type="submit"
disabled={createState.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"
>
{createState.saving ? 'Skapar…' : 'Skapa turnering'}
</button>
</div>
</form>
{#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>
<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 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>
{/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="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="text-sm whitespace-pre-line text-slate-200">{section.body}</p>
</div>
{/each}
</div>
{/if}
{#if createState.form.contact.trim()}
<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 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>
{/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 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>
{/each}
</ul>
</div>
{/if}
</div>
</section>
{/if}
</section>
{:else}
<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>
{#if selectedTournamentInfo()}
{@const selected = selectedTournamentInfo()!}
<p class="text-sm text-indigo-600">{registrationSummary(selected)}</p>
{/if}
</header>
<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 tracking-wide text-slate-500 uppercase">
Turneringar
</h3>
<button
type="button"
onclick={() => setTab('create')}
class="rounded-full border border-indigo-200 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50"
>
Ny
</button>
</div>
<ul class="space-y-2">
{#each tournaments as tournament (tournament.id)}
<li>
<button
type="button"
onclick={() => selectTournament(tournament, { replace: true })}
class={`w-full rounded-md px-3 py-2 text-left text-sm font-medium transition ${
manageState.selectedId === tournament.id
? 'bg-indigo-600 text-white shadow'
: 'bg-white text-slate-700 hover:bg-slate-100'
}`}
>
{tournament.title}
<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">
<a
href={`/admin/tournament/${tournament.slug}/registrations`}
class="inline-flex items-center rounded-full border border-slate-300 px-3 py-1 font-semibold text-slate-600 transition hover:border-slate-400 hover:bg-slate-100"
>
Visa anmälningar
</a>
<a
href={`/tournament/${tournament.slug}`}
target="_blank"
rel="noreferrer"
class="inline-flex items-center rounded-full border border-indigo-300 px-3 py-1 font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50"
>
Publik sida
</a>
</div>
{/if}
</li>
{:else}
<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}
</ul>
</aside>
<div class="flex-1 space-y-5">
<form class="space-y-5" onsubmit={handleUpdate}>
<div class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Titel</span>
<input
required
type="text"
bind:value={manageState.form.title}
class="rounded-md border border-slate-300 px-3 py-2 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>Spel</span>
<input
required
type="text"
bind:value={manageState.form.game}
class="rounded-md border border-slate-300 px-3 py-2 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>Slug (URL)</span>
<input
required
type="text"
value={manageState.form.slug}
oninput={(event) => {
const value = (event.currentTarget as HTMLInputElement).value;
manageState.form.slug = slugify(value);
}}
class="rounded-md border border-slate-300 px-3 py-2 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>Starttid</span>
<input
type="datetime-local"
bind:value={manageState.form.start_at}
class="rounded-md border border-slate-300 px-3 py-2 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>Tagline</span>
<input
type="text"
bind:value={manageState.form.tagline}
class="rounded-md border border-slate-300 px-3 py-2 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>Plats</span>
<input
type="text"
bind:value={manageState.form.location}
class="rounded-md border border-slate-300 px-3 py-2 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 md:col-span-2">
<span>Beskrivning</span>
<textarea
rows={4}
bind:value={manageState.form.description}
class="rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
></textarea>
</label>
<label class="flex flex-col gap-1 text-sm font-medium text-slate-700">
<span>Kontakt</span>
<input
type="text"
bind:value={manageState.form.contact}
class="rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
</label>
</div>
<section class="space-y-3">
<header class="flex items-center justify-between">
<h3 class="text-sm font-semibold tracking-wide text-slate-500 uppercase">
Sektioner
</h3>
<button
type="button"
onclick={() => addSection(manageState.form)}
class="rounded-full border border-indigo-200 px-3 py-1 text-xs font-semibold text-indigo-600 transition hover:border-indigo-400 hover:bg-indigo-50"
>
Lägg till sektion
</button>
</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"
>
Inga sektioner ännu. Lägg till regler, format eller andra detaljer.
</p>
{:else}
<div class="space-y-4">
{#each manageState.form.sections as section, index (sectionKey(index))}
<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 tracking-wide text-slate-500 uppercase"
>Titel</span
>
<input
type="text"
bind:value={section.title}
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
/>
</label>
<button
type="button"
onclick={() => removeSection(manageState.form, index)}
class="mt-1 shrink-0 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"
>
Ta bort
</button>
</div>
<label class="mt-3 block text-sm font-medium text-slate-700">
<span class="block text-xs tracking-wide text-slate-500 uppercase"
>Innehåll</span
>
<textarea
rows={3}
bind:value={section.body}
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
></textarea>
</label>
</div>
{/each}
</div>
{/if}
</section>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="text-sm text-slate-500">
{#if manageState.success}
<span class="text-emerald-600">{manageState.success}</span>
{:else if manageState.error}
<span class="text-red-600">{manageState.error}</span>
{:else if manageState.saving}
<span>Sparar…</span>
{:else}
<span>Ändringarna publiceras direkt.</span>
{/if}
</div>
<div class="flex flex-wrap gap-2">
{#if manageState.selectedId !== null}
<button
type="button"
onclick={handleDelete}
disabled={manageState.saving}
class="rounded-full border border-red-200 px-4 py-2 text-sm font-semibold text-red-600 transition hover:border-red-400 hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-60"
>
Ta bort
</button>
{/if}
<button
type="submit"
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'}
</button>
</div>
</div>
</form>
{#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>
<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 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>
{/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="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="text-sm whitespace-pre-line text-slate-200">{section.body}</p>
</div>
{/each}
</div>
{/if}
{#if manageState.form.contact.trim()}
<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 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>
{/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 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>
{/each}
</ul>
</div>
{/if}
</div>
</section>
{/if}
{#if manageState.selectedId !== null}
<div class="flex flex-wrap gap-2">
<a
href={manageSlug ? `/tournament/${manageSlug}` : '#'}
target={manageSlug ? '_blank' : undefined}
rel={manageSlug ? 'noreferrer' : undefined}
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'
: 'pointer-events-none border-indigo-200 text-indigo-300 opacity-60'
}`}
>
Visa publika sidan
</a>
</div>
{/if}
</div>
</div>
</section>
{/if}
</div>