Release 0.8.0
This commit is contained in:
1 parent
21c0de7dd9
commit
07f2c4607b
47 files changed
+5314
-2303
No files matched your search
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve -- all links are closed local routes assembled below */
|
||||
import { onMount, tick } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
filters,
|
||||
counts,
|
||||
topics
|
||||
}: {
|
||||
filters: { query: string; status: string; topicId: string; favorites: boolean; view: string };
|
||||
counts: {
|
||||
all: number;
|
||||
inbox: number;
|
||||
preparing: number;
|
||||
ready: number;
|
||||
favorites: number;
|
||||
completed: number;
|
||||
archived: number;
|
||||
deleted: number;
|
||||
topics: Record<string, number>;
|
||||
};
|
||||
topics: { id: string; name: string; iconName: string }[];
|
||||
} = $props();
|
||||
let collapsed = $state(false);
|
||||
let searchInput = $state<HTMLInputElement>();
|
||||
|
||||
onMount(() => {
|
||||
collapsed = localStorage.getItem('sendewerk-pieces-navigation') === 'collapsed';
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
collapsed = !collapsed;
|
||||
localStorage.setItem('sendewerk-pieces-navigation', collapsed ? 'collapsed' : 'open');
|
||||
}
|
||||
|
||||
async function revealSearch() {
|
||||
if (collapsed) toggle();
|
||||
await tick();
|
||||
searchInput?.focus();
|
||||
}
|
||||
|
||||
const fundusLinks = $derived([
|
||||
{
|
||||
href: '/fundus',
|
||||
label: 'Alle Pieces',
|
||||
icon: 'library',
|
||||
count: counts.all,
|
||||
active: !filters.status && !filters.favorites && !filters.view
|
||||
},
|
||||
{ href: '/eingang', label: 'Eingang', icon: 'inbox', count: counts.inbox, active: false },
|
||||
{
|
||||
href: '/fundus?view=completed',
|
||||
label: 'Abgeschlossen',
|
||||
icon: 'circle-check',
|
||||
count: counts.completed,
|
||||
active: filters.view === 'completed'
|
||||
},
|
||||
{
|
||||
href: '/fundus?status=preparing',
|
||||
label: 'In Vorbereitung',
|
||||
icon: 'pen',
|
||||
count: counts.preparing,
|
||||
active: filters.status === 'preparing'
|
||||
},
|
||||
{
|
||||
href: '/fundus?status=ready',
|
||||
label: 'Sendefertig',
|
||||
icon: 'circle-check',
|
||||
count: counts.ready,
|
||||
active: filters.status === 'ready'
|
||||
},
|
||||
{
|
||||
href: '/fundus?favorites=1',
|
||||
label: 'Favoriten',
|
||||
icon: 'star',
|
||||
count: counts.favorites,
|
||||
active: filters.favorites
|
||||
},
|
||||
{
|
||||
href: '/archiv',
|
||||
label: 'Archiv',
|
||||
icon: 'box-archive',
|
||||
count: counts.archived,
|
||||
active: false
|
||||
},
|
||||
{
|
||||
href: '/archiv#deleted-pieces-title',
|
||||
label: 'Papierkorb',
|
||||
icon: 'trash',
|
||||
count: counts.deleted,
|
||||
active: false
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<aside class="fundus-navigation" class:collapsed aria-label="Pieces-Navigation">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Bibliothek</p>
|
||||
<h1>Pieces</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label={collapsed ? 'Pieces-Navigation ausklappen' : 'Pieces-Navigation einklappen'}
|
||||
aria-expanded={!collapsed}
|
||||
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
|
||||
>
|
||||
</header>
|
||||
{#if collapsed}
|
||||
<button
|
||||
class="collapsed-search-button"
|
||||
type="button"
|
||||
onclick={revealSearch}
|
||||
aria-label="Pieces durchsuchen"><AppIcon name="magnifying-glass" /></button
|
||||
>
|
||||
{:else}
|
||||
<form class="context-search" method="GET" action="/fundus" role="search">
|
||||
{#if filters.status}<input type="hidden" name="status" value={filters.status} />{/if}
|
||||
{#if filters.topicId}<input type="hidden" name="topic" value={filters.topicId} />{/if}
|
||||
{#if filters.favorites}<input type="hidden" name="favorites" value="1" />{/if}
|
||||
{#if filters.view}<input type="hidden" name="view" value={filters.view} />{/if}
|
||||
<label
|
||||
><AppIcon name="magnifying-glass" /><span class="sr-only">Pieces durchsuchen</span><input
|
||||
bind:this={searchInput}
|
||||
name="q"
|
||||
type="search"
|
||||
value={filters.query}
|
||||
placeholder="Pieces durchsuchen"
|
||||
/></label
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
<nav>
|
||||
{#each fundusLinks as item (item.href)}
|
||||
<a
|
||||
href={item.href}
|
||||
class:active={item.active}
|
||||
aria-current={item.active ? 'page' : undefined}
|
||||
>
|
||||
<AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if topics.length}
|
||||
<section>
|
||||
<h2>Themen</h2>
|
||||
<nav aria-label="Themenfilter">
|
||||
{#each topics as topic (topic.id)}
|
||||
<a
|
||||
href={`/fundus?topic=${encodeURIComponent(topic.id)}`}
|
||||
class:active={filters.topicId === topic.id}
|
||||
>
|
||||
<AppIcon name={topic.iconName || 'tag'} /><span>{topic.name}</span><strong
|
||||
>{counts.topics[topic.id] ?? 0}</strong
|
||||
>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -1,85 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
value = $bindable(),
|
||||
rows = 4,
|
||||
placeholder = '',
|
||||
privateField = false,
|
||||
form
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
value: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
privateField?: boolean;
|
||||
form?: string;
|
||||
} = $props();
|
||||
|
||||
let textarea: HTMLTextAreaElement;
|
||||
|
||||
async function replaceSelection(before: string, after = before, fallback = 'Text') {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = value.slice(start, end) || fallback;
|
||||
value = `${value.slice(0, start)}${before}${selected}${after}${value.slice(end)}`;
|
||||
await tick();
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
async function addList() {
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const selected = value.slice(start, end) || 'Stichpunkt';
|
||||
const replacement = selected
|
||||
.split('\n')
|
||||
.map((line) => `- ${line}`)
|
||||
.join('\n');
|
||||
value = `${value.slice(0, start)}${replacement}${value.slice(end)}`;
|
||||
await tick();
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + 2, start + replacement.length);
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class:private-field={privateField} class="markdown-editor-field">
|
||||
<div class="markdown-editor-heading">
|
||||
<span
|
||||
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
|
||||
>
|
||||
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
|
||||
<button type="button" aria-label="Fett" title="Fett" onclick={() => replaceSelection('**')}
|
||||
><strong>B</strong></button
|
||||
>
|
||||
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
|
||||
><em>I</em></button
|
||||
>
|
||||
<button type="button" aria-label="Liste" title="Liste" onclick={addList}
|
||||
><AppIcon name="list-check" /> Liste</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Link"
|
||||
title="Link"
|
||||
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
|
||||
><AppIcon name="link" /> Link</button
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
aria-label={label}
|
||||
{name}
|
||||
{rows}
|
||||
{placeholder}
|
||||
{form}
|
||||
maxlength="50000"
|
||||
bind:value></textarea>
|
||||
</div>
|
||||
@@ -6,6 +6,8 @@
|
||||
labelledby,
|
||||
activeSection = $bindable(),
|
||||
onrequestclose,
|
||||
inline = false,
|
||||
sidePanel = false,
|
||||
header,
|
||||
children,
|
||||
footer
|
||||
@@ -13,12 +15,14 @@
|
||||
labelledby: string;
|
||||
activeSection: 'script' | 'research' | 'presentation';
|
||||
onrequestclose: () => void;
|
||||
inline?: boolean;
|
||||
sidePanel?: boolean;
|
||||
header: Snippet;
|
||||
children: Snippet;
|
||||
footer: Snippet;
|
||||
} = $props();
|
||||
|
||||
let dialog: HTMLDialogElement;
|
||||
let dialog = $state<HTMLDialogElement>();
|
||||
let workspace: HTMLDivElement;
|
||||
const scrollPositions = { script: 0, research: 0, presentation: 0 };
|
||||
|
||||
@@ -30,24 +34,18 @@
|
||||
}
|
||||
|
||||
export function showModal() {
|
||||
if (!dialog.open) dialog.showModal();
|
||||
if (!inline && dialog && !dialog.open) {
|
||||
if (sidePanel && window.matchMedia('(min-width: 52.01rem)').matches) dialog.show();
|
||||
else dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
export function close() {
|
||||
dialog.close();
|
||||
if (!inline) dialog?.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
class="piece-editor-dialog"
|
||||
aria-labelledby={labelledby}
|
||||
oncancel={(event) => {
|
||||
event.preventDefault();
|
||||
onrequestclose();
|
||||
}}
|
||||
onclick={(event) => event.target === dialog && onrequestclose()}
|
||||
>
|
||||
{#snippet editorChrome()}
|
||||
<header class="piece-dialog-header">
|
||||
<div class="piece-dialog-heading">{@render header()}</div>
|
||||
<button
|
||||
@@ -57,26 +55,48 @@
|
||||
onclick={onrequestclose}><AppIcon name="xmark" /></button
|
||||
>
|
||||
</header>
|
||||
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'script'}
|
||||
aria-current={activeSection === 'script' ? 'page' : undefined}
|
||||
onclick={() => selectSection('script')}>Sendungstext</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'research'}
|
||||
aria-current={activeSection === 'research' ? 'page' : undefined}
|
||||
onclick={() => selectSection('research')}>Recherche & Quellen</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'presentation'}
|
||||
aria-current={activeSection === 'presentation' ? 'page' : undefined}
|
||||
onclick={() => selectSection('presentation')}>Präsentation</button
|
||||
>
|
||||
</nav>
|
||||
{#if !inline}
|
||||
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'script'}
|
||||
aria-current={activeSection === 'script' ? 'page' : undefined}
|
||||
onclick={() => selectSection('script')}>Streamtext</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'research'}
|
||||
aria-current={activeSection === 'research' ? 'page' : undefined}
|
||||
onclick={() => selectSection('research')}>Recherche & Quellen</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeSection === 'presentation'}
|
||||
aria-current={activeSection === 'presentation' ? 'page' : undefined}
|
||||
onclick={() => selectSection('presentation')}>Präsentation</button
|
||||
>
|
||||
</nav>
|
||||
{/if}
|
||||
<div bind:this={workspace} class="piece-dialog-workspace">{@render children()}</div>
|
||||
<footer class="piece-dialog-footer">{@render footer()}</footer>
|
||||
</dialog>
|
||||
{/snippet}
|
||||
|
||||
{#if inline}
|
||||
<section class="piece-editor-dialog fundus-editor-surface" aria-labelledby={labelledby}>
|
||||
{@render editorChrome()}
|
||||
</section>
|
||||
{:else}
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
class="piece-editor-dialog"
|
||||
class:piece-editor-side-panel={sidePanel}
|
||||
aria-labelledby={labelledby}
|
||||
oncancel={(event) => {
|
||||
event.preventDefault();
|
||||
onrequestclose();
|
||||
}}
|
||||
onclick={(event) => event.target === dialog && onrequestclose()}
|
||||
>
|
||||
{@render editorChrome()}
|
||||
</dialog>
|
||||
{/if}
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { Markdown } from '@tiptap/markdown';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { onMount } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
name,
|
||||
label,
|
||||
value = $bindable(),
|
||||
rows = 4,
|
||||
placeholder = '',
|
||||
privateField = false,
|
||||
form
|
||||
}: {
|
||||
name: string;
|
||||
label: string;
|
||||
value: string;
|
||||
rows?: number;
|
||||
placeholder?: string;
|
||||
privateField?: boolean;
|
||||
form?: string;
|
||||
} = $props();
|
||||
|
||||
let element: HTMLDivElement;
|
||||
let valueInput: HTMLInputElement;
|
||||
let editor = $state<Editor>();
|
||||
let sourceMode = $state(false);
|
||||
let sourceWarning = $state(false);
|
||||
let transaction = $state(0);
|
||||
|
||||
function announceChange(markdown: string) {
|
||||
value = markdown;
|
||||
valueInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function command(run: (chain: ReturnType<Editor['chain']>) => boolean) {
|
||||
if (!editor) return;
|
||||
run(editor.chain().focus());
|
||||
}
|
||||
|
||||
function active(name: string, attributes?: Record<string, unknown>) {
|
||||
return transaction >= 0 && (editor?.isActive(name, attributes) ?? false);
|
||||
}
|
||||
|
||||
function showRichText() {
|
||||
if (!editor) return;
|
||||
editor.commands.setContent(value, { contentType: 'markdown', emitUpdate: false });
|
||||
sourceMode = false;
|
||||
}
|
||||
|
||||
function editLink() {
|
||||
if (!editor) return;
|
||||
const previous = String(editor.getAttributes('link').href ?? '');
|
||||
const href = window.prompt('Linkadresse', previous || 'https://');
|
||||
if (href === null) return;
|
||||
if (!href.trim()) editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
else editor.chain().focus().extendMarkRange('link').setLink({ href: href.trim() }).run();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element,
|
||||
extensions: [StarterKit, Markdown],
|
||||
content: value,
|
||||
contentType: 'markdown',
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'rich-markdown-content',
|
||||
'aria-label': label,
|
||||
'data-placeholder': placeholder
|
||||
}
|
||||
},
|
||||
onUpdate: ({ editor: current }) => announceChange(current.getMarkdown()),
|
||||
onTransaction: () => (transaction += 1)
|
||||
});
|
||||
const roundTrip = editor.getMarkdown().trimEnd();
|
||||
if (value.trimEnd() !== roundTrip) {
|
||||
sourceWarning = true;
|
||||
sourceMode = true;
|
||||
}
|
||||
return () => editor?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class:private-field={privateField} class="markdown-editor-field rich-markdown-field">
|
||||
<input bind:this={valueInput} type="hidden" {name} {form} {value} />
|
||||
<div class="markdown-editor-heading">
|
||||
<span
|
||||
>{label}{#if privateField}<small>Nur intern</small>{/if}</span
|
||||
>
|
||||
<span class="markdown-toolbar" role="toolbar" aria-label={`${label} formatieren`}>
|
||||
{#if !sourceMode}
|
||||
<button
|
||||
class:active={active('bold')}
|
||||
type="button"
|
||||
aria-label="Fett"
|
||||
onclick={() => command((chain) => chain.toggleBold().run())}><strong>B</strong></button
|
||||
>
|
||||
<button
|
||||
class:active={active('italic')}
|
||||
type="button"
|
||||
aria-label="Kursiv"
|
||||
onclick={() => command((chain) => chain.toggleItalic().run())}><em>I</em></button
|
||||
>
|
||||
<button
|
||||
class:active={active('bulletList')}
|
||||
type="button"
|
||||
aria-label="Liste"
|
||||
onclick={() => command((chain) => chain.toggleBulletList().run())}
|
||||
><AppIcon name="list-check" /></button
|
||||
>
|
||||
<button
|
||||
class:active={active('heading', { level: 2 })}
|
||||
type="button"
|
||||
aria-label="Zwischenüberschrift"
|
||||
onclick={() => command((chain) => chain.toggleHeading({ level: 2 }).run())}>H2</button
|
||||
>
|
||||
<button class:active={active('link')} type="button" aria-label="Link" onclick={editLink}
|
||||
><AppIcon name="link" /></button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class:active={sourceMode}
|
||||
onclick={() => (sourceMode ? showRichText() : (sourceMode = true))}
|
||||
>{sourceMode ? 'Visuell' : 'Markdown'}</button
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
{#if sourceWarning}<p class="markdown-source-warning">
|
||||
Dieser Text enthält Markdown, das visuell nicht verlustfrei abgebildet werden kann. Die
|
||||
Quellansicht bleibt deshalb aktiv.
|
||||
</p>{/if}
|
||||
<textarea
|
||||
hidden={!sourceMode}
|
||||
aria-label={`${label}, Markdown-Quelltext`}
|
||||
rows={Math.max(rows, 6)}
|
||||
maxlength="50000"
|
||||
bind:value></textarea>
|
||||
<div hidden={sourceMode} class="rich-markdown-surface" bind:this={element}></div>
|
||||
</div>
|
||||
@@ -123,7 +123,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="catalog-note">
|
||||
Sendungen gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
|
||||
Streams gezielt als widerrufbaren Leselink mit wählbarem Informationsumfang teilen.
|
||||
</p>
|
||||
</section>
|
||||
{:else}
|
||||
@@ -153,7 +153,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="share-intro">
|
||||
Der Gast sieht nur diese Sendung und den gewählten Informationsumfang. Der vollständige Link
|
||||
Der Gast sieht nur diesen Stream 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
|
||||
@@ -187,8 +187,8 @@
|
||||
<input type="hidden" name="showId" value={fixedShowId} />
|
||||
{:else}
|
||||
<label
|
||||
>Sendung<select name="showId" required>
|
||||
<option value="">Sendung wählen</option>
|
||||
>Stream<select name="showId" required>
|
||||
<option value="">Stream wählen</option>
|
||||
{#each shows as show (show.id)}<option value={show.id}
|
||||
>{show.title} · {statusLabel(show.status)}</option
|
||||
>{/each}
|
||||
@@ -317,7 +317,7 @@
|
||||
</details>
|
||||
{/each}
|
||||
{#if !shares.some((share) => share.showId === show.id)}
|
||||
<p class="quiet-copy">Für diese Sendung gibt es noch keine Lesefreigabe.</p>
|
||||
<p class="quiet-copy">Für diesen Stream gibt es noch keine Lesefreigabe.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
/* eslint-disable svelte/no-navigation-without-resolve -- filter links are fixed local routes assembled below */
|
||||
import { onMount, tick } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
query,
|
||||
state: filterState,
|
||||
counts,
|
||||
shows
|
||||
}: {
|
||||
query: string;
|
||||
state: string;
|
||||
counts: Record<string, number>;
|
||||
shows: { id: string; title: string; startsAtUtc: string | null }[];
|
||||
} = $props();
|
||||
let collapsed = $state(false);
|
||||
let searchInput = $state<HTMLInputElement>();
|
||||
|
||||
const links = $derived([
|
||||
{
|
||||
href: '/sendungen',
|
||||
label: 'Alle Streams',
|
||||
icon: 'calendar-days',
|
||||
count: counts.all,
|
||||
active: !filterState
|
||||
},
|
||||
{
|
||||
href: '/sendungen?state=created',
|
||||
label: 'Angelegt',
|
||||
icon: 'file',
|
||||
count: counts.created,
|
||||
active: filterState === 'created'
|
||||
},
|
||||
{
|
||||
href: '/sendungen?state=preparing',
|
||||
label: 'In Vorbereitung',
|
||||
icon: 'pen',
|
||||
count: counts.preparing,
|
||||
active: filterState === 'preparing'
|
||||
},
|
||||
{
|
||||
href: '/sendungen?state=ready',
|
||||
label: 'Sendefertig',
|
||||
icon: 'circle-check',
|
||||
count: counts.ready,
|
||||
active: filterState === 'ready'
|
||||
},
|
||||
{
|
||||
href: '/sendungen?state=completed',
|
||||
label: 'Abgeschlossen',
|
||||
icon: 'box-archive',
|
||||
count: counts.completed,
|
||||
active: filterState === 'completed'
|
||||
}
|
||||
]);
|
||||
|
||||
onMount(() => {
|
||||
collapsed = localStorage.getItem('sendewerk-stream-navigation') === 'collapsed';
|
||||
});
|
||||
|
||||
function toggle() {
|
||||
collapsed = !collapsed;
|
||||
localStorage.setItem('sendewerk-stream-navigation', collapsed ? 'collapsed' : 'open');
|
||||
}
|
||||
|
||||
async function revealSearch() {
|
||||
if (collapsed) toggle();
|
||||
await tick();
|
||||
searchInput?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="fundus-navigation stream-navigation" class:collapsed aria-label="Streams-Navigation">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Planung</p>
|
||||
<h1>Streams</h1>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggle}
|
||||
aria-label={collapsed ? 'Streams-Navigation ausklappen' : 'Streams-Navigation einklappen'}
|
||||
aria-expanded={!collapsed}
|
||||
><AppIcon name={collapsed ? 'chevron-right' : 'chevron-left'} /></button
|
||||
>
|
||||
</header>
|
||||
{#if collapsed}
|
||||
<button
|
||||
class="collapsed-search-button"
|
||||
type="button"
|
||||
onclick={revealSearch}
|
||||
aria-label="Streams durchsuchen"><AppIcon name="magnifying-glass" /></button
|
||||
>
|
||||
{:else}
|
||||
<form class="context-search" method="GET" action={resolve('/sendungen')} role="search">
|
||||
<label
|
||||
><AppIcon name="magnifying-glass" /><span class="sr-only">Streams durchsuchen</span><input
|
||||
bind:this={searchInput}
|
||||
name="q"
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="Streams durchsuchen"
|
||||
/></label
|
||||
>
|
||||
</form>
|
||||
{/if}
|
||||
<nav>
|
||||
{#each links as item (item.href)}
|
||||
<a href={item.href} class:active={item.active} aria-current={item.active ? 'page' : undefined}
|
||||
><AppIcon name={item.icon} /><span>{item.label}</span><strong>{item.count}</strong></a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
{#if shows.length && !collapsed}
|
||||
<section>
|
||||
<h2>Geplant</h2>
|
||||
<nav aria-label="Geplante Streams">
|
||||
{#each shows as show (show.id)}
|
||||
<a href={resolve('/sendungen/[id]', { id: show.id })}
|
||||
><AppIcon name="calendar" /><span>{show.title}</span></a
|
||||
>
|
||||
{/each}
|
||||
</nav>
|
||||
</section>
|
||||
{/if}
|
||||
</aside>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import MarkdownEditorField from './MarkdownEditorField.svelte';
|
||||
import RichMarkdownField from './RichMarkdownField.svelte';
|
||||
import InlineTitleEditor from './InlineTitleEditor.svelte';
|
||||
import NavIcon from './NavIcon.svelte';
|
||||
import PieceEditorDialog from './PieceEditorDialog.svelte';
|
||||
@@ -130,9 +130,10 @@
|
||||
bind:activeSection
|
||||
labelledby={`usage-editor-title-${usage.id}`}
|
||||
onrequestclose={requestClose}
|
||||
sidePanel
|
||||
>
|
||||
{#snippet header()}
|
||||
<p class="eyebrow">Sendungsfassung</p>
|
||||
<p class="eyebrow">Streamfassung</p>
|
||||
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
|
||||
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
|
||||
{/snippet}
|
||||
@@ -153,31 +154,31 @@
|
||||
<div class="piece-panel-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Für die Moderation</p>
|
||||
<h3>Sendungstext</h3>
|
||||
<h3>Streamtext</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="piece-writing-stack">
|
||||
<MarkdownEditorField
|
||||
<RichMarkdownField
|
||||
name="intro"
|
||||
label="Einleitung"
|
||||
placeholder="Wie beginnt dieses Thema in der Sendung?"
|
||||
placeholder="Wie beginnt dieses Thema im Stream?"
|
||||
bind:value={draft.intro}
|
||||
/>
|
||||
<MarkdownEditorField
|
||||
<RichMarkdownField
|
||||
name="moderation"
|
||||
label="Moderationsstichpunkte"
|
||||
placeholder="Argumente, Fragen und Ablaufpunkte"
|
||||
rows={10}
|
||||
bind:value={draft.moderation}
|
||||
/>
|
||||
<MarkdownEditorField
|
||||
<RichMarkdownField
|
||||
name="interaction"
|
||||
label="Interaktion"
|
||||
placeholder="Welche Frage möchtest du dem Chat stellen?"
|
||||
privateField
|
||||
bind:value={draft.interaction}
|
||||
/>
|
||||
<MarkdownEditorField
|
||||
<RichMarkdownField
|
||||
name="transition"
|
||||
label="Überleitung"
|
||||
placeholder="Wie geht es zum nächsten Thema weiter?"
|
||||
@@ -230,7 +231,7 @@
|
||||
<small>Diese Inhalte bleiben innerhalb der Redaktion.</small>
|
||||
</div>
|
||||
<div class="piece-writing-stack">
|
||||
<MarkdownEditorField
|
||||
<RichMarkdownField
|
||||
name="research"
|
||||
label="Private Recherche"
|
||||
placeholder="Hintergrund, Prüfung und offene Fragen"
|
||||
@@ -238,10 +239,10 @@
|
||||
privateField
|
||||
bind:value={draft.research}
|
||||
/>
|
||||
<MarkdownEditorField
|
||||
<RichMarkdownField
|
||||
name="ownNotes"
|
||||
label="Interne Notizen zu dieser Verwendung"
|
||||
placeholder="Absprachen und Hinweise nur für diese Sendung"
|
||||
placeholder="Absprachen und Hinweise nur für diesen Stream"
|
||||
privateField
|
||||
bind:value={draft.ownNotes}
|
||||
/>
|
||||
@@ -260,7 +261,7 @@
|
||||
<p class="eyebrow">Snapshot</p>
|
||||
<h3>Werkzeuge</h3>
|
||||
</div>
|
||||
<small>Name und Start-URL bleiben Teil der Sendungsfassung.</small>
|
||||
<small>Name und Start-URL bleiben Teil der Streamfassung.</small>
|
||||
</div>
|
||||
{#if draft.tools.length}<div class="source-list">
|
||||
{#each draft.tools as tool (tool.id)}<div class="source-row tool-assignment-row">
|
||||
@@ -280,7 +281,7 @@
|
||||
<label>Notiz<input name="toolNote" maxlength="2000" bind:value={tool.note} /></label
|
||||
>
|
||||
</div>{/each}
|
||||
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Sendungsfassung.</p>{/if}
|
||||
</div>{:else}<p class="context-empty">Keine Werkzeuge in dieser Streamfassung.</p>{/if}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -316,7 +317,7 @@
|
||||
bind:value={draft.outputTitle}
|
||||
/></label
|
||||
>
|
||||
<MarkdownEditorField
|
||||
<RichMarkdownField
|
||||
name="presentation"
|
||||
label="Öffentlicher Vorspann"
|
||||
placeholder="Text für die Publikumsausgabe"
|
||||
@@ -438,7 +439,7 @@
|
||||
<input type="hidden" name="revision" value={revision} />
|
||||
<input type="hidden" name="usageId" value={usage.id} />
|
||||
<button class="text-button" type="submit" disabled={!usage.originalPieceId || dirty}
|
||||
>Aus Fundus aktualisieren</button
|
||||
>Aus Piece aktualisieren</button
|
||||
>
|
||||
</form>
|
||||
<form use:enhance={enhanceCommand} method="POST" action="?/duplicateUsage">
|
||||
@@ -470,7 +471,7 @@
|
||||
method="POST"
|
||||
action="?/removeUsage"
|
||||
onsubmit={(event) =>
|
||||
!confirm('Piece aus dieser Sendung entfernen?') && event.preventDefault()}
|
||||
!confirm('Piece aus diesem Stream entfernen?') && event.preventDefault()}
|
||||
>
|
||||
<input type="hidden" name="revision" value={revision} /><input
|
||||
type="hidden"
|
||||
@@ -478,7 +479,7 @@
|
||||
value={usage.id}
|
||||
/>
|
||||
<button class="text-button danger-text" type="submit" disabled={dirty}
|
||||
>Aus Sendung entfernen</button
|
||||
>Aus Stream entfernen</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
type Preference = 'system' | 'light' | 'dark';
|
||||
const preferences: { value: Preference; label: string; icon: string }[] = [
|
||||
{ value: 'light', label: 'Hell', icon: 'sun' },
|
||||
{ value: 'dark', label: 'Dunkel', icon: 'moon' },
|
||||
{ value: 'system', label: 'System', icon: 'display' }
|
||||
];
|
||||
|
||||
let { displayName, role }: { displayName: string; role: 'barkeeper' | 'thekencrew' } = $props();
|
||||
let preference = $state<Preference>('system');
|
||||
|
||||
function apply(next: Preference) {
|
||||
preference = next;
|
||||
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.dataset.theme = next === 'system' ? (dark ? 'dark' : 'light') : next;
|
||||
document.documentElement.dataset.themePreference = next;
|
||||
localStorage.setItem('sendewerk-theme', next);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const stored = localStorage.getItem('sendewerk-theme');
|
||||
preference = preferences.some((item) => item.value === stored)
|
||||
? (stored as Preference)
|
||||
: 'system';
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const followSystem = () => preference === 'system' && apply('system');
|
||||
media.addEventListener('change', followSystem);
|
||||
return () => media.removeEventListener('change', followSystem);
|
||||
});
|
||||
</script>
|
||||
|
||||
<details class="account-menu user-menu">
|
||||
<summary aria-label={`Benutzermenü für ${displayName}`}>
|
||||
<span aria-hidden="true">{displayName.slice(0, 1).toUpperCase()}</span>
|
||||
</summary>
|
||||
<div class="user-menu-popover">
|
||||
<header>
|
||||
<strong>{displayName}</strong>
|
||||
<small>{role === 'barkeeper' ? 'Barkeeper' : 'Thekencrew'}</small>
|
||||
</header>
|
||||
{#if role === 'barkeeper'}
|
||||
<a class="user-menu-link" href={resolve('/verwaltung')}>
|
||||
<AppIcon name="gear" /><span>Einstellungen & Verwaltung</span>
|
||||
</a>
|
||||
{/if}
|
||||
<fieldset>
|
||||
<legend>Erscheinungsbild</legend>
|
||||
<div class="theme-menu-options">
|
||||
{#each preferences as item (item.value)}
|
||||
<button
|
||||
type="button"
|
||||
class:active={preference === item.value}
|
||||
aria-pressed={preference === item.value}
|
||||
onclick={() => apply(item.value)}
|
||||
>
|
||||
<AppIcon name={item.icon} />
|
||||
{item.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<form method="POST" action="/logout">
|
||||
<button class="user-menu-link" type="submit"
|
||||
><AppIcon name="right-from-bracket" /> Abmelden</button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
Reference in new issue
Block a user