Release 0.7.1

This commit is contained in:
hnzio committed 2026-10-25 23:29:00 +01:00
1 parent 6d96900d81
commit a08b02c34b
29 files changed
+1078 -401

No files matched your search

+8 -1
View File
@@ -6,6 +6,7 @@
$props();
let query = $state('');
let menu: HTMLDetailsElement;
let searchInput: HTMLInputElement;
let filtered = $derived(
classicRegularIcons.filter((icon) =>
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
@@ -18,7 +19,12 @@
<div class="icon-picker">
<input type="hidden" name={inputName} value={name} />
<details bind:this={menu}>
<details
bind:this={menu}
ontoggle={() => {
if (menu.open) queueMicrotask(() => searchInput.focus());
}}
>
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
<div class="icon-picker-popover">
<label
@@ -26,6 +32,7 @@
type="search"
placeholder="Icon suchen"
bind:value={query}
bind:this={searchInput}
/></label
>
<div class="icon-picker-grid">
+206 -157
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import AppIcon from './AppIcon.svelte';
type ShareShow = {
@@ -31,7 +32,12 @@
shows: ShareShow[];
shares: ShareItem[];
fixedShowId?: string;
form?: { intent?: string; error?: string; success?: string | boolean; shareUrl?: string } | null;
form?: {
intent?: string;
error?: string;
success?: string | boolean;
shareUrl?: string;
} | null;
} = $props();
let visibleShows = $derived(
@@ -40,6 +46,17 @@
: shows.filter((show) => shares.some((share) => share.showId === show.id))
);
let dialog: HTMLDialogElement;
let copyMessage = $state('');
onMount(() => {
if (form?.intent?.toLowerCase().includes('share')) dialog.showModal();
});
async function copyShareUrl() {
if (!form?.shareUrl) return;
await navigator.clipboard.writeText(new URL(form.shareUrl, window.location.origin).href);
copyMessage = 'Link kopiert.';
}
function germanDate(value: string | null, fallback = 'Termin offen') {
return value
@@ -87,174 +104,206 @@
}
</script>
<button class="secondary-button share-dialog-trigger" type="button" onclick={() => dialog.showModal()}>
<button
class="secondary-button share-dialog-trigger"
type="button"
onclick={() => dialog.showModal()}
>
<AppIcon name="link" /> Lesefreigaben
</button>
<dialog bind:this={dialog} class="share-management-dialog">
<section
class="quiet-panel share-management"
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
>
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
<section
class="quiet-panel share-management"
aria-labelledby={`share-title-${fixedShowId || 'all'}`}
>
<div class="panel-heading">
<div>
<p class="eyebrow">Gastzugänge</p>
<h2 id={`share-title-${fixedShowId || 'all'}`}>Lesefreigaben</h2>
</div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
<button
class="dialog-close"
type="button"
aria-label="Lesefreigaben schließen"
onclick={() => dialog.close()}
>
<AppIcon name="xmark" />
</button>
</div>
<span class="mail-mode">Aktualisiert sich bis zum Abschluss</span>
<button class="dialog-close" type="button" aria-label="Lesefreigaben schließen" onclick={() => dialog.close()}>
<AppIcon name="xmark" />
</button>
</div>
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.intent?.toLowerCase().includes('share') && typeof form.success === 'string'}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
<strong>Neuer Leselink</strong>
<!-- The server returns only a same-origin share path created by this action. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={form.shareUrl}>{form.shareUrl}</a>
</div>{/if}
<p class="share-intro">
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
wird aus Sicherheitsgründen nur direkt nach dem Anlegen angezeigt.
</p>
{#if form?.intent?.toLowerCase().includes('share') && form.error}<div
class="form-message error"
role="alert"
>
{form.error}
</div>{/if}
{#if form?.intent?.toLowerCase().includes('share') && typeof form.success === 'string'}<div
class="form-message success"
role="status"
>
{form.success}
</div>{/if}
{#if form?.shareUrl}<div class="form-message success share-result" role="status">
<strong>Neuer Leselink</strong>
<!-- The server returns only a same-origin share path created by this action. -->
<div>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={form.shareUrl}>{form.shareUrl}</a><button
class="secondary-button"
type="button"
onclick={copyShareUrl}><AppIcon name="copy" /> Kopieren</button
>
</div>
{#if copyMessage}<small>{copyMessage}</small>{/if}
</div>{/if}
<form class="share-create-form" method="POST" action="?/createShare">
{#if fixedShowId}
<input type="hidden" name="showId" value={fixedShowId} />
{:else}
<form class="share-create-form" method="POST" action="?/createShare">
{#if fixedShowId}
<input type="hidden" name="showId" value={fixedShowId} />
{:else}
<label
>Sendung<select name="showId" required>
<option value="">Sendung wählen</option>
{#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option
>{/each}
</select></label
>
{/if}
<label
>Sendung<select name="showId" required>
<option value="">Sendung wählen</option>
{#each shows as show (show.id)}<option value={show.id}
>{show.title} · {statusLabel(show.status)}</option
>{/each}
>Informationsumfang<select name="scope">
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
{/if}
<label
>Informationsumfang<select name="scope">
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
<label
>Optionales Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button>
</form>
<label
>Optionales Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label>Optionaler Ablauf<input name="expiresAt" type="datetime-local" /></label>
<button class="primary-button" type="submit" disabled={!shows.length}>Leselink anlegen</button
>
</form>
{#if visibleShows.length}
<div class="share-show-list">
{#each visibleShows as show (show.id)}
<article class="share-show-card">
<header>
<div>
<a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a>
<small>{germanDate(show.startsAtUtc)} Uhr</small>
</div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
</header>
<div class="share-list">
{#each shares.filter((share) => share.showId === show.id) as share (share.id)}
<details class="share-row">
<summary>
<span class="share-row-icon"><AppIcon name="link" /></span>
<span>
<strong>{scopeLabel(share.scope)}</strong>
<small
>{share.isPasswordProtected ? 'Mit Passwort' : 'Ohne Passwort'} · {germanDate(
share.expiresAtUtc,
'ohne Ablauf'
)}</small
{#if visibleShows.length}
<div class="share-show-list">
{#each visibleShows as show (show.id)}
<article class="share-show-card">
<header>
<div>
<a href={resolve('/sendungen/[id]', { id: show.id })}>{show.title}</a>
<small>{germanDate(show.startsAtUtc)} Uhr</small>
</div>
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
</header>
<div class="share-list">
{#each shares.filter((share) => share.showId === show.id) as share (share.id)}
<details class="share-row">
<summary>
<span class="share-row-icon"><AppIcon name="link" /></span>
<span>
<strong>{scopeLabel(share.scope)}</strong>
<small
>{share.isPasswordProtected ? 'Mit Passwort' : 'Ohne Passwort'} · {germanDate(
share.expiresAtUtc,
'ohne Ablauf'
)}</small
>
</span>
<span class:locked={shareState(share) !== 'Aktiv'}>{shareState(share)}</span>
<AppIcon name="pen" />
</summary>
<div class="share-edit-area">
{#if share.revokedAtUtc}
<p>Diese Freigabe ist widerrufen und kann nur noch gelöscht werden.</p>
{:else}
<form class="share-edit-form" method="POST" action="?/updateShare">
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<label
>Informationsumfang<select name="scope" value={share.scope}>
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
>
<label
>Passwort<select name="passwordMode">
<option value="keep">Unverändert</option>
<option value="remove">Kein Passwort</option>
<option value="replace">Neues Passwort setzen</option>
</select></label
>
<label
>Neues Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label
>Ablauf<input
name="expiresAt"
type="datetime-local"
value={berlinInput(share.expiresAtUtc)}
/><small>Leer lassen, um den Ablauf zu entfernen.</small></label
>
<button class="secondary-button" type="submit">Freigabe speichern</button>
</form>
{/if}
{#if !share.revokedAtUtc}<form
method="POST"
action="?/replaceShare"
onsubmit={(event) => {
if (
!confirm('Ersatzlink anlegen? Der bisherige Link wird sofort ungültig.')
)
event.preventDefault();
}}
>
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button" type="submit">Ersatzlink anlegen</button>
</form>{/if}
<form
method="POST"
action="?/deleteShare"
onsubmit={(event) => {
if (
!confirm('Freigabe endgültig löschen? Der Leselink wird sofort ungültig.')
)
event.preventDefault();
}}
>
</span>
<span class:locked={shareState(share) !== 'Aktiv'}>{shareState(share)}</span>
<AppIcon name="pen" />
</summary>
<div class="share-edit-area">
{#if share.revokedAtUtc}
<p>Diese Freigabe ist widerrufen und kann nur noch gelöscht werden.</p>
{:else}
<form class="share-edit-form" method="POST" action="?/updateShare">
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<label
>Informationsumfang<select name="scope" value={share.scope}>
<option value="rundown">Nur Ablauf</option>
<option value="moderation">Ablauf und Moderation</option>
<option value="research">Vollständig mit Recherche</option>
</select></label
<button class="text-button danger-text" type="submit">Freigabe löschen</button
>
<label
>Passwort<select name="passwordMode">
<option value="keep">Unverändert</option>
<option value="remove">Kein Passwort</option>
<option value="replace">Neues Passwort setzen</option>
</select></label
>
<label
>Neues Passwort<input
name="password"
type="password"
minlength="12"
maxlength="256"
autocomplete="new-password"
/></label
>
<label
>Ablauf<input
name="expiresAt"
type="datetime-local"
value={berlinInput(share.expiresAtUtc)}
/><small>Leer lassen, um den Ablauf zu entfernen.</small></label
>
<button class="secondary-button" type="submit">Freigabe speichern</button>
</form>
{/if}
<form
method="POST"
action="?/deleteShare"
onsubmit={(event) => {
if (
!confirm('Freigabe endgültig löschen? Der Leselink wird sofort ungültig.')
)
event.preventDefault();
}}
>
<input type="hidden" name="shareId" value={share.id} />
<input type="hidden" name="showId" value={show.id} />
<button class="text-button danger-text" type="submit">Freigabe löschen</button>
</form>
</div>
</details>
{/each}
{#if !shares.some((share) => share.showId === show.id)}
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
{/if}
</div>
</article>
{/each}
</div>
{:else}
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
</div>
</details>
{/each}
{#if !shares.some((share) => share.showId === show.id)}
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
{/if}
</div>
</article>
{/each}
</div>
{:else}
<p class="quiet-copy">Noch keine Lesefreigaben angelegt.</p>
{/if}
</section>
</dialog>
+71 -38
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import MarkdownEditorField from './MarkdownEditorField.svelte';
import InlineTitleEditor from './InlineTitleEditor.svelte';
import NavIcon from './NavIcon.svelte';
@@ -13,7 +13,8 @@
sectionId,
sections,
error,
enhanceCommand
enhanceCommand,
open = false
}: {
usage: PieceUsage;
revision: number;
@@ -21,6 +22,7 @@
sections: { id: string; name: string }[];
error?: string;
enhanceCommand: SubmitFunction;
open?: boolean;
} = $props();
let formId = $derived(`usage-editor-form-${usage.id}`);
@@ -30,6 +32,9 @@
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
);
let dirty = $state(false);
onMount(() => {
if (open) editor.showModal();
});
let selectedMedia = $derived(
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
);
@@ -44,6 +49,15 @@
editor.close();
}
function presentationSourceChanged(event: Event) {
const index = Number((event.currentTarget as HTMLSelectElement).value);
const source = draft.sources[index];
if (!source) return;
draft.presentationSourceLabel = source.label;
draft.presentationSourceUrl = source.url;
draft.presentationSourceTimecodeSeconds = source.timecodeSeconds;
}
const enhanceEditor: SubmitFunction = async (input) => {
const finish = await enhanceCommand(input);
return async (result) => {
@@ -212,20 +226,7 @@
</div>
<div class="piece-presentation-layout">
<div class="piece-presentation-controls">
<label
>Präsentationstitel<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentation"
label="Öffentlicher Text"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
<p class="field-label">Präsentationsart</p>
<div class="presentation-mode-picker" role="group" aria-label="Darstellung">
{#each [['text', 'Text'], ['image', 'Nur Bild'], ['link', 'Linkvorschau'], ['video', 'Video']] as mode (mode[0])}
<label
@@ -238,6 +239,25 @@
>
{/each}
</div>
{#if draft.presentationMode === 'text'}
<label
>Öffentliche Überschrift<input
name="outputTitle"
maxlength="300"
placeholder="Optional, sonst interner Titel"
bind:value={draft.outputTitle}
/></label
>
<MarkdownEditorField
name="presentation"
label="Öffentlicher Vorspann"
placeholder="Text für die Publikumsausgabe"
bind:value={draft.presentation}
/>
{:else}
<input type="hidden" name="outputTitle" value={draft.outputTitle} />
<input type="hidden" name="presentation" value={draft.presentation} />
{/if}
<input
type="hidden"
name="presentationSourceLabel"
@@ -253,32 +273,42 @@
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
{#if draft.presentationMode === 'link' || draft.presentationMode === 'video'}
<div class="presentation-settings">
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{draft.presentationSourceLabel ||
draft.presentationSourceUrl ||
'Keine Quelle'}</strong
<label class="wide-field"
>{draft.presentationMode === 'video' ? 'Video-URL' : 'Quelle/URL'}<select
onchange={presentationSourceChanged}
>
{#if draft.presentationSourceUrl}<small>{draft.presentationSourceUrl}</small>{/if}
</div>
{#each draft.sources as source, index (source.id)}
<option value={index} selected={source.url === draft.presentationSourceUrl}
>{source.label || source.url}</option
>
{/each}
</select></label
>
<label
>Kartentitel<input
>{draft.presentationMode === 'video' ? 'Videotitel' : 'Kartentitel'}<input
name="presentationTitle"
maxlength="300"
placeholder="Optionaler Anzeigetitel"
bind:value={draft.presentationTitle}
/></label
>
<label
>Bild-URL<input
{#if draft.presentationMode === 'link'}
<label
>Vorschaubild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="Aus der Linkvorschau"
bind:value={draft.presentationImageUrl}
/></label
>
{:else}
<input
type="hidden"
name="presentationImageUrl"
type="url"
maxlength="2000"
placeholder="https://…"
bind:value={draft.presentationImageUrl}
/></label
>
value={draft.presentationImageUrl}
/>
{/if}
<label class="wide-field"
>Anreißer<textarea
name="presentationDescription"
@@ -298,7 +328,9 @@
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
{/if}
</div>
{#if draft.media.length}<fieldset class="usage-media-picker">
{#if draft.media.length && ['text', 'image'].includes(draft.presentationMode)}<fieldset
class="usage-media-picker"
>
<legend>Hochgeladenes Präsentationsbild</legend>
<label
><input
@@ -336,19 +368,20 @@
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{/if}
{:else}
{:else if draft.presentationMode === 'image'}
{#if selectedMedia}<img
src={`/media/${selectedMedia.originalMediaId}`}
alt={selectedMedia.altText}
/>{:else if draft.presentationImageUrl}<img
src={draft.presentationImageUrl}
alt=""
/>{/if}
{:else if draft.presentationMode === 'link'}
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
<p>
{draft.presentationDescription ||
draft.presentationSourceUrl ||
'Noch keine Vorschauangaben.'}
</p>
{:else}
<p>{draft.presentationSourceUrl || 'Noch keine Video-URL ausgewählt.'}</p>
{/if}
</aside>
</div>