331 lines
10 KiB
Svelte
331 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { resolve } from '$app/paths';
|
|
import { onMount } from 'svelte';
|
|
import AppIcon from './AppIcon.svelte';
|
|
|
|
type ShareShow = {
|
|
id: string;
|
|
title: string;
|
|
startsAtUtc: string | null;
|
|
status: string;
|
|
};
|
|
|
|
type ShareItem = {
|
|
id: string;
|
|
showId: string;
|
|
showTitle: string;
|
|
showStatus: string;
|
|
showStartsAtUtc: string | null;
|
|
scope: 'rundown' | 'moderation' | 'research';
|
|
isPasswordProtected: boolean;
|
|
expiresAtUtc: string | null;
|
|
revokedAtUtc: string | null;
|
|
createdAtUtc: string;
|
|
};
|
|
|
|
let {
|
|
shows,
|
|
shares,
|
|
fixedShowId = '',
|
|
catalogPanel = false,
|
|
form = null
|
|
}: {
|
|
shows: ShareShow[];
|
|
shares: ShareItem[];
|
|
fixedShowId?: string;
|
|
catalogPanel?: boolean;
|
|
form?: {
|
|
intent?: string;
|
|
error?: string;
|
|
success?: string | boolean;
|
|
shareUrl?: string;
|
|
} | null;
|
|
} = $props();
|
|
|
|
let visibleShows = $derived(
|
|
fixedShowId
|
|
? shows.filter((show) => show.id === fixedShowId)
|
|
: 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
|
|
? new Intl.DateTimeFormat('de-DE', {
|
|
timeZone: 'Europe/Berlin',
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short'
|
|
}).format(new Date(value))
|
|
: fallback;
|
|
}
|
|
|
|
function berlinInput(value: string | null) {
|
|
if (!value) return '';
|
|
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Europe/Berlin',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hourCycle: 'h23'
|
|
}).formatToParts(new Date(value));
|
|
const read = (type: Intl.DateTimeFormatPartTypes) =>
|
|
parts.find((part) => part.type === type)?.value ?? '';
|
|
return `${read('year')}-${read('month')}-${read('day')}T${read('hour')}:${read('minute')}`;
|
|
}
|
|
|
|
function statusLabel(status: string) {
|
|
if (status === 'draft') return 'Entwurf';
|
|
if (status === 'ready') return 'Bereit';
|
|
if (status === 'live') return 'Live';
|
|
return 'Abgeschlossen';
|
|
}
|
|
|
|
function scopeLabel(scope: ShareItem['scope']) {
|
|
if (scope === 'rundown') return 'Nur Ablauf';
|
|
if (scope === 'moderation') return 'Ablauf und Moderation';
|
|
return 'Vollständig mit Recherche';
|
|
}
|
|
|
|
function shareState(share: ShareItem) {
|
|
if (share.revokedAtUtc) return 'Widerrufen';
|
|
if (share.expiresAtUtc && new Date(share.expiresAtUtc) <= new Date()) return 'Abgelaufen';
|
|
return 'Aktiv';
|
|
}
|
|
</script>
|
|
|
|
{#if catalogPanel}
|
|
<section class="quiet-panel share-catalog-panel">
|
|
<div class="panel-heading">
|
|
<div>
|
|
<p class="eyebrow">Gastzugänge</p>
|
|
<h2>Lesefreigaben</h2>
|
|
</div>
|
|
<button
|
|
class="compact-add-button share-dialog-trigger"
|
|
type="button"
|
|
aria-label="Lesefreigaben"
|
|
onclick={() => dialog.showModal()}
|
|
>
|
|
<AppIcon name="link" /> Freigaben verwalten
|
|
</button>
|
|
</div>
|
|
<p class="catalog-note">
|
|
Sendungen gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
|
|
</p>
|
|
</section>
|
|
{:else}
|
|
<button
|
|
class="secondary-button share-dialog-trigger"
|
|
type="button"
|
|
onclick={() => dialog.showModal()}
|
|
>
|
|
<AppIcon name="link" /> Lesefreigaben
|
|
</button>
|
|
{/if}
|
|
<dialog bind:this={dialog} class="share-management-dialog">
|
|
<section class="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>
|
|
<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}
|
|
<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
|
|
>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>
|
|
|
|
{#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();
|
|
}}
|
|
>
|
|
<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>
|
|
</dialog>
|