Release 0.5.1
This commit is contained in:
1 parent
5a9d7735d4
commit
fb24d171ae
36 files changed
+5099
-263
No files matched your search
@@ -5,16 +5,19 @@ import { getTaxonomy, listPieces } from '#lib/server/content.js';
|
||||
import {
|
||||
addPieceUsage,
|
||||
addShowSection,
|
||||
duplicatePieceUsage,
|
||||
getShow,
|
||||
getPieceUsageContexts,
|
||||
movePieceUsage,
|
||||
moveShowSection,
|
||||
refreshPieceUsageFromOriginal,
|
||||
removePieceUsage,
|
||||
removeShowSection,
|
||||
saveShowMetadata,
|
||||
updatePieceUsage,
|
||||
updateShowSection,
|
||||
type EditableShowStatus,
|
||||
type UsageStatus
|
||||
type PieceUsageUpdate
|
||||
} from '#lib/server/shows.js';
|
||||
|
||||
function text(data: FormData, name: string) {
|
||||
@@ -84,6 +87,8 @@ function actionError(cause: unknown) {
|
||||
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 === 'ORIGINAL_NOT_FOUND')
|
||||
return { status: 409, error: 'Das zugehörige Fundus-Piece ist nicht mehr verfügbar.' };
|
||||
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.' };
|
||||
@@ -104,18 +109,25 @@ export const load: PageServerLoad = ({ locals, params, url }) => {
|
||||
requireUser(locals.user);
|
||||
const show = getShow(params.id);
|
||||
if (!show) error(404, 'Sendung nicht gefunden');
|
||||
const pieces = listPieces({
|
||||
statuses: ['ready'],
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
categoryId: url.searchParams.get('category') ?? ''
|
||||
});
|
||||
const usageContexts = getPieceUsageContexts(
|
||||
pieces.map((piece) => piece.id),
|
||||
show.id
|
||||
);
|
||||
return {
|
||||
show,
|
||||
startsAtLocal: berlinInput(show.startsAtUtc),
|
||||
pieces: listPieces({
|
||||
statuses: ['ready'],
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
categoryId: url.searchParams.get('category') ?? ''
|
||||
}),
|
||||
pieces: pieces.map((piece) => ({ ...piece, ...usageContexts[piece.id] })),
|
||||
taxonomy: getTaxonomy(),
|
||||
filters: {
|
||||
query: url.searchParams.get('q') ?? '',
|
||||
categoryId: url.searchParams.get('category') ?? ''
|
||||
categoryId: url.searchParams.get('category') ?? '',
|
||||
includeUsed: url.searchParams.get('includeUsed') === '1',
|
||||
includePlanned: url.searchParams.get('includePlanned') === '1'
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -198,10 +210,33 @@ export const actions = {
|
||||
moderation: text(data, 'moderation'),
|
||||
transition: text(data, 'transition'),
|
||||
presentation: text(data, 'presentation'),
|
||||
status: text(data, 'status') as UsageStatus
|
||||
presentationMode: text(data, 'presentationMode') as PieceUsageUpdate['presentationMode'],
|
||||
presentationSourceLabel: text(data, 'presentationSourceLabel'),
|
||||
presentationSourceUrl: text(data, 'presentationSourceUrl'),
|
||||
presentationSourceTimecodeSeconds: text(data, 'presentationSourceTimecodeSeconds')
|
||||
? Number(data.get('presentationSourceTimecodeSeconds'))
|
||||
: null,
|
||||
presentationTitle: text(data, 'presentationTitle'),
|
||||
presentationDescription: text(data, 'presentationDescription'),
|
||||
presentationImageUrl: text(data, 'presentationImageUrl'),
|
||||
presentationSiteName: text(data, 'presentationSiteName')
|
||||
})
|
||||
);
|
||||
},
|
||||
duplicateUsage: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
return attempt('duplicateUsage', () =>
|
||||
duplicatePieceUsage(params.id, text(data, 'usageId'), Number(data.get('revision')))
|
||||
);
|
||||
},
|
||||
refreshUsage: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
return attempt('refreshUsage', () =>
|
||||
refreshPieceUsageFromOriginal(params.id, text(data, 'usageId'), Number(data.get('revision')))
|
||||
);
|
||||
},
|
||||
removeUsage: async ({ locals, params, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
|
||||
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
|
||||
import { formatClock, formatDuration } from '#lib/format.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
@@ -23,9 +28,27 @@
|
||||
let dropSectionId = $state('');
|
||||
let dropPosition = $state(0);
|
||||
let copyMessage = $state('');
|
||||
let showUsedPieces = $state(untrack(() => data.filters.includeUsed));
|
||||
let showPlannedPieces = $state(untrack(() => data.filters.includePlanned));
|
||||
let visiblePieces = $derived(
|
||||
data.pieces.filter(
|
||||
(piece) =>
|
||||
(showUsedPieces || piece.sentHistory.length === 0) &&
|
||||
(showPlannedPieces || !piece.isPlannedInCurrentShow)
|
||||
)
|
||||
);
|
||||
let plannedCount = $derived(
|
||||
data.show.sections.reduce((total, section) => total + section.usages.length, 0)
|
||||
);
|
||||
onMount(() => {
|
||||
const syncFiltersFromHistory = () => {
|
||||
const search = new URL(window.location.href).searchParams;
|
||||
showUsedPieces = search.get('includeUsed') === '1';
|
||||
showPlannedPieces = search.get('includePlanned') === '1';
|
||||
};
|
||||
window.addEventListener('popstate', syncFiltersFromHistory);
|
||||
return () => window.removeEventListener('popstate', syncFiltersFromHistory);
|
||||
});
|
||||
|
||||
function berlinDate(value: string | null) {
|
||||
if (!value) return 'Termin offen';
|
||||
@@ -36,8 +59,15 @@
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function durationInMinutes(seconds: number) {
|
||||
return Number((seconds / 60).toFixed(2));
|
||||
function setSourceFilter(name: 'includeUsed' | 'includePlanned', checked: boolean) {
|
||||
if (name === 'includeUsed') showUsedPieces = checked;
|
||||
else showPlannedPieces = checked;
|
||||
const url = new URL(page.url.href);
|
||||
if (checked) url.searchParams.set(name, '1');
|
||||
else url.searchParams.delete(name);
|
||||
// This URL is cloned from the current page; only same-route filter parameters change.
|
||||
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||
replaceState(url, page.state);
|
||||
}
|
||||
|
||||
function startDrag(kind: 'piece' | 'section' | 'usage', id: string, event: DragEvent) {
|
||||
@@ -142,6 +172,7 @@
|
||||
affected?.classList.add('is-updating');
|
||||
if (submitter instanceof HTMLButtonElement) submitter.disabled = true;
|
||||
return async ({ result, update }) => {
|
||||
if (result.type === 'success') formElement.closest<HTMLDialogElement>('dialog')?.close();
|
||||
await update({ reset: result.type === 'success', invalidateAll: true });
|
||||
affected?.classList.remove('is-updating');
|
||||
if (submitter instanceof HTMLButtonElement) submitter.disabled = false;
|
||||
@@ -264,9 +295,9 @@
|
||||
<p class="eyebrow">Quelle</p>
|
||||
<h2 id="source-title">Sendefertiger Fundus</h2>
|
||||
</div>
|
||||
<span>{data.pieces.length}</span>
|
||||
<span>{visiblePieces.length}</span>
|
||||
</header>
|
||||
<form method="GET" class="builder-filter">
|
||||
<form id="builder-filters" method="GET" class="builder-filter">
|
||||
<label
|
||||
><span class="sr-only">Fundus durchsuchen</span><input
|
||||
type="search"
|
||||
@@ -286,8 +317,32 @@
|
||||
>
|
||||
<button class="text-button" type="submit">Filtern</button>
|
||||
</form>
|
||||
<div class="builder-filter-options" aria-label="Fundusauswahl erweitern">
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includeUsed"
|
||||
value="1"
|
||||
form="builder-filters"
|
||||
checked={showUsedPieces}
|
||||
onchange={(event) => setSourceFilter('includeUsed', event.currentTarget.checked)}
|
||||
/>
|
||||
<span>Auch verwendete Inhalte zeigen</span>
|
||||
</label>
|
||||
<label class="filter-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="includePlanned"
|
||||
value="1"
|
||||
form="builder-filters"
|
||||
checked={showPlannedPieces}
|
||||
onchange={(event) => setSourceFilter('includePlanned', event.currentTarget.checked)}
|
||||
/>
|
||||
<span>Auch eingeplante Inhalte zeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="builder-source-list">
|
||||
{#each data.pieces as piece (piece.id)}
|
||||
{#each visiblePieces as piece (piece.id)}
|
||||
<article
|
||||
class="builder-source-card"
|
||||
class:is-dragging={dragged?.kind === 'piece' && dragged.id === piece.id}
|
||||
@@ -310,6 +365,8 @@
|
||||
<p>
|
||||
{piece.format} · {piece.topics.map((item) => item.name).join(', ') || 'Ohne Thema'}
|
||||
</p>
|
||||
{#if piece.isPlannedInCurrentShow}<span class="planned-label">Eingeplant</span>{/if}
|
||||
<PieceUsageHistory history={piece.sentHistory} />
|
||||
<small class="drag-hint">Ziehen oder Ziel auswählen</small>
|
||||
</div>
|
||||
<form
|
||||
@@ -338,10 +395,13 @@
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{#if !data.pieces.length}<div class="empty-state">
|
||||
{#if !visiblePieces.length}<div class="empty-state">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<h2>Nichts gefunden</h2>
|
||||
<p>Filter ändern oder Pieces im Fundus sendefertig markieren.</p>
|
||||
<h2>Keine verfügbaren Pieces</h2>
|
||||
<p>
|
||||
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig
|
||||
markieren.
|
||||
</p>
|
||||
</div>{/if}
|
||||
</section>
|
||||
|
||||
@@ -507,105 +567,15 @@
|
||||
aria-label={`${usage.title} nach unten`}>↓</button
|
||||
>
|
||||
</form>
|
||||
<UsageEditorDialog
|
||||
{usage}
|
||||
revision={data.show.revision}
|
||||
sectionId={section.id}
|
||||
sections={data.show.sections}
|
||||
error={form?.error}
|
||||
{enhanceCommand}
|
||||
/>
|
||||
</div>
|
||||
<details class="usage-editor">
|
||||
<summary>Bearbeiten und verschieben</summary>
|
||||
<form
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/updateUsage"
|
||||
class="usage-edit-form"
|
||||
>
|
||||
<input type="hidden" name="revision" value={data.show.revision} /><input
|
||||
type="hidden"
|
||||
name="usageId"
|
||||
value={usage.id}
|
||||
/>
|
||||
<label
|
||||
>Titel<input
|
||||
name="title"
|
||||
required
|
||||
maxlength="200"
|
||||
value={usage.title}
|
||||
/></label
|
||||
><label
|
||||
>Dauer in Minuten<input
|
||||
name="durationMinutes"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1440"
|
||||
step="any"
|
||||
value={durationInMinutes(usage.durationSeconds)}
|
||||
/></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
>Eigene Notizen<textarea name="ownNotes" rows="2"
|
||||
>{usage.ownNotes}</textarea
|
||||
></label
|
||||
><label class="wide-field"
|
||||
>Einleitung<textarea name="intro" rows="2">{usage.intro}</textarea
|
||||
></label
|
||||
><label class="wide-field"
|
||||
>Recherche<textarea name="research" rows="2"
|
||||
>{usage.research}</textarea
|
||||
></label
|
||||
><label class="wide-field"
|
||||
>Moderation<textarea name="moderation" rows="2"
|
||||
>{usage.moderation}</textarea
|
||||
></label
|
||||
><label class="wide-field"
|
||||
>Überleitung<textarea name="transition" rows="2"
|
||||
>{usage.transition}</textarea
|
||||
></label
|
||||
><label class="wide-field"
|
||||
>Präsentation<textarea name="presentation" rows="2"
|
||||
>{usage.presentation}</textarea
|
||||
></label
|
||||
>
|
||||
<input type="hidden" name="status" value={usage.status} /><button
|
||||
class="primary-button wide-field"
|
||||
type="submit">Verwendung speichern</button
|
||||
>
|
||||
</form>
|
||||
<div class="usage-command-row">
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
|
||||
<input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={data.show.revision}
|
||||
/><input type="hidden" name="usageId" value={usage.id} /><input
|
||||
type="hidden"
|
||||
name="position"
|
||||
value="9999"
|
||||
/><label
|
||||
>Ziel<select name="sectionId"
|
||||
><option value="">Reservebank</option
|
||||
>{#each data.show.sections as target (target.id)}<option
|
||||
value={target.id}
|
||||
selected={target.id === section.id}>{target.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
><button class="secondary-button" type="submit">Verschieben</button>
|
||||
</form>
|
||||
<form
|
||||
use:enhance={enhanceCommand}
|
||||
method="POST"
|
||||
action="?/removeUsage"
|
||||
onsubmit={(event) =>
|
||||
!confirm('Verwendung aus der Sendung entfernen?') &&
|
||||
event.preventDefault()}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={data.show.revision}
|
||||
/><input type="hidden" name="usageId" value={usage.id} /><button
|
||||
class="text-button danger-text"
|
||||
type="submit">Entfernen</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -811,6 +781,14 @@
|
||||
aria-label={`${usage.title} aus Reserve entfernen`}>Entfernen</button
|
||||
>
|
||||
</form>
|
||||
<UsageEditorDialog
|
||||
{usage}
|
||||
revision={data.show.revision}
|
||||
sectionId=""
|
||||
sections={data.show.sections}
|
||||
error={form?.error}
|
||||
{enhanceCommand}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
Reference in new issue
Block a user