Release 0.6.0
This commit is contained in:
1 parent
fd47910d09
commit
b80dded55d
40 files changed
+4080
-856
No files matched your search
@@ -1,13 +1,17 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import { requireBarkeeper, requireUser } from '#lib/server/auth.js';
|
||||
import { getHistoricalActualDurations } from '#lib/server/completion.js';
|
||||
import { getTaxonomy, listPieces, listPieceTemplates } from '#lib/server/content.js';
|
||||
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
|
||||
import { berlinLocalToUtc } from '#lib/server/time.js';
|
||||
import {
|
||||
addPieceUsage,
|
||||
addShowSection,
|
||||
createAndAddPieceUsage,
|
||||
duplicatePieceUsage,
|
||||
duplicateCompletedShow,
|
||||
getShow,
|
||||
getPieceUsageContexts,
|
||||
movePieceUsage,
|
||||
@@ -35,32 +39,6 @@ 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', {
|
||||
@@ -104,6 +82,12 @@ function actionError(cause: unknown) {
|
||||
};
|
||||
if (code === 'INVALID_DURATION')
|
||||
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
|
||||
if (code === 'INVALID_PASSWORD')
|
||||
return { status: 400, error: 'Ein Freigabepasswort benötigt mindestens 12 Zeichen.' };
|
||||
if (code === 'INVALID_EXPIRY')
|
||||
return { status: 400, error: 'Der Ablaufzeitpunkt muss in der Zukunft liegen.' };
|
||||
if (code === 'INVALID_SCOPE' || code === 'INVALID_PASSWORD_MODE')
|
||||
return { status: 400, error: 'Die Einstellungen der Freigabe sind ungültig.' };
|
||||
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.' };
|
||||
@@ -118,9 +102,26 @@ function attempt(intent: string, operation: () => unknown) {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = ({ locals, params, url }) => {
|
||||
requireUser(locals.user);
|
||||
const actor = requireUser(locals.user);
|
||||
const show = getShow(params.id);
|
||||
if (!show) error(404, 'Sendung nicht gefunden');
|
||||
const shares = actor.role === 'barkeeper' ? listShares(actor, show.id) : [];
|
||||
if (show.status === 'completed') {
|
||||
return {
|
||||
show,
|
||||
shares,
|
||||
canManageShares: actor.role === 'barkeeper',
|
||||
historical: true,
|
||||
actualDurations: getHistoricalActualDurations(show.id),
|
||||
duplicateCommandId: randomUUID(),
|
||||
startsAtLocal: berlinInput(show.startsAtUtc),
|
||||
pieces: [],
|
||||
taxonomy: getTaxonomy(),
|
||||
pieceTemplates: [],
|
||||
createCommandId: randomUUID(),
|
||||
filters: { query: '', categoryId: '', includeUsed: false, includePlanned: false }
|
||||
};
|
||||
}
|
||||
const pieces = listPieces({
|
||||
statuses: ['ready'],
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
@@ -132,6 +133,11 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
|
||||
);
|
||||
return {
|
||||
show,
|
||||
shares,
|
||||
canManageShares: actor.role === 'barkeeper',
|
||||
historical: false,
|
||||
actualDurations: {} as Record<string, number>,
|
||||
duplicateCommandId: randomUUID(),
|
||||
startsAtLocal: berlinInput(show.startsAtUtc),
|
||||
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
|
||||
taxonomy: getTaxonomy(),
|
||||
@@ -147,6 +153,66 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
createShare: async ({ locals, params, request }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
const share = await createShare(actor, {
|
||||
showId: params.id,
|
||||
scope: text(data, 'scope'),
|
||||
password: text(data, 'password'),
|
||||
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
|
||||
});
|
||||
return {
|
||||
intent: 'createShare',
|
||||
success: 'Leselink angelegt. Der Link wird nur jetzt vollständig angezeigt.',
|
||||
shareUrl: `/freigabe/${share.token}`
|
||||
};
|
||||
} catch (cause) {
|
||||
return fail(actionError(cause).status, { intent: 'createShare', ...actionError(cause) });
|
||||
}
|
||||
},
|
||||
updateShare: async ({ locals, params, request }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
await updateShare(
|
||||
actor,
|
||||
text(data, 'shareId'),
|
||||
{
|
||||
scope: text(data, 'scope'),
|
||||
passwordMode: text(data, 'passwordMode'),
|
||||
password: text(data, 'password'),
|
||||
expiresAtUtc: berlinLocalToUtc(text(data, 'expiresAt'), 'INVALID_EXPIRY')
|
||||
},
|
||||
params.id
|
||||
);
|
||||
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
|
||||
} catch (cause) {
|
||||
return fail(actionError(cause).status, { intent: 'updateShare', ...actionError(cause) });
|
||||
}
|
||||
},
|
||||
deleteShare: async ({ locals, params, request }) => {
|
||||
const actor = requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
deleteShare(actor, text(data, 'shareId'), params.id);
|
||||
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
|
||||
} catch (cause) {
|
||||
return fail(actionError(cause).status, { intent: 'deleteShare', ...actionError(cause) });
|
||||
}
|
||||
},
|
||||
duplicateShow: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
const duplicate = duplicateCompletedShow(params.id, text(data, 'commandId'));
|
||||
redirect(303, `/sendungen/${duplicate.id}`);
|
||||
} catch (cause) {
|
||||
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
|
||||
return fail(actionError(cause).status, { intent: 'duplicateShow', ...actionError(cause) });
|
||||
}
|
||||
},
|
||||
saveMetadata: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
|
||||
Reference in new issue
Block a user