295 lines
9.9 KiB
TypeScript
295 lines
9.9 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'
|
|
| 'interactionText'
|
|
| 'transitionText'
|
|
| 'presentationText'
|
|
>
|
|
) {
|
|
return {
|
|
intro: renderMarkdown(piece.intro),
|
|
research: renderMarkdown(piece.researchText),
|
|
moderation: renderMarkdown(piece.moderationNotes),
|
|
interaction: renderMarkdown(piece.interactionText ?? ''),
|
|
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,
|
|
preparationEffort: (text(data, 'preparationEffort') || null) as PieceInput['preparationEffort'],
|
|
durationSeconds: durationInSeconds(data),
|
|
intro: text(data, 'intro'),
|
|
researchText: text(data, 'researchText'),
|
|
moderationNotes: text(data, 'moderationNotes'),
|
|
interactionText: text(data, 'interactionText'),
|
|
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;
|