Release 0.5.10
This commit is contained in:
1 parent
c3033479b5
commit
4fe61971e4
52 files changed
+38565
-466
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import NavIcon from '#lib/components/NavIcon.svelte';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import ThemeToggle from '#lib/components/ThemeToggle.svelte';
|
||||
|
||||
let { children, data } = $props();
|
||||
@@ -84,7 +85,9 @@
|
||||
<span>Sendewerk</span>
|
||||
</a>
|
||||
<form method="POST" action="/logout">
|
||||
<button class="mobile-logout" type="submit" aria-label="Abmelden">↗</button>
|
||||
<button class="mobile-logout" type="submit" aria-label="Abmelden"
|
||||
><AppIcon name="right-from-bracket" /></button
|
||||
>
|
||||
</form>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import { listPieces, setPieceArchived } from '#lib/server/content.js';
|
||||
import { listPieces, setPieceArchived, setPieceDeleted } from '#lib/server/content.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
requireUser(locals.user);
|
||||
return { pieces: listPieces({ statuses: ['archived'] }) };
|
||||
return {
|
||||
pieces: listPieces({ statuses: ['archived'] }),
|
||||
deletedPieces: listPieces({
|
||||
statuses: ['inbox', 'preparing', 'ready', 'archived'],
|
||||
deleted: true
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -18,5 +24,15 @@ export const actions = {
|
||||
} catch {
|
||||
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
|
||||
}
|
||||
},
|
||||
restoreDeleted: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), false);
|
||||
return { success: 'Gelöschtes Piece wurde wiederhergestellt.' };
|
||||
} catch {
|
||||
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte Seite neu laden.' });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
</script>
|
||||
@@ -28,8 +29,29 @@
|
||||
</article>{/each}
|
||||
</div>
|
||||
{:else}<section class="quiet-panel planned-panel">
|
||||
<div class="planned-icon" aria-hidden="true">□</div>
|
||||
<div class="planned-icon" aria-hidden="true"><AppIcon name="box-archive" /></div>
|
||||
<h2>Keine Pieces archiviert</h2>
|
||||
<p>Archivierte Inhalte lassen sich hier später wiederherstellen.</p>
|
||||
</section>{/if}
|
||||
<section class="deleted-pieces" aria-labelledby="deleted-pieces-title">
|
||||
<h2 id="deleted-pieces-title">Gelöschte Pieces</h2>
|
||||
<p class="quiet-copy">
|
||||
Diese Einträge bleiben wiederherstellbar; bestehende Sendungsfassungen werden nicht verändert.
|
||||
</p>
|
||||
{#if data.deletedPieces.length}<div class="archive-list">
|
||||
{#each data.deletedPieces as piece (piece.id)}<article>
|
||||
<div>
|
||||
<h2>{piece.title}</h2>
|
||||
{#if piece.summary}<p>{piece.summary}</p>{/if}
|
||||
</div>
|
||||
<form method="POST" action="?/restoreDeleted">
|
||||
<input type="hidden" name="pieceId" value={piece.id} /><input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={piece.revision}
|
||||
/><button class="secondary-button" type="submit">Wiederherstellen</button>
|
||||
</form>
|
||||
</article>{/each}
|
||||
</div>{:else}<p class="context-empty">Keine gelöschten Pieces.</p>{/if}
|
||||
</section>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { requireUser } from '#lib/server/auth.js';
|
||||
import { captureInbox, listPieces } from '#lib/server/content.js';
|
||||
import { captureInbox, listPieces, setPieceDeleted } from '#lib/server/content.js';
|
||||
|
||||
export const load: PageServerLoad = ({ locals }) => {
|
||||
requireUser(locals.user);
|
||||
@@ -26,5 +26,15 @@ export const actions = {
|
||||
: 'Ein Titel ist erforderlich.'
|
||||
});
|
||||
}
|
||||
},
|
||||
delete: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
|
||||
return { deleted: true };
|
||||
} catch {
|
||||
return fail(409, { error: 'Das Piece wurde inzwischen geändert. Bitte neu laden.' });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
</script>
|
||||
@@ -45,18 +46,37 @@
|
||||
{#if data.pieces.length}
|
||||
<section class="inbox-list">
|
||||
<h2>{data.pieces.length} {data.pieces.length === 1 ? 'offener Inhalt' : 'offene Inhalte'}</h2>
|
||||
{#each data.pieces as piece (piece.id)}<a
|
||||
class="inbox-item"
|
||||
href={resolve('/fundus/[id]', { id: piece.id })}
|
||||
><span
|
||||
><strong>{piece.title}</strong><small
|
||||
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
|
||||
></span
|
||||
><span>Ausarbeiten →</span></a
|
||||
>{/each}
|
||||
{#each data.pieces as piece (piece.id)}<article class="inbox-item">
|
||||
<a href={resolve('/fundus/[id]', { id: piece.id })}
|
||||
><span
|
||||
><strong>{piece.title}</strong><small
|
||||
>{piece.sourceCount ? `${piece.sourceCount} Quelle` : 'Gedanke ohne Link'}</small
|
||||
></span
|
||||
><span>Ausarbeiten <AppIcon name="arrow-right" /></span></a
|
||||
>
|
||||
<details class="piece-actions">
|
||||
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
|
||||
>
|
||||
<div class="piece-actions-popover">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
onsubmit={(event) =>
|
||||
!confirm(`„${piece.title}“ löschen?`) && event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="pieceId" value={piece.id} /><input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={piece.revision}
|
||||
/>
|
||||
<button class="text-button danger-text" type="submit">Piece löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</article>{/each}
|
||||
</section>
|
||||
{:else}<section class="quiet-panel planned-panel">
|
||||
<div class="planned-icon" aria-hidden="true">+</div>
|
||||
<div class="planned-icon" aria-hidden="true"><AppIcon name="plus" /></div>
|
||||
<h2>Eingang ist leer</h2>
|
||||
<p>Neue Gedanken und Links können oben direkt aufgenommen werden.</p>
|
||||
</section>{/if}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
listPieces,
|
||||
listPieceTemplates,
|
||||
setPieceArchived,
|
||||
setPieceDeleted,
|
||||
setPieceFavorite
|
||||
} from '#lib/server/content.js';
|
||||
import { getPieceUsageContexts } from '#lib/server/shows.js';
|
||||
@@ -104,5 +105,18 @@ export const actions = {
|
||||
} catch (cause) {
|
||||
return fail(404, { intent: 'favorite', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
delete: async ({ locals, request }) => {
|
||||
requireUser(locals.user);
|
||||
const data = await request.formData();
|
||||
try {
|
||||
setPieceDeleted(String(data.get('pieceId') || ''), Number(data.get('revision')), true);
|
||||
return {
|
||||
intent: 'delete',
|
||||
success: 'Piece gelöscht. Bestehende Sendungsfassungen bleiben erhalten.'
|
||||
};
|
||||
} catch (cause) {
|
||||
return fail(409, { intent: 'delete', error: actionError(cause) });
|
||||
}
|
||||
}
|
||||
} satisfies Actions;
|
||||
@@ -5,6 +5,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import { formatClock } from '#lib/format.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
@@ -65,14 +66,16 @@
|
||||
<strong>{visiblePieces.length}</strong><span>Inhalte</span>
|
||||
</div>
|
||||
<details class="template-picker">
|
||||
<summary><span aria-hidden="true">+</span> Neues Piece</summary>
|
||||
<summary><AppIcon name="plus" /> Neues Piece</summary>
|
||||
<div class="template-popover">
|
||||
<div class="template-grid">
|
||||
{#each data.templates as template (template.id)}
|
||||
<form method="POST" action="?/create">
|
||||
<input type="hidden" name="template" value={template.id} />
|
||||
<button type="submit"
|
||||
><strong>{template.name}</strong><span>{template.description}</span></button
|
||||
><AppIcon name={template.iconName} size="1.35rem" /><strong
|
||||
>{template.name}</strong
|
||||
><span>{template.description}</span></button
|
||||
>
|
||||
</form>
|
||||
{/each}
|
||||
@@ -110,7 +113,7 @@
|
||||
>
|
||||
<div class="fundus-search-row">
|
||||
<label class="search-field"
|
||||
><span aria-hidden="true">⌕</span><span class="sr-only">Fundus durchsuchen</span><input
|
||||
><AppIcon name="magnifying-glass" /><span class="sr-only">Fundus durchsuchen</span><input
|
||||
name="q"
|
||||
type="search"
|
||||
value={data.filters.query}
|
||||
@@ -119,7 +122,7 @@
|
||||
>
|
||||
<button class="secondary-button" type="submit">Suchen</button>
|
||||
<details class="filter-picker">
|
||||
<summary><span aria-hidden="true">≡</span> Weitere Filter</summary>
|
||||
<summary><AppIcon name="list-check" /> Weitere Filter</summary>
|
||||
<div class="filter-panel">
|
||||
<label
|
||||
><span>Status</span><select name="status"
|
||||
@@ -204,7 +207,7 @@
|
||||
aria-label={piece.isFavorite
|
||||
? `${piece.title} aus Favoriten entfernen`
|
||||
: `${piece.title} als Favorit markieren`}
|
||||
aria-pressed={piece.isFavorite}>★</button
|
||||
aria-pressed={piece.isFavorite}><AppIcon name="star" /></button
|
||||
>
|
||||
</form>
|
||||
{#if piece.mediaId}<img
|
||||
@@ -212,14 +215,11 @@
|
||||
src={`/media/${piece.mediaId}`}
|
||||
alt=""
|
||||
/>{:else}<span class="piece-thumbnail piece-thumbnail-placeholder" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M4.5 6.5h15v11h-15zM7.5 14l3-3 2.2 2.2 1.8-1.7 2.5 2.5" />
|
||||
<circle cx="15.8" cy="9.2" r="1.2" />
|
||||
</svg>
|
||||
<AppIcon name={piece.topics[0]?.iconName || 'newspaper'} size="2rem" />
|
||||
</span>{/if}
|
||||
<div class="piece-primary">
|
||||
<h2><a href={resolve('/fundus/[id]', { id: piece.id })}>{piece.title}</a></h2>
|
||||
<p>{piece.summary || 'Noch keine Kurzbeschreibung.'}</p>
|
||||
{#if piece.summary}<p>{piece.summary}</p>{/if}
|
||||
<PieceUsageHistory history={piece.sentHistory} />
|
||||
</div>
|
||||
<div class="piece-classification">
|
||||
@@ -227,23 +227,24 @@
|
||||
><i></i>{piece.categories.map((item) => item.name).join(', ') || 'Offen'}</span
|
||||
>
|
||||
<div class="piece-tags">
|
||||
<span>{piece.format}</span>{#each piece.topics as topic (topic.id)}<span
|
||||
>{topic.name}</span
|
||||
>{/each}
|
||||
{#if piece.formatId}<span>{piece.format}</span
|
||||
>{/if}{#each piece.topics as topic (topic.id)}<span>{topic.name}</span>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="piece-state">
|
||||
<span class:ready={piece.status === 'ready'} class="status-label"
|
||||
>{statusLabel[piece.status]}</span
|
||||
><span class="piece-facts"
|
||||
><span><b>{formatClock(piece.durationSeconds)}</b> geplant</span><span
|
||||
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
|
||||
></span
|
||||
>{#if piece.durationSeconds || piece.sourceCount}<span class="piece-facts"
|
||||
>{#if piece.durationSeconds}<span
|
||||
><b>{formatClock(piece.durationSeconds)}</b> geplant</span
|
||||
>{/if}{#if piece.sourceCount}<span
|
||||
>{piece.sourceCount} {piece.sourceCount === 1 ? 'Quelle' : 'Quellen'}</span
|
||||
>{/if}</span
|
||||
>{/if}
|
||||
>
|
||||
</div>
|
||||
<details class="piece-actions">
|
||||
<summary aria-label={`Aktionen für ${piece.title}`}
|
||||
><span aria-hidden="true">•••</span></summary
|
||||
<summary aria-label={`Aktionen für ${piece.title}`}><AppIcon name="ellipsis" /></summary
|
||||
>
|
||||
<div class="piece-actions-popover">
|
||||
<form method="POST" action="?/duplicate">
|
||||
@@ -259,6 +260,21 @@
|
||||
value={piece.revision}
|
||||
/><button class="text-button danger-text" type="submit">Archivieren</button>
|
||||
</form>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
onsubmit={(event) =>
|
||||
!confirm(
|
||||
`Piece „${piece.title}“ löschen? Bestehende Sendungsfassungen bleiben erhalten.`
|
||||
) && event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="pieceId" value={piece.id} /><input
|
||||
type="hidden"
|
||||
name="revision"
|
||||
value={piece.revision}
|
||||
/>
|
||||
<button class="text-button danger-text" type="submit">Piece löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
@@ -266,7 +282,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
|
||||
<h2>Kein passendes Piece</h2>
|
||||
<p>Andere Filter wählen oder ein neues Piece anlegen.</p>
|
||||
<a class="secondary-button" href={resolve('/fundus')}>Filter zurücksetzen</a>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
savePiece,
|
||||
savePieceAsTemplate,
|
||||
setPieceArchived,
|
||||
setPieceDeleted,
|
||||
setPieceFavorite,
|
||||
uploadPieceImage,
|
||||
type EditablePieceStatus,
|
||||
@@ -88,6 +89,7 @@ function pieceInput(data: FormData): PieceInput {
|
||||
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,
|
||||
@@ -206,7 +208,13 @@ export const actions = {
|
||||
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'));
|
||||
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')
|
||||
@@ -264,5 +272,15 @@ export const actions = {
|
||||
} 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;
|
||||
@@ -4,6 +4,9 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { onDestroy, onMount, untrack } from 'svelte';
|
||||
import MarkdownEditorField from '#lib/components/MarkdownEditorField.svelte';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import ChoicePicker from '#lib/components/ChoicePicker.svelte';
|
||||
import InlineTitleEditor from '#lib/components/InlineTitleEditor.svelte';
|
||||
import PieceEditorDialog from '#lib/components/PieceEditorDialog.svelte';
|
||||
import PresentationDisplay from '#lib/components/PresentationDisplay.svelte';
|
||||
import type { PresentationContent } from '#lib/server/presentation.js';
|
||||
@@ -15,7 +18,6 @@
|
||||
let revision = $state(untrack(() => data.piece.revision));
|
||||
let sources = $state(untrack(() => data.piece.sources.map((source) => ({ ...source }))));
|
||||
let pieceTools = $state(untrack(() => data.piece.tools.map((tool) => ({ ...tool }))));
|
||||
let toolToAdd = $state('');
|
||||
let rendered = $state(untrack(() => ({ ...data.rendered })));
|
||||
let activeSection = $state<'script' | 'research' | 'presentation'>('script');
|
||||
let saveState = $state<'idle' | 'dirty' | 'saving' | 'saved' | 'error' | 'conflict'>('idle');
|
||||
@@ -44,7 +46,12 @@
|
||||
? resolve('/fundus/[id]/praesentation/preview-image', { id: data.piece.id })
|
||||
: ''
|
||||
);
|
||||
let previewContent = $derived<PresentationContent>({
|
||||
let selectedMedia = $derived(
|
||||
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
|
||||
);
|
||||
let previewContent = $derived<
|
||||
PresentationContent & { images: { id: string; altText: string }[] }
|
||||
>({
|
||||
mode: draft.presentationMode,
|
||||
topics: data.taxonomy.topics
|
||||
.filter((topic) => draft.topicIds.includes(topic.id))
|
||||
@@ -69,8 +76,16 @@
|
||||
? `https://www.youtube-nocookie.com/embed/${draft.presentationProviderId}${featuredSource?.timecodeSeconds ? `?start=${featuredSource.timecodeSeconds}` : ''}`
|
||||
: draft.presentationProvider === 'tiktok' && draft.presentationProviderId
|
||||
? `https://www.tiktok.com/player/v1/${draft.presentationProviderId}`
|
||||
: ''
|
||||
: '',
|
||||
images: selectedMedia ? [{ id: selectedMedia.id, altText: selectedMedia.altText }] : []
|
||||
});
|
||||
let presentationPreviewImage = $derived(
|
||||
selectedMedia
|
||||
? draft.presentationMode === 'text'
|
||||
? '/media'
|
||||
: `/media/${selectedMedia.id}`
|
||||
: sourcePreviewImage
|
||||
);
|
||||
let pendingHref: string | null = null;
|
||||
let allowNavigation = false;
|
||||
|
||||
@@ -152,24 +167,28 @@
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function addTool() {
|
||||
const tool = data.tools.find((item) => item.id === toolToAdd);
|
||||
if (!tool || pieceTools.some((item) => item.toolId === tool.id)) return;
|
||||
pieceTools.push({
|
||||
id: crypto.randomUUID(),
|
||||
toolId: tool.id,
|
||||
name: tool.name,
|
||||
startUrl: tool.startUrl,
|
||||
isActive: tool.isActive,
|
||||
workUrl: '',
|
||||
note: ''
|
||||
});
|
||||
toolToAdd = '';
|
||||
function removeTool(index: number) {
|
||||
pieceTools.splice(index, 1);
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
function removeTool(index: number) {
|
||||
pieceTools.splice(index, 1);
|
||||
function selectTools(ids: string[]) {
|
||||
for (const id of ids) {
|
||||
if (pieceTools.some((tool) => tool.toolId === id)) continue;
|
||||
const tool = data.tools.find((item) => item.id === id);
|
||||
if (tool)
|
||||
pieceTools.push({
|
||||
id: crypto.randomUUID(),
|
||||
toolId: tool.id,
|
||||
name: tool.name,
|
||||
startUrl: tool.startUrl,
|
||||
iconName: tool.iconName,
|
||||
isActive: tool.isActive,
|
||||
workUrl: '',
|
||||
note: ''
|
||||
});
|
||||
}
|
||||
pieceTools = pieceTools.filter((tool) => ids.includes(tool.toolId));
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
@@ -244,22 +263,15 @@
|
||||
class:active={data.piece.isFavorite}
|
||||
type="submit"
|
||||
aria-label={data.piece.isFavorite ? 'Aus Favoriten entfernen' : 'Als Favorit markieren'}
|
||||
aria-pressed={data.piece.isFavorite}>★</button
|
||||
aria-pressed={data.piece.isFavorite}><AppIcon name="star" /></button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<label class="piece-dialog-title">
|
||||
<span class="sr-only">Titel</span>
|
||||
<textarea
|
||||
form="piece-editor-form"
|
||||
name="title"
|
||||
required
|
||||
rows="1"
|
||||
maxlength="200"
|
||||
aria-label="Titel"
|
||||
bind:value={draft.title}
|
||||
oninput={scheduleSave}></textarea>
|
||||
</label>
|
||||
<InlineTitleEditor
|
||||
form="piece-editor-form"
|
||||
bind:value={draft.title}
|
||||
oncommit={scheduleSave}
|
||||
/>
|
||||
<h1 class="sr-only" id="piece-editor-title">{draft.title}</h1>
|
||||
{/snippet}
|
||||
|
||||
@@ -345,17 +357,16 @@
|
||||
bind:value={draft.summary}></textarea></label
|
||||
>
|
||||
<fieldset>
|
||||
<legend>Themen</legend>
|
||||
<div class="choice-grid">
|
||||
{#each data.taxonomy.topics as item (item.id)}<label
|
||||
><input
|
||||
type="checkbox"
|
||||
name="topicIds"
|
||||
value={item.id}
|
||||
bind:group={draft.topicIds}
|
||||
/>{item.name}</label
|
||||
>{/each}
|
||||
</div>
|
||||
<legend>Themen</legend><ChoicePicker
|
||||
items={data.taxonomy.topics}
|
||||
selectedIds={draft.topicIds}
|
||||
label="Themen"
|
||||
fieldName="topicIds"
|
||||
onselectionchange={(ids) => {
|
||||
draft.topicIds = ids;
|
||||
scheduleSave();
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>Vorgeschlagene Abschnittskategorien</legend>
|
||||
@@ -440,22 +451,20 @@
|
||||
<p class="eyebrow">Arbeitsmittel</p>
|
||||
<h3>Werkzeuge</h3>
|
||||
</div>
|
||||
<div class="tool-picker">
|
||||
<label class="sr-only" for="piece-tool-picker">Werkzeug auswählen</label>
|
||||
<select id="piece-tool-picker" bind:value={toolToAdd}>
|
||||
<option value="">Werkzeug auswählen</option>
|
||||
{#each data.tools.filter((tool) => tool.isActive && !pieceTools.some((assigned) => assigned.toolId === tool.id)) as tool (tool.id)}
|
||||
<option value={tool.id}
|
||||
>{tool.name}{tool.formatIds.includes(draft.formatId ?? '')
|
||||
? ' · passt zum Format'
|
||||
: ''}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="secondary-button" type="button" onclick={addTool} disabled={!toolToAdd}
|
||||
>Hinzufügen</button
|
||||
>
|
||||
</div>
|
||||
<ChoicePicker
|
||||
items={data.tools
|
||||
.filter((tool) => tool.isActive)
|
||||
.map((tool) => ({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
iconName: tool.iconName,
|
||||
meta: tool.formatIds.includes(draft.formatId ?? '') ? 'Passt zum Format' : ''
|
||||
}))}
|
||||
selectedIds={pieceTools.map((tool) => tool.toolId)}
|
||||
label="Werkzeuge"
|
||||
onselectionchange={selectTools}
|
||||
/>
|
||||
</div>
|
||||
{#if pieceTools.length}<div class="source-list">
|
||||
{#each pieceTools as tool, index (tool.id)}<div
|
||||
@@ -463,7 +472,10 @@
|
||||
>
|
||||
<input type="hidden" name="toolId" value={tool.toolId} />
|
||||
<div class="tool-assignment-name">
|
||||
<strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small>{/if}
|
||||
<AppIcon name={tool.iconName || 'wrench'} /><span
|
||||
><strong>{tool.name}</strong>{#if !tool.isActive}<small>Deaktiviert</small
|
||||
>{/if}</span
|
||||
>
|
||||
</div>
|
||||
<label
|
||||
>Arbeitslink<input
|
||||
@@ -590,7 +602,7 @@
|
||||
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
|
||||
<PresentationDisplay
|
||||
content={previewContent}
|
||||
imageSrc={sourceImageUnavailable ? '' : sourcePreviewImage}
|
||||
imageSrc={sourceImageUnavailable ? '' : presentationPreviewImage}
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -620,17 +632,44 @@
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'research'}>
|
||||
<section class="piece-editor-panel piece-media-panel" hidden={activeSection !== 'presentation'}>
|
||||
<div class="piece-subsection-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Private Dateien</p>
|
||||
<h3>Material</h3>
|
||||
<p class="eyebrow">Hochgeladene Bilder</p>
|
||||
<h3>Präsentationsbild</h3>
|
||||
</div>
|
||||
<small>JPEG, PNG, WebP oder GIF bis 5 MB</small>
|
||||
</div>
|
||||
{#if data.piece.media.length}<div class="media-grid">
|
||||
{#if data.piece.media.length}<label class="media-none"
|
||||
><input
|
||||
form="piece-editor-form"
|
||||
type="radio"
|
||||
name="presentationMediaId"
|
||||
value=""
|
||||
checked={!draft.presentationMediaId}
|
||||
onchange={() => {
|
||||
draft.presentationMediaId = null;
|
||||
scheduleSave();
|
||||
}}
|
||||
/> Kein hochgeladenes Bild</label
|
||||
>
|
||||
<div class="media-grid">
|
||||
{#each data.piece.media as media (media.id)}<figure>
|
||||
<img src={`/media/${media.id}`} alt={media.altText} />
|
||||
<label class="media-presentation-choice"
|
||||
><input
|
||||
form="piece-editor-form"
|
||||
type="radio"
|
||||
name="presentationMediaId"
|
||||
value={media.id}
|
||||
checked={draft.presentationMediaId === media.id}
|
||||
onchange={() => {
|
||||
draft.presentationMediaId = media.id;
|
||||
scheduleSave();
|
||||
}}
|
||||
/><img src={`/media/${media.id}`} alt={media.altText} /><span
|
||||
>Als Präsentationsbild</span
|
||||
></label
|
||||
>
|
||||
<figcaption>
|
||||
<span>{media.altText || media.originalName}</span>
|
||||
<form method="POST" action="?/deleteImage">
|
||||
@@ -662,6 +701,10 @@
|
||||
><button class="secondary-button" type="submit" disabled={actionsBlocked}
|
||||
>Bild hochladen</button
|
||||
>
|
||||
<label class="upload-select"
|
||||
><input type="checkbox" name="selectForPresentation" value="1" checked /> Direkt als Präsentationsbild
|
||||
verwenden</label
|
||||
>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -696,6 +739,19 @@
|
||||
disabled={actionsBlocked}>Archivieren</button
|
||||
>
|
||||
</form>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
onsubmit={(event) =>
|
||||
!confirm('Piece löschen? Bestehende Sendungsfassungen bleiben erhalten.') &&
|
||||
event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="revision" value={revision} /><button
|
||||
class="text-button danger-text"
|
||||
type="submit"
|
||||
disabled={actionsBlocked}>Piece löschen</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
<div class="piece-dialog-primary-action">
|
||||
<span
|
||||
|
||||
@@ -40,8 +40,10 @@ export const load: PageServerLoad = ({ locals, params }) => {
|
||||
piece.presentationProviderId,
|
||||
source?.timecodeSeconds ?? null
|
||||
),
|
||||
sources: piece.sources,
|
||||
images: piece.media.map((item) => ({ id: item.id, altText: item.altText }))
|
||||
sources: [],
|
||||
images: piece.media
|
||||
.filter((item) => item.id === piece.presentationMediaId)
|
||||
.map((item) => ({ id: item.id, altText: item.altText }))
|
||||
};
|
||||
return { pieceId: piece.id, content };
|
||||
};
|
||||
@@ -14,8 +14,10 @@
|
||||
<div class="presentation-test-status" role="status">Isolierte Testansicht</div>
|
||||
<PresentationDisplay
|
||||
content={data.content}
|
||||
imageSrc={data.content.mode === 'text'
|
||||
? '/media'
|
||||
imageSrc={data.content.images.length
|
||||
? data.content.mode === 'text'
|
||||
? '/media'
|
||||
: `/media/${data.content.images[0].id}`
|
||||
: `/fundus/${data.pieceId}/praesentation/preview-image`}
|
||||
/>
|
||||
</main>
|
||||
@@ -56,7 +56,7 @@
|
||||
<meta name="robots" content="noindex, nofollow, noarchive" />
|
||||
</svelte:head>
|
||||
|
||||
<main class="heinzlive-output">
|
||||
<main class:empty={!view.content} class="heinzlive-output">
|
||||
<div class="output-connection sr-only" class:problem={connection === 'interrupted'} role="status">
|
||||
<span></span>{connection === 'interrupted' ? 'Verbindung unterbrochen' : 'Verbunden'}
|
||||
</div>
|
||||
@@ -64,16 +64,12 @@
|
||||
{#key view.revision}
|
||||
<PresentationDisplay
|
||||
content={view.content}
|
||||
imageSrc={view.content.mode === 'text'
|
||||
? `/heinzlive/${page.params.token}/media`
|
||||
imageSrc={view.content.images.length
|
||||
? view.content.mode === 'text'
|
||||
? `/heinzlive/${page.params.token}/media`
|
||||
: `/heinzlive/${page.params.token}/media/${view.content.images[0].id}`
|
||||
: `/heinzlive/${page.params.token}/preview-image`}
|
||||
/>
|
||||
{/key}
|
||||
{:else}
|
||||
<section class="output-idle">
|
||||
<div class="brand-mark" aria-hidden="true"><i></i><i></i><i></i></div>
|
||||
<h1>Bereit für den nächsten Inhalt</h1>
|
||||
<p>Die Moderation wählt ausdrücklich aus, was hier erscheint.</p>
|
||||
</section>
|
||||
{/if}
|
||||
</main>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { formatClock, formatDuration } from '#lib/format.js';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
@@ -183,6 +184,17 @@
|
||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||
<button class="secondary-button" type="submit">Ausgabe verbinden und öffnen</button>
|
||||
</form>
|
||||
{#if data.live.presentationUsageId}<form
|
||||
method="POST"
|
||||
action={liveAction('present')}
|
||||
use:enhance={enhanceCommand}
|
||||
>
|
||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||
<input type="hidden" name="usageId" value="" />
|
||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||
<input type="hidden" name="commandId" value={data.commandId} />
|
||||
<button class="text-button" type="submit">Präsentation leeren</button>
|
||||
</form>{/if}
|
||||
<details class="live-output-settings">
|
||||
<summary>Ausgabekanal</summary>
|
||||
<div>
|
||||
@@ -307,17 +319,6 @@
|
||||
: 'Präsentieren'}
|
||||
</button>
|
||||
</form>
|
||||
{#if data.live.presentationUsageId}<form
|
||||
method="POST"
|
||||
action={liveAction('present')}
|
||||
use:enhance={enhanceCommand}
|
||||
>
|
||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||
<input type="hidden" name="usageId" value="" />
|
||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||
<input type="hidden" name="commandId" value={data.commandId} />
|
||||
<button class="text-button" type="submit">Präsentation leeren</button>
|
||||
</form>{/if}
|
||||
<form method="POST" action={liveAction('skip')} use:enhance={enhanceCommand}>
|
||||
<input type="hidden" name="showId" value={data.live.show.id} />
|
||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||
@@ -412,7 +413,7 @@
|
||||
type="submit"
|
||||
disabled={usageIndex === 0 ||
|
||||
section.usages[usageIndex - 1]?.status !== 'open'}
|
||||
aria-label={`${usage.title} nach oben`}>↑</button
|
||||
aria-label={`${usage.title} nach oben`}><AppIcon name="arrow-up" /></button
|
||||
>
|
||||
</form>
|
||||
<form
|
||||
@@ -429,7 +430,8 @@
|
||||
type="submit"
|
||||
disabled={usageIndex === section.usages.length - 1 ||
|
||||
section.usages[usageIndex + 1]?.status !== 'open'}
|
||||
aria-label={`${usage.title} nach unten`}>↓</button
|
||||
aria-label={`${usage.title} nach unten`}
|
||||
><AppIcon name="arrow-down" /></button
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
@@ -438,7 +440,9 @@
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<input type="hidden" name="revision" value={data.live.liveRevision} />
|
||||
<input type="hidden" name="commandId" value={data.commandId} />
|
||||
<button type="submit" aria-label={`${usage.title} präsentieren`}>↗</button>
|
||||
<button type="submit" aria-label={`${usage.title} präsentieren`}
|
||||
><AppIcon name="display" /></button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -465,7 +469,8 @@
|
||||
<button
|
||||
type="submit"
|
||||
disabled={reserveIndex === 0}
|
||||
aria-label={`${usage.title} in Reserve nach oben`}>↑</button
|
||||
aria-label={`${usage.title} in Reserve nach oben`}
|
||||
><AppIcon name="arrow-up" /></button
|
||||
>
|
||||
</form>
|
||||
<form method="POST" action={liveAction('moveUsage')} use:enhance={enhanceCommand}>
|
||||
@@ -477,7 +482,8 @@
|
||||
<button
|
||||
type="submit"
|
||||
disabled={reserveIndex === data.live.reserve.length - 1}
|
||||
aria-label={`${usage.title} in Reserve nach unten`}>↓</button
|
||||
aria-label={`${usage.title} in Reserve nach unten`}
|
||||
><AppIcon name="arrow-down" /></button
|
||||
>
|
||||
</form>
|
||||
{#if data.live.current || data.live.sections[0]}<form
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { formatDuration } from '#lib/format.js';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
@@ -27,8 +28,9 @@
|
||||
|
||||
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
||||
|
||||
<section class="show-create-panel">
|
||||
<div>
|
||||
<details class="show-create-panel">
|
||||
<summary><AppIcon name="plus" /> Neue Sendung planen</summary>
|
||||
<div class="show-create-heading">
|
||||
<p class="eyebrow">Neuen Stream planen</p>
|
||||
<h2>Leer starten oder Ablauf übernehmen</h2>
|
||||
</div>
|
||||
@@ -45,23 +47,29 @@
|
||||
<label
|
||||
>Termin<input name="startsAt" type="datetime-local" value={form?.startsAt ?? ''} /></label
|
||||
>
|
||||
<label
|
||||
>Ausgangspunkt<select name="templateId" value={form?.templateId ?? ''}
|
||||
><option value="">Leer starten</option
|
||||
>{#each data.templates as template (template.id)}<option value={template.id}
|
||||
>{template.name}</option
|
||||
>{/each}</select
|
||||
></label
|
||||
>
|
||||
<fieldset class="show-template-grid">
|
||||
<legend>Ausgangspunkt</legend>
|
||||
<label
|
||||
><input type="radio" name="templateId" value="" checked={!form?.templateId} /><AppIcon
|
||||
name="newspaper"
|
||||
/><span
|
||||
><strong>Leer starten</strong><small>Eine leere Sendung frei aufbauen.</small></span
|
||||
></label
|
||||
>
|
||||
{#each data.templates as template (template.id)}<label
|
||||
><input
|
||||
type="radio"
|
||||
name="templateId"
|
||||
value={template.id}
|
||||
checked={form?.templateId === template.id}
|
||||
/><AppIcon name="list-check" /><span
|
||||
><strong>{template.name}</strong><small>{template.description}</small></span
|
||||
></label
|
||||
>{/each}
|
||||
</fieldset>
|
||||
<button class="primary-button" type="submit">Sendung anlegen</button>
|
||||
</form>
|
||||
<div class="template-notes">
|
||||
{#each data.templates as template (template.id)}<p>
|
||||
<strong>{template.name}:</strong>
|
||||
{template.description}
|
||||
</p>{/each}
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<section class="show-library" aria-labelledby="show-library-title">
|
||||
<header>
|
||||
@@ -86,16 +94,17 @@
|
||||
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
|
||||
</div>
|
||||
<div class="show-card-facts">
|
||||
<span><strong>{show.sectionCount}</strong> Abschnitte</span><span
|
||||
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
|
||||
><b aria-hidden="true">→</b>
|
||||
{#if show.sectionCount}<span><strong>{show.sectionCount}</strong> Abschnitte</span
|
||||
>{/if}{#if show.totalDurationSeconds}<span
|
||||
><strong>{formatDuration(show.totalDurationSeconds)}</strong> Planzeit</span
|
||||
>{/if}<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-state">
|
||||
<span aria-hidden="true">+</span>
|
||||
<span aria-hidden="true"><AppIcon name="plus" /></span>
|
||||
<h2>Noch keine Sendung</h2>
|
||||
<p>Oben kann der erste Stream geplant werden.</p>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,16 @@ function actionError(cause: unknown) {
|
||||
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 (
|
||||
['USAGE_IS_CURRENT', 'USAGE_IS_PRESENTED', 'USAGE_HAS_LIVE_RUNS', 'USAGE_NOT_OPEN'].includes(
|
||||
code
|
||||
)
|
||||
)
|
||||
return {
|
||||
status: 409,
|
||||
error:
|
||||
'Diese Sendungsfassung war bereits live aktiv oder wird gerade verwendet und kann nicht entfernt werden.'
|
||||
};
|
||||
if (code === 'INVALID_DURATION')
|
||||
return { status: 400, error: 'Piece-Dauern müssen zwischen 0 und 1.440 Minuten liegen.' };
|
||||
if (code.startsWith('INVALID_'))
|
||||
@@ -239,6 +249,7 @@ export const actions = {
|
||||
presentationDescription: text(data, 'presentationDescription'),
|
||||
presentationImageUrl: text(data, 'presentationImageUrl'),
|
||||
presentationSiteName: text(data, 'presentationSiteName'),
|
||||
presentationMediaId: text(data, 'presentationMediaId') || null,
|
||||
tools: toolIds.map((id, index) => ({
|
||||
id,
|
||||
workUrl: toolWorkUrls[index] ?? '',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { flip } from 'svelte/animate';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import PieceUsageHistory from '#lib/components/PieceUsageHistory.svelte';
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import UsageEditorDialog from '#lib/components/UsageEditorDialog.svelte';
|
||||
import { formatClock, formatDuration } from '#lib/format.js';
|
||||
import type { PageProps } from './$types';
|
||||
@@ -207,7 +208,7 @@
|
||||
<div class="page-wrap builder-page">
|
||||
<header class="page-heading show-heading">
|
||||
<div class="show-heading-copy">
|
||||
<a class="back-link" href={resolve('/sendungen')}>← Sendungen</a>
|
||||
<a class="back-link" href={resolve('/sendungen')}><AppIcon name="arrow-left" /> Sendungen</a>
|
||||
<h1>{data.show.title}</h1>
|
||||
<p class="show-date">
|
||||
{data.show.startsAtUtc ? `${berlinDate(data.show.startsAtUtc)} Uhr` : 'Termin offen'}
|
||||
@@ -219,7 +220,7 @@
|
||||
<strong>{formatDuration(data.show.totalDurationSeconds)}</strong><span>Planzeit</span>
|
||||
</div>
|
||||
<a class="live-link" href={resolve(`/live?show=${encodeURIComponent(data.show.id)}`)}
|
||||
>Live-Steuerung <span>↗</span></a
|
||||
>Live-Steuerung <AppIcon name="display" /></a
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
@@ -412,7 +413,8 @@
|
||||
><span>{formatClock(piece.durationSeconds)}</span>
|
||||
</div>
|
||||
<h3>
|
||||
{#if piece.isFavorite}<span class="favorite-mark" aria-label="Favorit">★</span
|
||||
{#if piece.isFavorite}<span class="favorite-mark" aria-label="Favorit"
|
||||
><AppIcon name="star" /></span
|
||||
>{/if}{piece.title}
|
||||
</h3>
|
||||
<p>
|
||||
@@ -449,7 +451,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{#if !visiblePieces.length}<div class="empty-state">
|
||||
<span aria-hidden="true">⌕</span>
|
||||
<span aria-hidden="true"><AppIcon name="magnifying-glass" /></span>
|
||||
<h2>Keine verfügbaren Pieces</h2>
|
||||
<p>
|
||||
Suchfilter ändern, ausgeblendete Inhalte einblenden oder Pieces im Fundus sendefertig
|
||||
@@ -525,7 +527,7 @@
|
||||
/><input type="hidden" name="position" value={index - 1} /><button
|
||||
type="submit"
|
||||
disabled={index === 0}
|
||||
aria-label={`${section.name} nach oben`}>↑</button
|
||||
aria-label={`${section.name} nach oben`}><AppIcon name="arrow-up" /></button
|
||||
>
|
||||
</form>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/moveSection">
|
||||
@@ -536,7 +538,7 @@
|
||||
/><input type="hidden" name="position" value={index + 1} /><button
|
||||
type="submit"
|
||||
disabled={index === data.show.sections.length - 1}
|
||||
aria-label={`${section.name} nach unten`}>↓</button
|
||||
aria-label={`${section.name} nach unten`}><AppIcon name="arrow-down" /></button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
@@ -602,7 +604,8 @@
|
||||
/><button
|
||||
type="submit"
|
||||
disabled={usageIndex === 0}
|
||||
aria-label={`${usage.title} nach oben`}>↑</button
|
||||
aria-label={`${usage.title} nach oben`}
|
||||
><AppIcon name="arrow-up" /></button
|
||||
>
|
||||
</form>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
|
||||
@@ -617,7 +620,8 @@
|
||||
/><button
|
||||
type="submit"
|
||||
disabled={usageIndex === section.usages.length - 1}
|
||||
aria-label={`${usage.title} nach unten`}>↓</button
|
||||
aria-label={`${usage.title} nach unten`}
|
||||
><AppIcon name="arrow-down" /></button
|
||||
>
|
||||
</form>
|
||||
<UsageEditorDialog
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
createTaxonomy,
|
||||
deleteTaxonomy,
|
||||
getTaxonomy,
|
||||
updateTaxonomy,
|
||||
type TaxonomyKind
|
||||
} from '#lib/server/content.js';
|
||||
import { getMailConfig } from '#lib/server/config.js';
|
||||
@@ -42,6 +43,7 @@ function actionError(cause: unknown): string {
|
||||
return 'Verwendete oder für Vorlagen benötigte Einträge können nicht entfernt werden.';
|
||||
if (code === 'INVALID_COLOR') return 'Bitte eine gültige Farbe wählen.';
|
||||
if (code === 'INVALID_TAXONOMY') return 'Bitte einen gültigen Namen eingeben.';
|
||||
if (code === 'INVALID_ICON') return 'Bitte ein Icon aus der angebotenen Auswahl verwenden.';
|
||||
if (code === 'TOOL_EXISTS') return 'Ein Werkzeug mit diesem Namen existiert bereits.';
|
||||
if (code === 'INVALID_TOOL_URL')
|
||||
return 'Die Start-URL muss eine vollständige HTTP- oder HTTPS-Adresse sein.';
|
||||
@@ -56,6 +58,7 @@ function toolInput(data: FormData): ToolInput {
|
||||
name: String(data.get('name') || ''),
|
||||
startUrl: String(data.get('startUrl') || ''),
|
||||
description: String(data.get('description') || ''),
|
||||
iconName: String(data.get('iconName') || ''),
|
||||
isActive: data.get('isActive') === '1',
|
||||
formatIds: data.getAll('formatIds').map(String)
|
||||
};
|
||||
@@ -130,12 +133,36 @@ export const actions = {
|
||||
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
|
||||
}
|
||||
try {
|
||||
createTaxonomy(kind, String(data.get('name') || ''), String(data.get('color') || ''));
|
||||
createTaxonomy(
|
||||
kind,
|
||||
String(data.get('name') || ''),
|
||||
String(data.get('color') || ''),
|
||||
String(data.get('iconName') || '')
|
||||
);
|
||||
return { intent: 'taxonomy', success: 'Eintrag angelegt.' };
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
updateTaxonomy: async ({ locals, request }) => {
|
||||
requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
const kind = String(data.get('kind') || '') as TaxonomyKind;
|
||||
if (!['category', 'format', 'topic'].includes(kind)) {
|
||||
return fail(400, { intent: 'taxonomy', error: 'Ungültige Taxonomieart.' });
|
||||
}
|
||||
try {
|
||||
updateTaxonomy(
|
||||
kind,
|
||||
String(data.get('taxonomyId') || ''),
|
||||
String(data.get('name') || ''),
|
||||
String(data.get('iconName') || '')
|
||||
);
|
||||
return { intent: 'taxonomy', success: 'Eintrag aktualisiert.' };
|
||||
} catch (cause) {
|
||||
return fail(400, { intent: 'taxonomy', error: actionError(cause) });
|
||||
}
|
||||
},
|
||||
deleteTaxonomy: async ({ locals, request }) => {
|
||||
requireBarkeeper(locals.user);
|
||||
const data = await request.formData();
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import AppIcon from '#lib/components/AppIcon.svelte';
|
||||
import IconPicker from '#lib/components/IconPicker.svelte';
|
||||
import ToolEditorDialog from '#lib/components/ToolEditorDialog.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
let toolDialog: { showModal: (tool?: (typeof data.tools)[number] | null) => void };
|
||||
|
||||
function confirmTaxonomyRemoval(event: SubmitEvent) {
|
||||
if (!confirm('Eintrag wirklich entfernen?')) event.preventDefault();
|
||||
@@ -120,81 +124,38 @@
|
||||
<p class="eyebrow">Redaktionelle Hilfsmittel</p>
|
||||
<h2>Werkzeugkatalog</h2>
|
||||
</div>
|
||||
<small>Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.</small>
|
||||
<button class="compact-add-button" type="button" onclick={() => toolDialog.showModal()}>
|
||||
<AppIcon name="plus" /> Werkzeug hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div class="tool-settings-list">
|
||||
<p class="catalog-note">
|
||||
Deaktivierte Werkzeuge bleiben in bestehenden Pieces und Sendungen erhalten.
|
||||
</p>
|
||||
<div class="catalog-settings-list tool-settings-list">
|
||||
{#each data.tools as tool (tool.id)}
|
||||
<details class:inactive={!tool.isActive}>
|
||||
<summary
|
||||
><strong>{tool.name}</strong><span>{tool.isActive ? 'Aktiv' : 'Deaktiviert'}</span
|
||||
></summary
|
||||
>
|
||||
<form class="compact-form tool-form" method="POST" action="?/updateTool">
|
||||
<input type="hidden" name="toolId" value={tool.id} />
|
||||
<label>Name<input name="name" required maxlength="120" value={tool.name} /></label>
|
||||
<label
|
||||
>Start-URL<input
|
||||
name="startUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
value={tool.startUrl}
|
||||
placeholder="https://…"
|
||||
/></label
|
||||
<button
|
||||
type="button"
|
||||
class:inactive={!tool.isActive}
|
||||
aria-label={`${tool.name} bearbeiten`}
|
||||
onclick={() => toolDialog.showModal(tool)}
|
||||
>
|
||||
<span class="catalog-entry-icon"><AppIcon name={tool.iconName || 'wrench'} /></span>
|
||||
<span class="catalog-entry-copy">
|
||||
<strong>{tool.name}</strong>
|
||||
<small
|
||||
>{tool.isActive ? 'Aktiv' : 'Deaktiviert'}{#if tool.formatIds.length}
|
||||
· {data.taxonomy.formats
|
||||
.filter((format) => tool.formatIds.includes(format.id))
|
||||
.map((format) => format.name)
|
||||
.join(', ')}{/if}</small
|
||||
>
|
||||
<label class="wide-field"
|
||||
>Beschreibung<textarea name="description" rows="2" maxlength="2000"
|
||||
>{tool.description}</textarea
|
||||
></label
|
||||
>
|
||||
<fieldset class="wide-field">
|
||||
<legend>Passende Formate</legend>
|
||||
<div class="choice-grid">
|
||||
{#each data.taxonomy.formats as format (format.id)}<label
|
||||
><input
|
||||
type="checkbox"
|
||||
name="formatIds"
|
||||
value={format.id}
|
||||
checked={tool.formatIds.includes(format.id)}
|
||||
/>{format.name}</label
|
||||
>{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label class="tool-active"
|
||||
><input type="checkbox" name="isActive" value="1" checked={tool.isActive} /> Aktiv</label
|
||||
>
|
||||
<button class="secondary-button" type="submit">Speichern</button>
|
||||
</form>
|
||||
</details>
|
||||
</span>
|
||||
<AppIcon name="pen" />
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<details class="new-tool">
|
||||
<summary>Werkzeug hinzufügen</summary>
|
||||
<form class="compact-form tool-form" method="POST" action="?/createTool">
|
||||
<label>Name<input name="name" required maxlength="120" /></label>
|
||||
<label
|
||||
>Start-URL<input
|
||||
name="startUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder="https://…"
|
||||
/></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
>Beschreibung<textarea name="description" rows="2" maxlength="2000"></textarea></label
|
||||
>
|
||||
<fieldset class="wide-field">
|
||||
<legend>Passende Formate</legend>
|
||||
<div class="choice-grid">
|
||||
{#each data.taxonomy.formats as format (format.id)}<label
|
||||
><input type="checkbox" name="formatIds" value={format.id} />{format.name}</label
|
||||
>{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<input type="hidden" name="isActive" value="1" />
|
||||
<button class="primary-button" type="submit">Werkzeug anlegen</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
<ToolEditorDialog bind:this={toolDialog} formats={data.taxonomy.formats} />
|
||||
|
||||
<h2 class="section-heading">Taxonomie</h2>
|
||||
<div class="settings-grid">
|
||||
@@ -209,7 +170,9 @@
|
||||
type="hidden"
|
||||
name="taxonomyId"
|
||||
value={category.id}
|
||||
/><button type="submit" aria-label={`${category.name} entfernen`}>×</button>
|
||||
/><button type="submit" aria-label={`${category.name} entfernen`}
|
||||
><AppIcon name="trash" /></button
|
||||
>
|
||||
</form></span
|
||||
>{/each}
|
||||
</div>
|
||||
@@ -241,7 +204,9 @@
|
||||
type="hidden"
|
||||
name="taxonomyId"
|
||||
value={format.id}
|
||||
/><button type="submit" aria-label={`${format.name} entfernen`}>×</button>
|
||||
/><button type="submit" aria-label={`${format.name} entfernen`}
|
||||
><AppIcon name="trash" /></button
|
||||
>
|
||||
</form></span
|
||||
>{/each}
|
||||
</div>
|
||||
@@ -256,20 +221,31 @@
|
||||
><button class="text-button" type="submit">Hinzufügen</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="quiet-panel taxonomy-panel">
|
||||
<section class="quiet-panel taxonomy-panel topic-management-panel">
|
||||
<p class="eyebrow">Themen</p>
|
||||
<h2>Inhaltliche Zuordnung</h2>
|
||||
<div class="taxonomy-list editable-taxonomy">
|
||||
{#each data.taxonomy.topics as topic (topic.id)}<span
|
||||
>{topic.name}
|
||||
<div class="topic-settings-list">
|
||||
{#each data.taxonomy.topics as topic (topic.id)}
|
||||
<details>
|
||||
<summary
|
||||
><AppIcon name={topic.iconName || 'circle'} /><strong>{topic.name}</strong><AppIcon
|
||||
name="pen"
|
||||
/></summary
|
||||
>
|
||||
<form class="topic-edit-form" method="POST" action="?/updateTaxonomy">
|
||||
<input type="hidden" name="kind" value="topic" />
|
||||
<input type="hidden" name="taxonomyId" value={topic.id} />
|
||||
<label>Name<input name="name" required maxlength="80" value={topic.name} /></label>
|
||||
<IconPicker name={topic.iconName} />
|
||||
<button class="secondary-button" type="submit">Änderungen speichern</button>
|
||||
</form>
|
||||
<form method="POST" action="?/deleteTaxonomy" onsubmit={confirmTaxonomyRemoval}>
|
||||
<input type="hidden" name="kind" value="topic" /><input
|
||||
type="hidden"
|
||||
name="taxonomyId"
|
||||
value={topic.id}
|
||||
/><button type="submit" aria-label={`${topic.name} entfernen`}>×</button>
|
||||
</form></span
|
||||
>{/each}
|
||||
<input type="hidden" name="kind" value="topic" />
|
||||
<input type="hidden" name="taxonomyId" value={topic.id} />
|
||||
<button class="text-button danger-text" type="submit">Thema löschen</button>
|
||||
</form>
|
||||
</details>
|
||||
{/each}
|
||||
</div>
|
||||
<form class="taxonomy-form" method="POST" action="?/createTaxonomy">
|
||||
<input type="hidden" name="kind" value="topic" /><label
|
||||
@@ -279,7 +255,7 @@
|
||||
maxlength="80"
|
||||
placeholder="Neues Thema"
|
||||
/></label
|
||||
><button class="text-button" type="submit">Hinzufügen</button>
|
||||
><IconPicker name="" /><button class="text-button" type="submit">Hinzufügen</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user