Release 0.5.10
This commit is contained in:
1 parent
c3033479b5
commit
4fe61971e4
52 files changed
+38565
-466
No files matched your search
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
let { name, label, size = '1em' }: { name: string; label?: string; size?: string } = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
role={label ? 'img' : undefined}
|
||||
aria-label={label}
|
||||
aria-hidden={label ? undefined : 'true'}
|
||||
class="app-icon"
|
||||
>
|
||||
<use href={`/icons/classic-regular.svg#${name || 'circle'}`} />
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.app-icon {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
fill: currentColor;
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
name: string;
|
||||
iconName?: string;
|
||||
description?: string;
|
||||
meta?: string;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
selectedIds,
|
||||
label,
|
||||
fieldName,
|
||||
onselectionchange
|
||||
}: {
|
||||
items: Item[];
|
||||
selectedIds: string[];
|
||||
label: string;
|
||||
fieldName?: string;
|
||||
onselectionchange: (ids: string[]) => void;
|
||||
} = $props();
|
||||
let query = $state('');
|
||||
let menu: HTMLDetailsElement;
|
||||
let filtered = $derived(
|
||||
items.filter((item) =>
|
||||
`${item.name} ${item.description ?? ''} ${item.meta ?? ''}`
|
||||
.toLocaleLowerCase('de')
|
||||
.includes(query.trim().toLocaleLowerCase('de'))
|
||||
)
|
||||
);
|
||||
let selected = $derived(items.filter((item) => selectedIds.includes(item.id)));
|
||||
|
||||
function toggle(id: string) {
|
||||
onselectionchange(
|
||||
selectedIds.includes(id)
|
||||
? selectedIds.filter((selectedId) => selectedId !== id)
|
||||
: [...selectedIds, id]
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="choice-picker">
|
||||
{#if fieldName}
|
||||
{#each selectedIds as id (id)}<input type="hidden" name={fieldName} value={id} />{/each}
|
||||
{/if}
|
||||
{#if selected.length}
|
||||
<div class="choice-picker-selected" aria-label={`${label}: ausgewählt`}>
|
||||
{#each selected as item (item.id)}
|
||||
<span
|
||||
>{#if item.iconName}<AppIcon name={item.iconName} />{/if}{item.name}</span
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<details bind:this={menu} class="choice-picker-menu">
|
||||
<summary>{selected.length ? `${label} ändern` : `${label} auswählen`}</summary>
|
||||
<div class="choice-picker-popover">
|
||||
<label class="choice-picker-search">
|
||||
<span class="sr-only">{label} durchsuchen</span>
|
||||
<AppIcon name="magnifying-glass" />
|
||||
<input type="search" placeholder={`${label} durchsuchen`} bind:value={query} />
|
||||
</label>
|
||||
<div class="choice-picker-options">
|
||||
{#each filtered as item (item.id)}
|
||||
<label class:active={selectedIds.includes(item.id)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onchange={() => toggle(item.id)}
|
||||
/>
|
||||
{#if item.iconName}<AppIcon name={item.iconName} size="1.2em" />{/if}
|
||||
<span
|
||||
><strong>{item.name}</strong>{#if item.description}<small>{item.description}</small
|
||||
>{/if}</span
|
||||
>
|
||||
{#if item.meta}<small>{item.meta}</small>{/if}
|
||||
</label>
|
||||
{:else}<p>Keine Treffer.</p>{/each}
|
||||
</div>
|
||||
<button
|
||||
class="choice-picker-close text-button"
|
||||
type="button"
|
||||
onclick={() => (menu.open = false)}>Auswahl schließen</button
|
||||
>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { classicRegularIcons } from '#lib/icons/classic-regular.js';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let { name = $bindable(), inputName = 'iconName' }: { name: string; inputName?: string } =
|
||||
$props();
|
||||
let query = $state('');
|
||||
let menu: HTMLDetailsElement;
|
||||
let filtered = $derived(
|
||||
classicRegularIcons.filter((icon) =>
|
||||
`${icon.name} ${icon.label} ${icon.terms.join(' ')}`
|
||||
.toLocaleLowerCase('de')
|
||||
.includes(query.trim().toLocaleLowerCase('de'))
|
||||
)
|
||||
);
|
||||
let visible = $derived(filtered.slice(0, 120));
|
||||
</script>
|
||||
|
||||
<div class="icon-picker">
|
||||
<input type="hidden" name={inputName} value={name} />
|
||||
<details bind:this={menu}>
|
||||
<summary><AppIcon name={name || 'circle'} /> Icon auswählen</summary>
|
||||
<div class="icon-picker-popover">
|
||||
<label
|
||||
><span class="sr-only">Icons durchsuchen</span><input
|
||||
type="search"
|
||||
placeholder="Icon suchen"
|
||||
bind:value={query}
|
||||
/></label
|
||||
>
|
||||
<div class="icon-picker-grid">
|
||||
{#each visible as icon (icon.name)}
|
||||
<button
|
||||
type="button"
|
||||
class:active={name === icon.name}
|
||||
title={icon.label}
|
||||
onclick={() => {
|
||||
name = icon.name;
|
||||
menu.open = false;
|
||||
}}><AppIcon name={icon.name} /><span>{icon.label}</span></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{#if filtered.length > visible.length}<p class="icon-picker-hint">
|
||||
{visible.length} von {filtered.length} Icons sichtbar. Suche verwenden, um weitere Icons zu
|
||||
finden.
|
||||
</p>{/if}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
form,
|
||||
name = 'title',
|
||||
label = 'Titel',
|
||||
oncommit
|
||||
}: {
|
||||
value: string;
|
||||
form: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
oncommit?: () => void;
|
||||
} = $props();
|
||||
|
||||
let editing = $state(false);
|
||||
let original = $state('');
|
||||
let input = $state<HTMLInputElement>();
|
||||
let trigger = $state<HTMLButtonElement>();
|
||||
|
||||
async function edit() {
|
||||
original = value;
|
||||
editing = true;
|
||||
await tick();
|
||||
input?.focus();
|
||||
input?.select();
|
||||
}
|
||||
|
||||
async function finish(commit: boolean) {
|
||||
if (!editing) return;
|
||||
if (!commit) value = original;
|
||||
editing = false;
|
||||
if (commit && value !== original) oncommit?.();
|
||||
await tick();
|
||||
trigger?.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="inline-title-editor">
|
||||
<input type="hidden" {form} {name} {value} />
|
||||
{#if editing}
|
||||
<label>
|
||||
<span class="sr-only">{label}</span>
|
||||
<input
|
||||
bind:this={input}
|
||||
bind:value
|
||||
maxlength="200"
|
||||
required
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
void finish(true);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
void finish(false);
|
||||
}
|
||||
}}
|
||||
onblur={() => void finish(true)}
|
||||
/>
|
||||
</label>
|
||||
{:else}
|
||||
<button bind:this={trigger} type="button" class="inline-title-trigger" onclick={edit}>
|
||||
<span>{value}</span><AppIcon name="pen" size="0.9em" />
|
||||
<span class="sr-only">{label} bearbeiten</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
name,
|
||||
@@ -60,12 +61,15 @@
|
||||
<button type="button" aria-label="Kursiv" title="Kursiv" onclick={() => replaceSelection('_')}
|
||||
><em>I</em></button
|
||||
>
|
||||
<button type="button" aria-label="Liste" title="Liste" onclick={addList}>• Liste</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')}>↗ Link</button
|
||||
onclick={() => replaceSelection('[', '](https://)', 'Linktext')}
|
||||
><AppIcon name="link" /> Link</button
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
<script lang="ts">
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let { name }: { name: string } = $props();
|
||||
const aliases: Record<string, string> = {
|
||||
library: 'folder-open',
|
||||
shows: 'calendar-days',
|
||||
archive: 'box-archive',
|
||||
settings: 'gear',
|
||||
edit: 'pen'
|
||||
};
|
||||
let iconName = $derived(aliases[name] ?? name);
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
{#if name === 'inbox'}
|
||||
<path d="M4 5.5h16v13H4zM4 14h4l1.5 2h5l1.5-2h4" />
|
||||
{:else if name === 'library'}
|
||||
<path d="M5 4.5h4v15H5zM10.5 4.5h4v15h-4zM16 5.5l3-1 3.5 14-3 1z" />
|
||||
{:else if name === 'shows'}
|
||||
<path d="M4 6.5h16v13H4zM8 3.5v6M16 3.5v6M4 10h16" />
|
||||
{:else if name === 'archive'}
|
||||
<path d="M4 7h16v13H4zM3 4h18v4H3zM9 12h6" />
|
||||
{:else if name === 'settings'}
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<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>
|
||||
<AppIcon name={iconName} size="1.25rem" />
|
||||
|
||||
<style>
|
||||
svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.7;
|
||||
:global(.app-icon) {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { tick, type Snippet } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
let {
|
||||
labelledby,
|
||||
@@ -53,7 +54,7 @@
|
||||
class="dialog-close"
|
||||
type="button"
|
||||
aria-label="Editor schließen"
|
||||
onclick={onrequestclose}>×</button
|
||||
onclick={onrequestclose}><AppIcon name="xmark" /></button
|
||||
>
|
||||
</header>
|
||||
<nav class="piece-dialog-tabs" aria-label="Editorbereiche">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
|
||||
type Preference = 'system' | 'light' | 'dark';
|
||||
const preferences: Preference[] = ['system', 'light', 'dark'];
|
||||
@@ -42,8 +43,10 @@
|
||||
onclick={cycle}
|
||||
aria-label={`Darstellung: ${labels[preference]}`}
|
||||
>
|
||||
<span class="theme-symbol" aria-hidden="true">
|
||||
{preference === 'system' ? '◐' : preference === 'light' ? '○' : '●'}
|
||||
</span>
|
||||
<span class="theme-symbol" aria-hidden="true"
|
||||
><AppIcon
|
||||
name={preference === 'system' ? 'display' : preference === 'light' ? 'sun' : 'moon'}
|
||||
/></span
|
||||
>
|
||||
<span>{labels[preference]}</span>
|
||||
</button>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import AppIcon from './AppIcon.svelte';
|
||||
import ChoicePicker from './ChoicePicker.svelte';
|
||||
import IconPicker from './IconPicker.svelte';
|
||||
|
||||
type Format = { id: string; name: string };
|
||||
type Tool = {
|
||||
id: string;
|
||||
name: string;
|
||||
startUrl: string;
|
||||
description: string;
|
||||
iconName: string;
|
||||
isActive: boolean;
|
||||
formatIds: string[];
|
||||
};
|
||||
|
||||
let { formats }: { formats: Format[] } = $props();
|
||||
let dialog: HTMLDialogElement;
|
||||
let nameInput: HTMLInputElement;
|
||||
let tool = $state<Tool | null>(null);
|
||||
let name = $state('');
|
||||
let startUrl = $state('');
|
||||
let description = $state('');
|
||||
let iconName = $state('wrench');
|
||||
let isActive = $state(true);
|
||||
let formatIds = $state<string[]>([]);
|
||||
|
||||
export async function showModal(selectedTool: Tool | null = null) {
|
||||
tool = selectedTool;
|
||||
name = selectedTool?.name ?? '';
|
||||
startUrl = selectedTool?.startUrl ?? '';
|
||||
description = selectedTool?.description ?? '';
|
||||
iconName = selectedTool?.iconName || 'wrench';
|
||||
isActive = selectedTool?.isActive ?? true;
|
||||
formatIds = [...(selectedTool?.formatIds ?? [])];
|
||||
dialog.showModal();
|
||||
await tick();
|
||||
nameInput.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
dialog.close();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialog}
|
||||
class="management-dialog tool-editor-dialog"
|
||||
aria-labelledby="tool-dialog-title"
|
||||
onclick={(event) => event.target === dialog && close()}
|
||||
>
|
||||
<header>
|
||||
<div class="management-dialog-title">
|
||||
<span class="management-dialog-icon"><AppIcon name={iconName || 'wrench'} /></span>
|
||||
<div>
|
||||
<p class="eyebrow">{tool ? 'Werkzeug bearbeiten' : 'Werkzeug hinzufügen'}</p>
|
||||
<h2 id="tool-dialog-title">{tool?.name || 'Neues Werkzeug'}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" aria-label="Werkzeugdialog schließen" onclick={close}
|
||||
><AppIcon name="xmark" /></button
|
||||
>
|
||||
</header>
|
||||
<form
|
||||
class="management-dialog-form"
|
||||
method="POST"
|
||||
action={tool ? '?/updateTool' : '?/createTool'}
|
||||
>
|
||||
{#if tool}<input type="hidden" name="toolId" value={tool.id} />{/if}
|
||||
<div class="management-dialog-fields">
|
||||
<label
|
||||
>Name<input
|
||||
bind:this={nameInput}
|
||||
bind:value={name}
|
||||
name="name"
|
||||
required
|
||||
maxlength="120"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Start-URL<input
|
||||
bind:value={startUrl}
|
||||
name="startUrl"
|
||||
type="url"
|
||||
maxlength="2000"
|
||||
placeholder="https://…"
|
||||
/></label
|
||||
>
|
||||
<label class="wide-field"
|
||||
>Beschreibung<textarea bind:value={description} name="description" rows="3" maxlength="2000"
|
||||
></textarea></label
|
||||
>
|
||||
</div>
|
||||
<div class="management-dialog-selections">
|
||||
<div>
|
||||
<span class="field-label">Icon</span>
|
||||
<IconPicker bind:name={iconName} />
|
||||
</div>
|
||||
<div>
|
||||
<span class="field-label">Passende Formate</span>
|
||||
<ChoicePicker
|
||||
items={formats}
|
||||
selectedIds={formatIds}
|
||||
label="Formate"
|
||||
fieldName="formatIds"
|
||||
onselectionchange={(ids) => (formatIds = ids)}
|
||||
/>
|
||||
</div>
|
||||
<label class="toggle-row"
|
||||
><input type="checkbox" name="isActive" value="1" bind:checked={isActive} /><span
|
||||
><strong>Aktiv</strong><small
|
||||
>Deaktivierte Werkzeuge bleiben in bestehenden Pieces erhalten.</small
|
||||
></span
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<footer>
|
||||
<button class="secondary-button" type="button" onclick={close}>Abbrechen</button>
|
||||
<button class="primary-button" type="submit"
|
||||
>{tool ? 'Änderungen speichern' : 'Werkzeug anlegen'}</button
|
||||
>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { enhance, type SubmitFunction } from '$app/forms';
|
||||
import { untrack } from 'svelte';
|
||||
import MarkdownEditorField from './MarkdownEditorField.svelte';
|
||||
import InlineTitleEditor from './InlineTitleEditor.svelte';
|
||||
import NavIcon from './NavIcon.svelte';
|
||||
import PieceEditorDialog from './PieceEditorDialog.svelte';
|
||||
import type { PieceUsage } from '#lib/server/shows.js';
|
||||
@@ -29,6 +30,9 @@
|
||||
untrack(() => ({ ...usage, tools: usage.tools.map((tool) => ({ ...tool })) }))
|
||||
);
|
||||
let dirty = $state(false);
|
||||
let selectedMedia = $derived(
|
||||
draft.media.find((media) => media.id === draft.presentationMediaId) ?? null
|
||||
);
|
||||
|
||||
function durationInMinutes(seconds: number) {
|
||||
return Number((seconds / 60).toFixed(2));
|
||||
@@ -67,18 +71,7 @@
|
||||
>
|
||||
{#snippet header()}
|
||||
<p class="eyebrow">Sendungsfassung</p>
|
||||
<label class="piece-dialog-title">
|
||||
<span class="sr-only">Titel</span>
|
||||
<textarea
|
||||
form={formId}
|
||||
name="title"
|
||||
required
|
||||
rows="1"
|
||||
maxlength="200"
|
||||
aria-label="Titel"
|
||||
bind:value={draft.title}
|
||||
oninput={() => (dirty = true)}></textarea>
|
||||
</label>
|
||||
<InlineTitleEditor form={formId} bind:value={draft.title} oncommit={() => (dirty = true)} />
|
||||
<h2 class="sr-only" id={`usage-editor-title-${usage.id}`}>{draft.title}</h2>
|
||||
{/snippet}
|
||||
|
||||
@@ -250,6 +243,7 @@
|
||||
value={draft.presentationSourceTimecodeSeconds ?? ''}
|
||||
/>
|
||||
<input type="hidden" name="presentationSiteName" value={draft.presentationSiteName} />
|
||||
<input type="hidden" name="presentationMediaId" value={draft.presentationMediaId ?? ''} />
|
||||
{#if draft.presentationMode !== 'text'}
|
||||
<div class="presentation-settings">
|
||||
<div class="presentation-source-summary wide-field">
|
||||
@@ -297,6 +291,33 @@
|
||||
<input type="hidden" name="presentationImageUrl" value={draft.presentationImageUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if draft.media.length}<fieldset class="usage-media-picker">
|
||||
<legend>Hochgeladenes Präsentationsbild</legend>
|
||||
<label
|
||||
><input
|
||||
type="radio"
|
||||
checked={!draft.presentationMediaId}
|
||||
onchange={() => {
|
||||
draft.presentationMediaId = null;
|
||||
dirty = true;
|
||||
}}
|
||||
/> Kein Bild</label
|
||||
>
|
||||
<div class="media-grid">
|
||||
{#each draft.media as media (media.id)}<label class="media-presentation-choice"
|
||||
><input
|
||||
type="radio"
|
||||
checked={draft.presentationMediaId === media.id}
|
||||
onchange={() => {
|
||||
draft.presentationMediaId = media.id;
|
||||
dirty = true;
|
||||
}}
|
||||
/><img src={`/media/${media.originalMediaId}`} alt={media.altText} /><span
|
||||
>Auswählen</span
|
||||
></label
|
||||
>{/each}
|
||||
</div>
|
||||
</fieldset>{/if}
|
||||
<aside class="piece-presentation-preview" aria-label="Präsentationsvorschau">
|
||||
<p class="output-kicker">Vorschau · {draft.presentationMode}</p>
|
||||
<h4>{draft.outputTitle || draft.title}</h4>
|
||||
@@ -304,8 +325,18 @@
|
||||
<p class="preview-markdown-source">
|
||||
{draft.presentation || 'Noch kein öffentlicher Text.'}
|
||||
</p>
|
||||
{#if selectedMedia}<img
|
||||
src={`/media/${selectedMedia.originalMediaId}`}
|
||||
alt={selectedMedia.altText}
|
||||
/>{/if}
|
||||
{:else}
|
||||
{#if draft.presentationImageUrl}<img src={draft.presentationImageUrl} alt="" />{/if}
|
||||
{#if selectedMedia}<img
|
||||
src={`/media/${selectedMedia.originalMediaId}`}
|
||||
alt={selectedMedia.altText}
|
||||
/>{:else if draft.presentationImageUrl}<img
|
||||
src={draft.presentationImageUrl}
|
||||
alt=""
|
||||
/>{/if}
|
||||
<p>
|
||||
{draft.presentationDescription ||
|
||||
draft.presentationSourceUrl ||
|
||||
|
||||
Reference in new issue
Block a user