256 lines
8.7 KiB
Svelte
256 lines
8.7 KiB
Svelte
<script lang="ts">
|
|
import { resolve } from '$app/paths';
|
|
import { berlinDateKey, buildCalendarDays, formatMonthLabel } from '#lib/calendar.js';
|
|
import { formatDuration } from '#lib/format.js';
|
|
import AppIcon from '#lib/components/AppIcon.svelte';
|
|
import type { PageProps } from './$types';
|
|
|
|
let { data, form }: PageProps = $props();
|
|
let scheduledShows = $derived(data.shows.filter((show) => show.startsAtUtc !== null));
|
|
let undatedShows = $derived(data.shows.filter((show) => show.startsAtUtc === null));
|
|
let calendarDays = $derived(buildCalendarDays(data.selectedMonth, data.todayDate));
|
|
let showsByDate = $derived.by(() => {
|
|
const grouped: Record<string, (typeof data.shows)[number][]> = {};
|
|
for (const show of scheduledShows) {
|
|
const key = berlinDateKey(show.startsAtUtc!);
|
|
const shows = grouped[key] ?? [];
|
|
shows.push(show);
|
|
grouped[key] = shows;
|
|
}
|
|
for (const shows of Object.values(grouped)) {
|
|
shows.sort((left, right) => left.startsAtUtc!.localeCompare(right.startsAtUtc!));
|
|
}
|
|
return grouped;
|
|
});
|
|
let agendaDays = $derived(
|
|
calendarDays.filter((day) => day.inSelectedMonth && (showsByDate[day.date]?.length ?? 0) > 0)
|
|
);
|
|
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
|
timeZone: 'Europe/Berlin',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
|
|
function berlinDate(value: string | null) {
|
|
if (!value) return 'Termin offen';
|
|
return new Intl.DateTimeFormat('de-DE', {
|
|
timeZone: 'Europe/Berlin',
|
|
dateStyle: 'medium',
|
|
timeStyle: 'short'
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function berlinTime(value: string) {
|
|
return timeFormatter.format(new Date(value));
|
|
}
|
|
|
|
function agendaDate(value: string) {
|
|
return new Intl.DateTimeFormat('de-DE', {
|
|
timeZone: 'UTC',
|
|
weekday: 'long',
|
|
day: '2-digit',
|
|
month: 'long'
|
|
}).format(new Date(`${value}T12:00:00Z`));
|
|
}
|
|
|
|
function statusLabel(status: string) {
|
|
if (status === 'ready') return 'Bereit';
|
|
if (status === 'draft') return 'Entwurf';
|
|
if (status === 'live') return 'Live';
|
|
return 'Abgeschlossen';
|
|
}
|
|
</script>
|
|
|
|
<svelte:head><title>Sendungen | Sendewerk</title></svelte:head>
|
|
|
|
<div class="page-wrap shows-page">
|
|
<header class="page-heading shows-heading">
|
|
<div>
|
|
<h1>Sendungen</h1>
|
|
<p class="page-intro">Streams aus Fundus-Pieces und freien Abschnitten zusammenstellen.</p>
|
|
</div>
|
|
<form method="POST" action="?/setView" class="show-view-toggle" aria-label="Darstellung">
|
|
<input type="hidden" name="month" value={data.selectedMonth} />
|
|
<button type="submit" name="view" value="list" aria-pressed={data.view === 'list'}>
|
|
<AppIcon name="list" /> Liste
|
|
</button>
|
|
<button type="submit" name="view" value="calendar" aria-pressed={data.view === 'calendar'}>
|
|
<AppIcon name="calendar" /> Kalender
|
|
</button>
|
|
</form>
|
|
</header>
|
|
|
|
{#if form?.error}<div class="form-message error" role="alert">{form.error}</div>{/if}
|
|
|
|
<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>
|
|
<form method="POST" action="?/create" class="show-create-form">
|
|
<label
|
|
>Titel<input
|
|
name="title"
|
|
required
|
|
maxlength="200"
|
|
value={form?.title ?? ''}
|
|
placeholder="Nächste heinzlive-Sendung"
|
|
/></label
|
|
>
|
|
<label
|
|
>Termin<input name="startsAt" type="datetime-local" value={form?.startsAt ?? ''} /></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>
|
|
</details>
|
|
|
|
<section class="show-library" aria-labelledby="show-library-title">
|
|
<header>
|
|
<h2 id="show-library-title">{data.view === 'list' ? 'Geplante Sendungen' : 'Kalender'}</h2>
|
|
<span>{data.shows.length}</span>
|
|
</header>
|
|
|
|
{#if data.view === 'list'}
|
|
{#if scheduledShows.length}<div class="show-card-list">
|
|
{#each scheduledShows as show (show.id)}<a
|
|
class="show-card"
|
|
href={resolve('/sendungen/[id]', { id: show.id })}
|
|
>
|
|
<div>
|
|
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
|
|
<h3>{show.title}</h3>
|
|
<p>{berlinDate(show.startsAtUtc)} Uhr</p>
|
|
</div>
|
|
<div class="show-card-facts">
|
|
{#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 compact">
|
|
<h2>Keine terminierten Sendungen</h2>
|
|
<p>Sendungen ohne Termin bleiben unten erreichbar.</p>
|
|
</div>{/if}
|
|
{:else}
|
|
<div class="show-month-toolbar">
|
|
<div>
|
|
<p class="eyebrow">Monatsansicht</p>
|
|
<h3>{formatMonthLabel(data.selectedMonth)}</h3>
|
|
</div>
|
|
<!-- Query-only navigation stays on the resolved Sendungen route. -->
|
|
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
|
<nav aria-label="Kalendermonat" class="show-month-navigation">
|
|
<a
|
|
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.previousMonth)}`}
|
|
aria-label="Vorheriger Monat"><AppIcon name="chevron-left" /></a
|
|
>
|
|
<a href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.todayMonth)}`}>Heute</a
|
|
>
|
|
<a
|
|
href={`${resolve('/sendungen')}?month=${encodeURIComponent(data.nextMonth)}`}
|
|
aria-label="Nächster Monat"><AppIcon name="chevron-right" /></a
|
|
>
|
|
</nav>
|
|
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
|
</div>
|
|
|
|
<div class="show-calendar" aria-label={formatMonthLabel(data.selectedMonth)}>
|
|
<div class="show-calendar-weekdays" aria-hidden="true">
|
|
{#each ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as weekday (weekday)}<span
|
|
>{weekday}</span
|
|
>{/each}
|
|
</div>
|
|
<div class="show-calendar-grid">
|
|
{#each calendarDays as day (day.date)}<section
|
|
class:outside={!day.inSelectedMonth}
|
|
class:today={day.isToday}
|
|
class="show-calendar-day"
|
|
aria-label={day.date}
|
|
>
|
|
<strong>{day.dayNumber}</strong>
|
|
{#if day.inSelectedMonth}<div class="show-calendar-events">
|
|
{#each showsByDate[day.date] ?? [] as show (show.id)}<a
|
|
href={resolve('/sendungen/[id]', { id: show.id })}
|
|
class={`show-calendar-event ${show.status}`}
|
|
>
|
|
<time>{berlinTime(show.startsAtUtc!)}</time>
|
|
<span>{show.title}</span>
|
|
<small>{statusLabel(show.status)}</small>
|
|
</a>{/each}
|
|
</div>{/if}
|
|
</section>{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="show-agenda" aria-label={`Termine ${formatMonthLabel(data.selectedMonth)}`}>
|
|
{#if agendaDays.length}{#each agendaDays as day (day.date)}<section
|
|
class:today={day.isToday}
|
|
class="show-agenda-day"
|
|
>
|
|
<h3>{agendaDate(day.date)}</h3>
|
|
<div>
|
|
{#each showsByDate[day.date] ?? [] as show (show.id)}<a
|
|
class="show-agenda-event"
|
|
href={resolve('/sendungen/[id]', { id: show.id })}
|
|
>
|
|
<time>{berlinTime(show.startsAtUtc!)}</time>
|
|
<span
|
|
><strong>{show.title}</strong><small>{statusLabel(show.status)}</small></span
|
|
>
|
|
</a>{/each}
|
|
</div>
|
|
</section>{/each}{:else}<div class="empty-state compact">
|
|
<h2>Keine Termine in diesem Monat</h2>
|
|
</div>{/if}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
{#if undatedShows.length}<section
|
|
class="show-library show-undated"
|
|
aria-labelledby="undated-title"
|
|
>
|
|
<header>
|
|
<h2 id="undated-title">Ohne Termin</h2>
|
|
<span>{undatedShows.length}</span>
|
|
</header>
|
|
<div class="show-card-list">
|
|
{#each undatedShows as show (show.id)}<a
|
|
class="show-card"
|
|
href={resolve('/sendungen/[id]', { id: show.id })}
|
|
>
|
|
<div>
|
|
<span class={`show-state ${show.status}`}>{statusLabel(show.status)}</span>
|
|
<h3>{show.title}</h3>
|
|
<p>Termin offen</p>
|
|
</div>
|
|
<div class="show-card-facts">
|
|
<b aria-hidden="true"><AppIcon name="arrow-right" /></b>
|
|
</div>
|
|
</a>{/each}
|
|
</div>
|
|
</section>{/if}
|
|
</div>
|