1777 lines
60 KiB
Svelte
1777 lines
60 KiB
Svelte
<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"
|
||
>Platshållare (valfritt)</span
|
||
>
|
||
<input
|
||
type="text"
|
||
bind:value={field.placeholder}
|
||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||
/>
|
||
{#if isAttendanceField(field)}
|
||
<p class="mt-2 text-xs text-slate-500">
|
||
Detta fält kopplar anmälan till närvarolistan och kan inte tas bort.
|
||
</p>
|
||
{/if}
|
||
</label>
|
||
</div>
|
||
{/each}
|
||
</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"
|
||
>Platshållare (valfritt)</span
|
||
>
|
||
<input
|
||
type="text"
|
||
bind:value={field.placeholder}
|
||
class="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm text-slate-900 focus:border-indigo-500 focus:ring focus:ring-indigo-100 focus:outline-none"
|
||
/>
|
||
</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>
|