import { error, fail } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { requireUser } from '#lib/server/auth.js'; import { getTaxonomy, listPieces } from '#lib/server/content.js'; import { addPieceUsage, addShowSection, getShow, movePieceUsage, moveShowSection, removePieceUsage, removeShowSection, saveShowMetadata, updatePieceUsage, updateShowSection, type EditableShowStatus, type UsageStatus } from '#lib/server/shows.js'; function text(data: FormData, name: string) { return String(data.get(name) || ''); } function nullableDuration(data: FormData) { const value = text(data, 'explicitDurationSeconds').trim(); return value === '' ? null : Number(value); } function pieceDurationInSeconds(data: FormData) { return Math.round(Number(data.get('durationMinutes')) * 60); } function berlinLocalToUtc(value: string): string | null { if (!value) return null; const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(value); if (!match) throw new Error('INVALID_TIMESTAMP'); const parts = match.slice(1).map(Number); const localMilliseconds = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4]); let candidate = localMilliseconds; for (let attempt = 0; attempt < 2; attempt += 1) { const formatted = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(candidate)); const read = (type: Intl.DateTimeFormatPartTypes) => Number(formatted.find((part) => part.type === type)?.value); candidate += localMilliseconds - Date.UTC(read('year'), read('month') - 1, read('day'), read('hour'), read('minute')); } return new Date(candidate).toISOString(); } function berlinInput(value: string | null) { if (!value) return ''; const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(new Date(value)); const read = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? ''; return `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`; } function actionError(cause: unknown) { const code = cause instanceof Error ? cause.message : ''; if (code === 'CONFLICT') return { status: 409, error: 'Die Sendung wurde inzwischen geändert. Bitte neu laden.', conflict: true }; if (code === 'SHOW_COMPLETED') return { status: 409, error: 'Abgeschlossene Sendungen können nicht bearbeitet werden.' }; if (code === 'PIECE_NOT_READY') return { status: 400, error: 'Nur sendefertige Pieces können eingeplant werden.' }; if (code === 'NOT_FOUND') return { status: 404, error: 'Der Eintrag wurde nicht gefunden.' }; if (code === 'INVALID_DURATION') return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' }; if (code.startsWith('INVALID_')) return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' }; return { status: 500, error: 'Die Änderung konnte nicht gespeichert werden.' }; } function attempt(intent: string, operation: () => unknown) { try { return { intent, success: 'Änderung gespeichert.', result: operation() }; } catch (cause) { return fail(actionError(cause).status, { intent, ...actionError(cause) }); } } export const load: PageServerLoad = ({ locals, params, url }) => { requireUser(locals.user); const show = getShow(params.id); if (!show) error(404, 'Sendung nicht gefunden'); return { show, startsAtLocal: berlinInput(show.startsAtUtc), pieces: listPieces({ statuses: ['ready'], query: url.searchParams.get('q') ?? '', categoryId: url.searchParams.get('category') ?? '' }), taxonomy: getTaxonomy(), filters: { query: url.searchParams.get('q') ?? '', categoryId: url.searchParams.get('category') ?? '' } }; }; export const actions = { saveMetadata: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('saveMetadata', () => saveShowMetadata(params.id, Number(data.get('revision')), { title: text(data, 'title'), startsAtUtc: berlinLocalToUtc(text(data, 'startsAt')), twitchTitle: text(data, 'twitchTitle'), announcementText: text(data, 'announcementText'), status: text(data, 'status') as EditableShowStatus }) ); }, addSection: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('addSection', () => addShowSection(params.id, Number(data.get('revision')), { name: text(data, 'name'), categoryId: text(data, 'categoryId') || null, explicitDurationSeconds: nullableDuration(data) }) ); }, updateSection: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('updateSection', () => updateShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision')), { name: text(data, 'name'), categoryId: text(data, 'categoryId') || null, explicitDurationSeconds: nullableDuration(data) }) ); }, removeSection: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('removeSection', () => removeShowSection(params.id, text(data, 'sectionId'), Number(data.get('revision'))) ); }, moveSection: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('moveSection', () => moveShowSection( params.id, text(data, 'sectionId'), Number(data.get('position')), Number(data.get('revision')) ) ); }, addUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('addUsage', () => addPieceUsage(params.id, Number(data.get('revision')), { pieceId: text(data, 'pieceId'), sectionId: text(data, 'sectionId') || null }) ); }, updateUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('updateUsage', () => updatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), { title: text(data, 'title'), durationSeconds: pieceDurationInSeconds(data), ownNotes: text(data, 'ownNotes'), intro: text(data, 'intro'), research: text(data, 'research'), moderation: text(data, 'moderation'), transition: text(data, 'transition'), presentation: text(data, 'presentation'), status: text(data, 'status') as UsageStatus }) ); }, removeUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('removeUsage', () => removePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision'))) ); }, moveUsage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); return attempt('moveUsage', () => movePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')), { sectionId: text(data, 'sectionId') || null, position: Number(data.get('position')) }) ); } } satisfies Actions;