Release 0.4.0

This commit is contained in:
hnzio committed 2026-10-21 23:52:05 +02:00
1 parent 7590be66a5
commit 4042fe6a64
24 files changed
+3570 -290

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
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;