Release 0.6.0

This commit is contained in:
hnzio committed 2026-10-25 09:45:31 +01:00
1 parent fd47910d09
commit b80dded55d
40 files changed
+4080 -856

No files matched your search

+5 -1
View File
@@ -1,7 +1,11 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals, url }) => ({
user: url.pathname.startsWith('/heinzlive/') ? null : locals.user,
user:
url.pathname.startsWith('/heinzlive/') || url.pathname.startsWith('/freigabe/')
? null
: locals.user,
presentationMode: url.pathname.startsWith('/heinzlive/'),
guestMode: url.pathname.startsWith('/freigabe/'),
liveControllerMode: url.pathname === '/live'
});
+11
View File
@@ -34,6 +34,17 @@
{#if data.presentationMode}
{@render children()}
{:else if data.guestMode}
<div class="guest-shell">
<header class="auth-header">
<span class="brand"
><span class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></span><span
>Sendewerk</span
></span
><ThemeToggle />
</header>
{@render children()}
</div>
{:else if data.user && data.liveControllerMode}
<main id="main-content" class="live-controller-shell">{@render children()}</main>
{:else if data.user}
+19 -1
View File
@@ -1,11 +1,15 @@
import { fail } from '@sveltejs/kit';
import { fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js';
import { duplicateCompletedShow, listShows } from '#lib/server/shows.js';
export const load: PageServerLoad = ({ locals }) => {
requireUser(locals.user);
return {
completedShows: listShows(['completed']),
duplicateCommandId: randomUUID(),
pieces: listPieces({ statuses: ['archived'] }),
deletedPieces: listPieces({
statuses: ['inbox', 'preparing', 'ready', 'archived'],
@@ -34,5 +38,19 @@ export const actions = {
} catch {
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
}
},
duplicateShow: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
try {
const duplicate = duplicateCompletedShow(
String(data.get('showId') || ''),
String(data.get('commandId') || '')
);
redirect(303, `/sendungen/${duplicate.id}`);
} catch (cause) {
if (cause && typeof cause === 'object' && 'status' in cause) throw cause;
return fail(400, { error: 'Die Sendung konnte nicht dupliziert werden.' });
}
}
} satisfies Actions;
+22 -1
View File
@@ -1,5 +1,7 @@
<script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte';
import { resolve } from '$app/paths';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
</script>
@@ -9,10 +11,29 @@
<div class="page-wrap narrow-page">
<header class="page-heading">
<h1>Archiv</h1>
<p class="page-intro">Abgelegte Pieces; abgeschlossene Sendungen folgen in Etappe 6.</p>
<p class="page-intro">Stabile Sendungshistorie und bewusst abgelegte Fundus-Pieces.</p>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
<section aria-labelledby="completed-shows-title">
<h2 id="completed-shows-title" class="section-heading">Abgeschlossene Sendungen</h2>
{#if data.completedShows.length}<div class="archive-list">
{#each data.completedShows as show (show.id)}<article>
<div>
<h2><a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a></h2>
<p>
{show.sectionCount} Abschnitte · {formatDuration(show.totalDurationSeconds)} Planzeit
</p>
</div>
<form method="POST" action="?/duplicateShow">
<input type="hidden" name="showId" value={show.id} />
<input type="hidden" name="commandId" value={data.duplicateCommandId} />
<button class="secondary-button" type="submit">Als neue Sendung duplizieren</button>
</form>
</article>{/each}
</div>{:else}<p class="context-empty">Noch keine Sendung historisch abgeschlossen.</p>{/if}
</section>
<h2 class="section-heading">Archivierte Pieces</h2>
{#if data.pieces.length}<div class="archive-list">
{#each data.pieces as piece (piece.id)}<article>
<div>
@@ -0,0 +1,31 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { getAuthConfig } from '#lib/server/config.js';
import { authenticateShare, getSharedShow, SHARE_SESSION_COOKIE } from '#lib/server/shares.js';
export const load: PageServerLoad = ({ params, cookies }) => {
return getSharedShow(params.token, cookies.get(SHARE_SESSION_COOKIE));
};
export const actions = {
password: async ({ params, request, cookies, getClientAddress }) => {
const data = await request.formData();
try {
const session = await authenticateShare(
params.token,
String(data.get('password') || ''),
getClientAddress()
);
cookies.set(SHARE_SESSION_COOKIE, session.token, {
path: `/freigabe/${params.token}`,
httpOnly: true,
sameSite: 'strict',
secure: getAuthConfig().secureCookies,
expires: session.expires
});
return { success: true };
} catch {
return fail(401, { error: 'Passwort falsch oder Freigabe nicht mehr verfügbar.' });
}
}
} satisfies Actions;
+141
View File
@@ -0,0 +1,141 @@
<script lang="ts">
import { page } from '$app/state';
import { formatDuration } from '#lib/format.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
function berlinDate(value: string | null) {
if (!value) return 'Termin nicht angegeben';
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'full',
timeStyle: 'short'
}).format(new Date(value));
}
</script>
<svelte:head><title>Sendungsfreigabe | Sendewerk</title></svelte:head>
<main class="share-page">
{#if data.status === 'unavailable'}
<section class="quiet-panel share-gate">
<p class="eyebrow">Lesefreigabe</p>
<h1>Nicht verfügbar</h1>
<p>Dieser Link ist ungültig, abgelaufen oder wurde widerrufen.</p>
</section>
{:else if data.status === 'password'}
<section class="quiet-panel share-gate">
<p class="eyebrow">Geschützte Lesefreigabe</p>
<h1>Passwort erforderlich</h1>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
<form method="POST" action="?/password" class="compact-form">
<label
>Passwort<input
name="password"
type="password"
required
autocomplete="current-password"
/></label
>
<button class="primary-button" type="submit">Sendung öffnen</button>
</form>
</section>
{:else}
<header class="share-heading">
<p class="eyebrow">
{data.share.isCompleted ? 'Abgeschlossene Sendung' : 'Aktuelle Sendungsplanung'} · Nur lesen
</p>
<h1>{data.share.title}</h1>
<p>{berlinDate(data.share.startsAtUtc)} Uhr</p>
</header>
<div class="shared-rundown">
{#each data.share.sections as section (section)}
<section class="quiet-panel shared-section">
<header>
<div>
{#if section.category}<small
style={`--tax-color: ${section.categoryColor ?? '#67dde8'}`}
>{section.category}</small
>{/if}
<h2>{section.name}</h2>
</div>
{#if section.explicitDurationSeconds !== null}<span
>{formatDuration(section.explicitDurationSeconds)}</span
>{/if}
</header>
{#each section.usages as usage (usage)}
<article class="shared-usage">
<div class="shared-usage-heading">
<div>
<small
>{usage.status === 'done'
? 'Gesendet'
: usage.status === 'skipped'
? 'Übersprungen'
: 'Offen'}</small
>
<h3>{usage.title}</h3>
</div>
<span>{formatDuration(usage.actualDurationSeconds || usage.durationSeconds)}</span>
</div>
{#if usage.media}<img
src={`${page.url.pathname}/media/${usage.media.id}`}
alt={usage.media.alt_text}
/>{/if}
{#if 'introHtml' in usage && usage.introHtml}<div class="shared-copy">
<strong>Einleitung</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.introHtml}</div>
</div>{/if}
{#if 'moderationHtml' in usage && usage.moderationHtml}<div class="shared-copy">
<strong>Moderation</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.moderationHtml}</div>
</div>{/if}
{#if 'interactionHtml' in usage && usage.interactionHtml}<div class="shared-copy">
<strong>Interaktion</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.interactionHtml}</div>
</div>{/if}
{#if 'transitionHtml' in usage && usage.transitionHtml}<div class="shared-copy">
<strong>Überleitung</strong>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div>{@html usage.transitionHtml}</div>
</div>{/if}
{#if 'researchHtml' in usage && usage.researchHtml}<details>
<summary>Recherche</summary>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="shared-copy">{@html usage.researchHtml}</div>
</details>{/if}
{#if 'sources' in usage && usage.sources?.length}<details>
<summary>Quellen</summary>
<ul>
{#each usage.sources ?? [] as source (source)}<li>
<!-- External source URLs are validated and stored server-side. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} rel="noreferrer">{source.label || source.url}</a>
</li>{/each}
</ul>
</details>{/if}
{#if 'tools' in usage && usage.tools?.length}<details>
<summary>Werkzeuge</summary>
<ul>
{#each usage.tools ?? [] as tool (tool)}<li>
<strong>{tool.name}</strong>{#if tool.note}
· {tool.note}{/if}
</li>{/each}
</ul>
</details>{/if}
</article>
{/each}
</section>
{/each}
</div>
{/if}
</main>
@@ -0,0 +1,18 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSharedMedia, SHARE_SESSION_COOKIE } from '#lib/server/shares.js';
export const GET: RequestHandler = async ({ params, cookies }) => {
const media = await getSharedMedia(params.token, cookies.get(SHARE_SESSION_COOKIE), params.id);
if (!media) error(404, 'Bild nicht gefunden');
return new Response(media.data, {
headers: {
'Content-Type': media.mimeType,
'Content-Length': String(media.byteSize),
'Cache-Control': 'private, no-store',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Cross-Origin-Resource-Policy': 'same-origin',
'X-Content-Type-Options': 'nosniff'
}
});
};
+22 -1
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { error, fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import { requireUser } from '#lib/server/auth.js';
import { finalizeShow, getCompletionSummary } from '#lib/server/completion.js';
import {
completeCurrent,
clearPresentationChannel,
@@ -74,7 +75,13 @@ export const load: PageServerLoad = ({ locals, url }) => {
const showId = url.searchParams.get('show') || listShows()[0]?.id;
if (!showId) error(404, 'Keine Sendung vorhanden');
try {
return { live: getLiveView(showId), commandId: randomUUID() };
const live = getLiveView(showId);
return {
live,
commandId: randomUUID(),
completion:
live.endedAtUtc && live.show.status !== 'completed' ? getCompletionSummary(showId) : null
};
} catch (cause) {
if (cause instanceof Error && cause.message === 'NOT_FOUND')
error(404, 'Sendung nicht gefunden');
@@ -142,6 +149,20 @@ export const actions = {
endLive(text(data, 'showId'), text(data, 'commandId'), Number(data.get('revision')))
);
},
finalize: async ({ locals, request }) => {
const actor = requireUser(locals.user);
const data = await request.formData();
return attempt('finalize', () =>
finalizeShow({
showId: text(data, 'showId'),
commandId: text(data, 'commandId'),
expectedShowRevision: Number(data.get('showRevision')),
expectedLiveRevision: Number(data.get('revision')),
archivePieceIds: data.getAll('archivePieceIds').map(String),
actor
})
);
},
resumeEnded: async ({ locals, request }) => {
requireUser(locals.user);
const data = await request.formData();
+87 -25
View File
@@ -30,6 +30,7 @@
let railCloseButton = $state<HTMLButtonElement>();
let liveMenuTrigger = $state<HTMLButtonElement>();
let liveMenuPopover = $state<HTMLElement>();
let completionDialog = $state<HTMLDialogElement>();
let scrollPositions: Record<string, number> = {};
let outputWindow: Window | null = null;
let allUsages = $derived(data.live.sections.flatMap((section) => section.usages));
@@ -144,6 +145,10 @@
});
});
$effect(() => {
if (!data.completion && completionDialog?.open) completionDialog.close();
});
onMount(() => {
const key = `sendewerk-live-${data.live.show.id}`;
try {
@@ -611,34 +616,48 @@
>
</form>
{:else if data.live.endedAtUtc}
<form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Lauf fortsetzen</button
{#if data.live.show.status === 'completed'}
<p class="quiet-copy">Diese Sendung ist historisch abgeschlossen und unveränderlich.</p>
<a class="secondary-button" href={resolve('/sendungen/[id]', { id: data.live.show.id })}
>Historische Sendung öffnen</a
>
</form>
<form
method="POST"
action={liveAction('startNewRun')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (
!confirm(
'Neuen Durchlauf starten? Fortschritt und Zeitmessungen werden zurückgesetzt.'
{:else}
<form method="POST" action={liveAction('resumeEnded')} use:enhance={enhanceCommand}>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="secondary-button" type="submit"
><AppIcon name="play" /> Lauf fortsetzen</button
>
</form>
<form
method="POST"
action={liveAction('startNewRun')}
use:enhance={enhanceCommand}
onsubmit={(event) => {
if (
!confirm(
'Neuen Durchlauf starten? Fortschritt und Zeitmessungen werden zurückgesetzt.'
)
)
)
event.preventDefault();
}}
>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit"
><AppIcon name="arrow-rotate-left" /> Neuer Durchlauf</button
event.preventDefault();
}}
>
</form>
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="commandId" value={data.commandId} />
<button class="text-button" type="submit"
><AppIcon name="arrow-rotate-left" /> Neuer Durchlauf</button
>
</form>
<button
class="primary-button"
type="button"
onclick={() => completionDialog?.showModal()}
>
<AppIcon name="box-archive" /> Sendung abschließen
</button>
{/if}
{:else}
<form
method="POST"
@@ -723,6 +742,49 @@
{#if popupMessage}<div class="live-message form-message" role="status">{popupMessage}</div>{/if}
</div>
<dialog bind:this={completionDialog} class="completion-dialog">
<form method="POST" action={liveAction('finalize')} use:enhance={enhanceCommand}>
<header>
<p class="eyebrow">Unveränderliche Historie</p>
<h2>Sendung abschließen</h2>
</header>
<p>
Der aktuelle Ablauf wird dauerhaft eingefroren. Übersprungene Pieces zählen nicht als
gesendet.
</p>
{#if data.completion}
<div class="completion-counts">
<span><strong>{data.completion.doneCount}</strong> gesendet</span><span
><strong>{data.completion.skippedCount}</strong> übersprungen</span
><span><strong>{data.completion.openCount}</strong> offen</span>
</div>
<fieldset>
<legend>Originale bewusst in den Fundus-Archivbereich legen</legend>
{#each data.completion.candidates as candidate (candidate.pieceId)}<label
class="check-row"
><input type="checkbox" name="archivePieceIds" value={candidate.pieceId} /><span
><strong>{candidate.title}</strong>{#if candidate.usageCount > 1}<small
>{candidate.usageCount} gesendete Verwendungen</small
>{/if}</span
></label
>{/each}
{#if !data.completion.candidates.length}<p class="quiet-copy">
Keine archivierbaren Originale vorhanden.
</p>{/if}
</fieldset>
{/if}
<input type="hidden" name="showId" value={data.live.show.id} />
<input type="hidden" name="revision" value={data.live.liveRevision} />
<input type="hidden" name="showRevision" value={data.live.show.revision} />
<input type="hidden" name="commandId" value={data.commandId} />
<footer>
<button class="text-button" type="button" onclick={() => completionDialog?.close()}
>Abbrechen</button
><button class="primary-button" type="submit">Historischen Stand abschließen</button>
</footer>
</form>
</dialog>
<div class="live-workspace">
{#if viewedUsage}<article
class="live-current"
+2 -32
View File
@@ -3,37 +3,7 @@ import type { Actions, PageServerLoad } from './$types';
import { berlinDateKey, berlinMonthKey, normalizeMonthKey, shiftMonth } from '#lib/calendar.js';
import { getShowOverviewView, requireUser, setShowOverviewView } from '#lib/server/auth.js';
import { createShow, listShows, listShowTemplates } from '#lib/server/shows.js';
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);
const represented = Date.UTC(
read('year'),
read('month') - 1,
read('day'),
read('hour'),
read('minute')
);
candidate += localMilliseconds - represented;
}
return new Date(candidate).toISOString();
}
import { berlinLocalToUtc } from '#lib/server/time.js';
export const load: PageServerLoad = ({ locals, url }) => {
const user = requireUser(locals.user);
@@ -41,7 +11,7 @@ export const load: PageServerLoad = ({ locals, url }) => {
const todayMonth = berlinMonthKey(today);
const selectedMonth = normalizeMonthKey(url.searchParams.get('month'), todayMonth);
return {
shows: listShows(),
shows: listShows(['draft', 'ready', 'live']),
templates: listShowTemplates(),
view: getShowOverviewView(user.id),
selectedMonth,
+95 -29
View File
@@ -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();
File diff suppressed because it is too large. Load diff
+53
View File
@@ -8,6 +8,9 @@ import {
type TaxonomyKind
} from '#lib/server/content.js';
import { getMailConfig } from '#lib/server/config.js';
import { createShare, deleteShare, listShares, updateShare } from '#lib/server/shares.js';
import { listShows } from '#lib/server/shows.js';
import { berlinLocalToUtc } from '#lib/server/time.js';
import { createTool, listTools, updateTool, type ToolInput } from '#lib/server/tools.js';
import {
inviteUser,
@@ -28,6 +31,8 @@ export const load: PageServerLoad = ({ locals }) => {
users: listUsers(),
invitations: listOpenInvitations(),
tools: listTools(true),
shows: listShows(),
shares: listShares(locals.user!),
mailTransport: getMailConfig().transport
};
};
@@ -50,6 +55,10 @@ function actionError(cause: unknown): string {
if (code === 'INVALID_TOOL_FORMAT')
return 'Mindestens ein gewähltes Format existiert nicht mehr.';
if (code === 'INVALID_TOOL') return 'Bitte die Werkzeugdaten prüfen.';
if (code === 'INVALID_PASSWORD') return 'Ein Freigabepasswort benötigt mindestens 12 Zeichen.';
if (code === 'INVALID_EXPIRY') return 'Der Ablaufzeitpunkt muss in der Zukunft liegen.';
if (code === 'INVALID_SCOPE') return 'Der Informationsumfang ist ungültig.';
if (code === 'INVALID_PASSWORD_MODE') return 'Die Passwortoption ist ungültig.';
return 'Die Aktion konnte nicht ausgeführt werden.';
}
@@ -65,6 +74,50 @@ function toolInput(data: FormData): ToolInput {
}
export const actions = {
createShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
const share = await createShare(actor, {
showId: String(data.get('showId') || ''),
scope: String(data.get('scope') || ''),
password: String(data.get('password') || ''),
expiresAtUtc: berlinLocalToUtc(String(data.get('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(400, { intent: 'createShare', error: actionError(cause) });
}
},
updateShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
await updateShare(actor, String(data.get('shareId') || ''), {
scope: String(data.get('scope') || ''),
passwordMode: String(data.get('passwordMode') || ''),
password: String(data.get('password') || ''),
expiresAtUtc: berlinLocalToUtc(String(data.get('expiresAt') || ''), 'INVALID_EXPIRY')
});
return { intent: 'updateShare', success: 'Freigabe aktualisiert.' };
} catch (cause) {
return fail(400, { intent: 'updateShare', error: actionError(cause) });
}
},
deleteShare: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
try {
deleteShare(actor, String(data.get('shareId') || ''));
return { intent: 'deleteShare', success: 'Freigabe gelöscht.' };
} catch (cause) {
return fail(400, { intent: 'deleteShare', error: actionError(cause) });
}
},
invite: async ({ locals, request }) => {
const actor = requireBarkeeper(locals.user);
const data = await request.formData();
+14 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import AppIcon from '#lib/components/AppIcon.svelte';
import IconPicker from '#lib/components/IconPicker.svelte';
import ShareManagement from '#lib/components/ShareManagement.svelte';
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
@@ -18,8 +19,19 @@
<h1>Verwaltung</h1>
<p class="page-intro">Konten, Werkzeuge und bestehende Taxonomie verwalten.</p>
</header>
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
{#if form?.success}<div class="form-message success" role="status">{form.success}</div>{/if}
{#if form?.error && !form.intent?.toLowerCase().includes('share')}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.success && !form.intent?.toLowerCase().includes('share')}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
<ShareManagement shows={data.shows} shares={data.shares} {form} />
<section class="quiet-panel account-panel">
<div class="panel-heading">