Release 0.5.1

This commit is contained in:
hnzio committed 2026-10-22 07:56:25 +02:00
1 parent 5a9d7735d4
commit fb24d171ae
36 files changed
+5099 -263

No files matched your search

+2
View File
@@ -16,6 +16,8 @@
<path
d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6 7 7M17 17l1.4 1.4M18.4 5.6 17 7M7 17l-1.4 1.4"
/>
{:else if name === 'edit'}
<path d="M4 20h4L19 9l-4-4L4 16v4zM13.5 6.5l4 4" />
{/if}
</svg>
@@ -0,0 +1,36 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { SentPieceUsage } from '#lib/server/shows.js';
let { history }: { history: SentPieceUsage[] } = $props();
function berlinDate(value: string) {
return new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
dateStyle: 'medium'
}).format(new Date(value));
}
</script>
{#if history.length}
<div class="piece-usage-history">
<p>
<strong>Gesendet am {berlinDate(history[0].usedAtUtc)}</strong>
in <a href={resolve('/sendungen/[id]', { id: history[0].showId })}>{history[0].showTitle}</a>
{#if history.length > 1}<span>+ {history.length - 1} weitere</span>{/if}
</p>
{#if history.length > 1}
<details>
<summary>Vollständige Verwendungshistorie</summary>
<ul>
{#each history as usage (usage.usageId)}
<li>
<time datetime={usage.usedAtUtc}>{berlinDate(usage.usedAtUtc)}</time>
<a href={resolve('/sendungen/[id]', { id: usage.showId })}>{usage.showTitle}</a>
</li>
{/each}
</ul>
</details>
{/if}
</div>
{/if}
@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PresentationContent } from '#lib/server/presentation.js';
let {
content,
imageSrc = ''
}: {
content: PresentationContent & {
sources?: { label: string; url: string }[];
images?: { id: string; altText: string }[];
};
imageSrc?: string;
} = $props();
let frame = $state<HTMLIFrameElement>();
let playerUnavailable = $state(false);
let imageUnavailable = $state(false);
onMount(() => {
const receive = (event: MessageEvent) => {
if (content.provider !== 'tiktok' || event.origin !== 'https://www.tiktok.com') return;
if (event.source !== frame?.contentWindow || typeof event.data !== 'object' || !event.data)
return;
const message = event.data as { type?: string; value?: unknown };
if (message.type === 'onPlayerReady' && content.source?.timecodeSeconds) {
frame.contentWindow?.postMessage(
{
type: 'seekTo',
value: content.source.timecodeSeconds,
'x-tiktok-player': true
},
'https://www.tiktok.com'
);
}
if (message.type === 'onPlayerError') playerUnavailable = true;
};
addEventListener('message', receive);
return () => removeEventListener('message', receive);
});
</script>
{#if content.mode === 'text'}
<article class="output-content output-text">
<p class="output-kicker">heinzlive</p>
<h1>{content.title}</h1>
<!-- HTML is produced exclusively by the server-side allowlist sanitizer. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<div class="output-copy">{@html content.html}</div>
{#if content.images?.length}<div class="output-images">
{#each content.images as image (image.id)}
<img src={`${imageSrc}/${image.id}`} alt={image.altText} />
{/each}
</div>{/if}
{#if content.sources?.length}<footer class="output-sources">
<span>Quellen</span>
{#each content.sources as source, index (`${index}-${source.url}`)}
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={source.url} target="_blank" rel="noopener noreferrer">{source.label}</a>
{/each}
</footer>{/if}
</article>
{:else if content.mode === 'link'}
<article class="output-content output-link-card">
{#if imageSrc && !imageUnavailable}<div class="output-link-image">
<img src={imageSrc} alt="" onerror={() => (imageUnavailable = true)} />
</div>{/if}
<div class="output-link-copy">
<p class="output-kicker">{content.siteName || content.source?.label || 'Webseite'}</p>
<h1>{content.previewTitle || content.title}</h1>
{#if content.description}<p>{content.description}</p>{/if}
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
>Original öffnen</a
>{/if}
</div>
</article>
{:else}
<article class:portrait={content.provider === 'tiktok'} class="output-content output-video">
<header>
<p class="output-kicker">{content.siteName || content.source?.label || 'Video'}</p>
<h1>{content.previewTitle || content.title}</h1>
</header>
{#if content.embedUrl && !playerUnavailable}
<div class="output-player">
<iframe
bind:this={frame}
title={content.previewTitle || content.title}
src={content.embedUrl}
allow="encrypted-media; fullscreen; picture-in-picture"
allowfullscreen
onerror={() => (playerUnavailable = true)}
></iframe>
</div>
{:else}
<section class="output-fallback">
<strong>Video kann nicht eingebettet werden</strong>
<p>Der Anbieter oder diese URL ist für die eingebettete Wiedergabe nicht verfügbar.</p>
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
{#if content.source}<a href={content.source.url} target="_blank" rel="noopener noreferrer"
>Original öffnen</a
>{/if}
</section>
{/if}
{#if content.source}<footer class="output-video-source">
<!-- The URL is a server-validated external HTTP(S) source. -->
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
<a href={content.source.url} target="_blank" rel="noopener noreferrer">Original öffnen</a>
</footer>{/if}
</article>
{/if}
+228
View File
@@ -0,0 +1,228 @@
<script lang="ts">
import { enhance, type SubmitFunction } from '$app/forms';
import { untrack } from 'svelte';
import NavIcon from './NavIcon.svelte';
import type { PieceUsage } from '#lib/server/shows.js';
let {
usage,
revision,
sectionId,
sections,
error,
enhanceCommand
}: {
usage: PieceUsage;
revision: number;
sectionId: string;
sections: { id: string; name: string }[];
error?: string;
enhanceCommand: SubmitFunction;
} = $props();
let dialog: HTMLDialogElement;
let presentationMode = $state(untrack(() => usage.presentationMode));
function durationInMinutes(seconds: number) {
return Number((seconds / 60).toFixed(2));
}
</script>
<button
class="usage-edit-button"
type="button"
aria-label={`${usage.title} bearbeiten und verschieben`}
title="Bearbeiten und verschieben"
onclick={() => dialog.showModal()}
>
<NavIcon name="edit" />
</button>
<dialog
bind:this={dialog}
class="usage-editor-dialog"
aria-labelledby={`usage-editor-title-${usage.id}`}
onclick={(event) => event.target === dialog && dialog.close()}
>
<header>
<div>
<p class="eyebrow">Sendungsspezifische Kopie</p>
<h2 id={`usage-editor-title-${usage.id}`}>{usage.title}</h2>
</div>
<form method="dialog">
<button class="dialog-close" type="submit" aria-label="Editor schließen">×</button>
</form>
</header>
<div class="usage-dialog-scroll">
{#if error}<div class="form-message error usage-dialog-message" role="alert">{error}</div>{/if}
<form
use:enhance={enhanceCommand}
method="POST"
action="?/updateUsage"
class="usage-dialog-editor"
>
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<section class="editor-section">
<div class="section-title">
<p class="eyebrow">Grundlagen</p>
<h3>Titel, Dauer und Notizen</h3>
</div>
<div class="editor-fields two-columns">
<label class="wide-field"
>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="3" maxlength="50000"
>{usage.ownNotes}</textarea
></label
>
</div>
</section>
<section class="editor-section markdown-section">
<div class="section-title">
<p class="eyebrow">Redaktion</p>
<h3>Getrennte Arbeitsbereiche</h3>
</div>
<div class="markdown-grid">
<label
><span>Einleitung</span><textarea name="intro" rows="7" maxlength="50000"
>{usage.intro}</textarea
></label
>
<label class="private-field"
><span>Private Recherche</span><textarea name="research" rows="7" maxlength="50000"
>{usage.research}</textarea
></label
>
<label
><span>Moderationsstichpunkte</span><textarea
name="moderation"
rows="7"
maxlength="50000">{usage.moderation}</textarea
></label
>
<label
><span>Überleitung</span><textarea name="transition" rows="7" maxlength="50000"
>{usage.transition}</textarea
></label
>
<label class="wide-field"
><span>Präsentationstext</span><textarea name="presentation" rows="7" maxlength="50000"
>{usage.presentation}</textarea
></label
>
</div>
<div class="presentation-settings">
<label
>Darstellung<select name="presentationMode" bind:value={presentationMode}>
<option value="text">Text</option>
<option value="link">Linkvorschau</option>
<option value="video">Video</option>
</select></label
>
{#if presentationMode !== 'text'}
<input
type="hidden"
name="presentationSourceLabel"
value={usage.presentationSourceLabel}
/>
<input type="hidden" name="presentationSourceUrl" value={usage.presentationSourceUrl} />
<input
type="hidden"
name="presentationSourceTimecodeSeconds"
value={usage.presentationSourceTimecodeSeconds ?? ''}
/>
<input type="hidden" name="presentationSiteName" value={usage.presentationSiteName} />
<div class="presentation-source-summary wide-field">
<span>Ausgewählte Quelle</span>
<strong
>{usage.presentationSourceLabel ||
usage.presentationSourceUrl ||
'Keine Quelle'}</strong
>
{#if usage.presentationSourceUrl}<small>{usage.presentationSourceUrl}</small>{/if}
</div>
<label
>Titel<input
name="presentationTitle"
maxlength="300"
value={usage.presentationTitle}
/></label
>
<label
>Bild-URL<input
name="presentationImageUrl"
type="url"
maxlength="2000"
value={usage.presentationImageUrl}
/></label
>
<label class="wide-field"
>Anreißer<textarea name="presentationDescription" rows="3" maxlength="1000"
>{usage.presentationDescription}</textarea
></label
>
{/if}
</div>
</section>
<button class="primary-button" type="submit">Verwendung speichern</button>
</form>
<section class="usage-dialog-actions" aria-label="Weitere Aktionen">
<div>
<form use:enhance={enhanceCommand} method="POST" action="?/refreshUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit" disabled={!usage.originalPieceId}
>Aus Fundus aktualisieren</button
>
</form>
<small
>Übernimmt Titel, Dauer und Redaktionstexte. Eigene Notizen und Live-Daten bleiben.</small
>
</div>
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
<input type="hidden" name="revision" value={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="secondary-button" type="submit">Duplizieren</button>
</form>
<form use:enhance={enhanceCommand} method="POST" action="?/moveUsage">
<input type="hidden" name="revision" value={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 sections as target (target.id)}
<option value={target.id} selected={target.id === sectionId}>{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={revision} />
<input type="hidden" name="usageId" value={usage.id} />
<button class="text-button danger-text" type="submit">Entfernen</button>
</form>
</section>
</div>
</dialog>