Files
sendewerk/src/routes/fundus/[id]/+page.server.ts
T
2026-10-23 21:33:15 +02:00

287 lines
9.7 KiB
TypeScript

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,
setPieceDeleted,
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,
presentationMediaId: text(data, 'presentationMediaId') || 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'),
data.get('selectForPresentation') === '1'
);
} 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 });
}
},
delete: async ({ locals, params, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
setPieceDeleted(params.id, Number(data.get('revision')), true);
} catch (cause) {
return fail(409, { intent: 'delete', error: saveError(cause).error });
}
redirect(303, '/fundus');
}
} satisfies Actions;