import { error, fail, redirect } from '@sveltejs/kit'; import type { Actions, PageServerLoad } from './$types'; import { requireUser } from '#lib/server/auth.js'; import { deletePieceImage, duplicatePiece, getPiece, getTaxonomy, listPieceTemplates, loadPiecePresentationMetadata, savePiece, savePieceAsTemplate, setPieceArchived, setPieceFavorite, uploadPieceImage, type EditablePieceStatus, type PieceInput, type PieceSourceInput } from '#lib/server/content.js'; import { renderMarkdown } from '#lib/server/markdown.js'; import { listTools } from '#lib/server/tools.js'; function renderedFields( piece: Pick< PieceInput, 'intro' | 'researchText' | 'moderationNotes' | 'transitionText' | 'presentationText' > ) { return { intro: renderMarkdown(piece.intro), research: renderMarkdown(piece.researchText), moderation: renderMarkdown(piece.moderationNotes), transition: renderMarkdown(piece.transitionText), presentation: renderMarkdown(piece.presentationText) }; } function text(data: FormData, name: string) { return String(data.get(name) || ''); } function durationInSeconds(data: FormData) { return Math.round(Number(data.get('durationMinutes')) * 60); } function pieceInput(data: FormData): PieceInput { const labels = data.getAll('sourceLabel').map(String); const urls = data.getAll('sourceUrl').map(String); const timecodes = data.getAll('sourceTimecode').map(String); const sources: PieceSourceInput[] = []; for (let index = 0; index < Math.max(labels.length, urls.length, timecodes.length); index += 1) { const label = labels[index]?.trim() ?? ''; const url = urls[index]?.trim() ?? ''; const timecode = timecodes[index]?.trim() ?? ''; if (!label && !url && !timecode) continue; sources.push({ label, url, timecodeSeconds: timecode ? Number(timecode) : null }); } const toolIds = data.getAll('toolId').map(String); const toolWorkUrls = data.getAll('toolWorkUrl').map(String); const toolNotes = data.getAll('toolNote').map(String); return { title: text(data, 'title'), outputTitle: text(data, 'outputTitle'), summary: text(data, 'summary'), formatId: text(data, 'formatId') || null, status: text(data, 'status') as EditablePieceStatus, durationSeconds: durationInSeconds(data), intro: text(data, 'intro'), researchText: text(data, 'researchText'), moderationNotes: text(data, 'moderationNotes'), transitionText: text(data, 'transitionText'), presentationText: text(data, 'presentationText'), presentationMode: text(data, 'presentationMode') as PieceInput['presentationMode'], presentationSourcePosition: text(data, 'presentationSourcePosition') ? Number(data.get('presentationSourcePosition')) : null, presentationTitle: text(data, 'presentationTitle'), presentationDescription: text(data, 'presentationDescription'), presentationImageUrl: text(data, 'presentationImageUrl'), presentationSiteName: text(data, 'presentationSiteName'), presentationProvider: text(data, 'presentationProvider'), presentationProviderId: text(data, 'presentationProviderId'), presentationTitleManual: text(data, 'presentationTitleManual') === '1', presentationDescriptionManual: text(data, 'presentationDescriptionManual') === '1', presentationImageManual: text(data, 'presentationImageManual') === '1', presentationMetadataFetchedAtUtc: text(data, 'presentationMetadataFetchedAtUtc') || null, topicIds: data.getAll('topicIds').map(String), categoryIds: data.getAll('categoryIds').map(String), sources, tools: toolIds.map((toolId, index) => ({ toolId, workUrl: toolWorkUrls[index] ?? '', note: toolNotes[index] ?? '' })) }; } function saveError(cause: unknown) { const code = cause instanceof Error ? cause.message : ''; if (code === 'CONFLICT') return { status: 409, error: 'Eine neuere Änderung liegt vor. Deine Eingaben wurden nicht überschrieben.', conflict: true }; if (code === 'INVALID_TITLE') return { status: 400, error: 'Bitte einen Titel eingeben.' }; if (code === 'INVALID_DURATION') return { status: 400, error: 'Die Dauer muss zwischen 0 und 1.440 Minuten liegen.' }; if (code === 'INVALID_SOURCES') return { status: 400, error: 'Quellen benötigen einen Namen und eine vollständige HTTP- oder HTTPS-Adresse.' }; if (code === 'INVALID_PRESENTATION') return { status: 400, error: 'Für Linkvorschau und Video muss eine vorhandene gültige Quelle ausgewählt sein.' }; if (code.startsWith('INVALID_')) return { status: 400, error: 'Mindestens ein Feld ist ungültig oder zu lang.' }; return { status: 500, error: 'Speichern fehlgeschlagen. Deine Eingaben bleiben im Formular erhalten.' }; } export const load: PageServerLoad = ({ locals, params }) => { requireUser(locals.user); const piece = getPiece(params.id); if (!piece) error(404, 'Piece nicht gefunden'); if (piece.status === 'archived') redirect(303, '/archiv'); return { piece, taxonomy: getTaxonomy(), tools: listTools(true), templates: listPieceTemplates().filter((template) => template.id !== 'blank'), rendered: renderedFields(piece) }; }; export const actions = { save: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); const input = pieceInput(data); try { const saved = savePiece(params.id, Number(data.get('revision')), input); return { intent: 'save', saved: true, ...saved, rendered: renderedFields(input) }; } catch (cause) { const problem = saveError(cause); return fail(problem.status, { intent: 'save', ...problem }); } }, loadPreview: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); try { await loadPiecePresentationMetadata(params.id, Number(data.get('revision'))); } catch (cause) { const code = cause instanceof Error ? cause.message : ''; if (code === 'FETCH_BLOCKED') { return fail(400, { intent: 'loadPreview', error: 'Interne oder private Netzwerkziele dürfen nicht abgerufen werden.' }); } if (code.startsWith('FETCH_')) { return fail(400, { intent: 'loadPreview', error: 'Die Vorschau konnte nicht sicher geladen werden. Die manuellen Angaben bleiben erhalten.' }); } const problem = saveError(cause); return fail(problem.status, { intent: 'loadPreview', ...problem }); } redirect(303, `/fundus/${params.id}`); }, saveTemplate: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); const reference = text(data, 'templateRef'); const [templateId, revision] = reference.split('|'); try { savePieceAsTemplate( params.id, text(data, 'templateName'), text(data, 'templateDescription'), templateId || undefined, revision ? Number(revision) : undefined ); return { intent: 'saveTemplate', success: 'Vorlage gespeichert.' }; } catch (cause) { const problem = saveError(cause); return fail(problem.status, { intent: 'saveTemplate', ...problem }); } }, upload: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); const image = data.get('image'); if (!(image instanceof File)) return fail(400, { intent: 'upload', error: 'Bitte ein Bild auswählen.' }); try { await uploadPieceImage(params.id, Number(data.get('revision')), image, text(data, 'altText')); } catch (cause) { const code = cause instanceof Error ? cause.message : ''; if (code === 'CONFLICT') return fail(409, { intent: 'upload', error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' }); if (code === 'INVALID_IMAGE_SIZE') return fail(400, { intent: 'upload', error: 'Das Bild darf höchstens 5 MB groß und nicht leer sein.' }); if (code === 'INVALID_IMAGE_TYPE') return fail(400, { intent: 'upload', error: 'Erlaubt sind JPEG, PNG, WebP und GIF.' }); return fail(400, { intent: 'upload', error: 'Bild konnte nicht gespeichert werden.' }); } redirect(303, `/fundus/${params.id}`); }, deleteImage: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); try { await deletePieceImage(params.id, text(data, 'mediaId'), Number(data.get('revision'))); } catch (cause) { return fail(409, { intent: 'deleteImage', error: saveError(cause).error }); } redirect(303, `/fundus/${params.id}`); }, duplicate: async ({ locals, params }) => { requireUser(locals.user); let id: string; try { id = await duplicatePiece(params.id); } catch { return fail(400, { intent: 'duplicate', error: 'Das Piece konnte nicht dupliziert werden.' }); } redirect(303, `/fundus/${id}`); }, archive: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); try { setPieceArchived(params.id, Number(data.get('revision')), true); } catch (cause) { return fail(409, { intent: 'archive', error: saveError(cause).error }); } redirect(303, '/archiv'); }, favorite: async ({ locals, params, request }) => { requireUser(locals.user); const data = await request.formData(); try { setPieceFavorite(params.id, data.get('favorite') === '1'); return { intent: 'favorite', success: true }; } catch (cause) { return fail(404, { intent: 'favorite', error: saveError(cause).error }); } } } satisfies Actions;