220 lines
6.6 KiB
Svelte
220 lines
6.6 KiB
Svelte
<script lang="ts">
|
||
/* eslint-disable svelte/no-navigation-without-resolve -- RSS links are validated external URLs. */
|
||
import { onMount, tick } from 'svelte';
|
||
import AppIcon from './AppIcon.svelte';
|
||
import ChoicePicker from './ChoicePicker.svelte';
|
||
|
||
type FeedEntry = {
|
||
id: string;
|
||
feedId: string;
|
||
feedName: string;
|
||
title: string;
|
||
url: string;
|
||
summary: string;
|
||
publishedAtUtc: string | null;
|
||
};
|
||
|
||
let {
|
||
entries,
|
||
categories,
|
||
form = null
|
||
}: {
|
||
entries: FeedEntry[];
|
||
categories: { id: string; name: string }[];
|
||
form?: { intent?: string; feedError?: string } | null;
|
||
} = $props();
|
||
let dialog: HTMLDialogElement;
|
||
let closeButton: HTMLButtonElement;
|
||
let selectedIds = $state<string[]>([]);
|
||
let selectedFeedIds = $state<string[]>([]);
|
||
let categoryIds = $state<string[]>([]);
|
||
let page = $state(1);
|
||
const pageSize = 20;
|
||
let feeds = $derived(
|
||
Array.from(new Map(entries.map((entry) => [entry.feedId, entry.feedName])).entries()).map(
|
||
([id, name]) => ({ id, name })
|
||
)
|
||
);
|
||
let filteredEntries = $derived(entries.filter((entry) => selectedFeedIds.includes(entry.feedId)));
|
||
let pageCount = $derived(Math.max(1, Math.ceil(filteredEntries.length / pageSize)));
|
||
let visibleEntries = $derived(filteredEntries.slice((page - 1) * pageSize, page * pageSize));
|
||
let allSourcesSelected = $derived(feeds.length > 0 && selectedFeedIds.length === feeds.length);
|
||
|
||
onMount(() => {
|
||
selectedFeedIds = feeds.map((feed) => feed.id);
|
||
if (form?.intent === 'feedImport' && form.feedError) void showModal();
|
||
});
|
||
|
||
export async function showModal() {
|
||
if (!selectedFeedIds.length) selectedFeedIds = feeds.map((feed) => feed.id);
|
||
if (!dialog.open) dialog.showModal();
|
||
await tick();
|
||
closeButton.focus();
|
||
}
|
||
|
||
function close() {
|
||
dialog.close();
|
||
}
|
||
|
||
function selectAllSources() {
|
||
selectedFeedIds = feeds.map((feed) => feed.id);
|
||
page = 1;
|
||
}
|
||
|
||
function toggleSource(id: string) {
|
||
selectedFeedIds = selectedFeedIds.includes(id)
|
||
? selectedFeedIds.filter((feedId) => feedId !== id)
|
||
: [...selectedFeedIds, id];
|
||
page = 1;
|
||
}
|
||
|
||
function toggleEntry(id: string) {
|
||
selectedIds = selectedIds.includes(id)
|
||
? selectedIds.filter((entryId) => entryId !== id)
|
||
: [...selectedIds, id];
|
||
}
|
||
|
||
function handleEntryKeydown(event: KeyboardEvent, id: string) {
|
||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||
event.preventDefault();
|
||
toggleEntry(id);
|
||
}
|
||
</script>
|
||
|
||
<dialog
|
||
bind:this={dialog}
|
||
class="management-dialog rss-selection-dialog"
|
||
aria-labelledby="rss-selection-title"
|
||
onclick={(event) => event.target === dialog && close()}
|
||
>
|
||
<header>
|
||
<div class="management-dialog-title">
|
||
<span class="management-dialog-icon"><AppIcon name="rss" /></span>
|
||
<div>
|
||
<p class="eyebrow">RSS-Importeingang</p>
|
||
<h2 id="rss-selection-title">RSS-Beiträge auswählen</h2>
|
||
</div>
|
||
</div>
|
||
<button
|
||
bind:this={closeButton}
|
||
class="dialog-close"
|
||
type="button"
|
||
aria-label="RSS-Auswahl schließen"
|
||
onclick={close}><AppIcon name="xmark" /></button
|
||
>
|
||
</header>
|
||
<form class="rss-selection-form" method="POST" action="?/importFeeds">
|
||
{#each selectedIds as id (id)}<input type="hidden" name="entryIds" value={id} />{/each}
|
||
<div class="rss-selection-content">
|
||
{#if form?.intent === 'feedImport' && form.feedError}<div
|
||
class="form-message error"
|
||
role="alert"
|
||
>
|
||
{form.feedError}
|
||
</div>{/if}
|
||
<nav class="rss-source-filters" aria-label="RSS-Quellen filtern">
|
||
<button
|
||
type="button"
|
||
class:active={allSourcesSelected}
|
||
aria-pressed={allSourcesSelected}
|
||
onclick={selectAllSources}>Alle Quellen</button
|
||
>
|
||
{#each feeds as feed (feed.id)}
|
||
<button
|
||
type="button"
|
||
class:active={selectedFeedIds.includes(feed.id)}
|
||
aria-pressed={selectedFeedIds.includes(feed.id)}
|
||
onclick={() => toggleSource(feed.id)}>{feed.name}</button
|
||
>
|
||
{/each}
|
||
</nav>
|
||
|
||
<div class="rss-result-summary" aria-live="polite">
|
||
<strong>{filteredEntries.length} verfügbare Beiträge</strong>
|
||
<span>Seite {page} von {pageCount}</span>
|
||
</div>
|
||
<div
|
||
class="rss-entry-list"
|
||
role="listbox"
|
||
aria-label="Verfügbare RSS-Beiträge"
|
||
aria-multiselectable="true"
|
||
>
|
||
{#each visibleEntries as entry (entry.id)}
|
||
<div
|
||
class:selected={selectedIds.includes(entry.id)}
|
||
class="rss-entry"
|
||
role="option"
|
||
tabindex="0"
|
||
aria-selected={selectedIds.includes(entry.id)}
|
||
onclick={() => toggleEntry(entry.id)}
|
||
onkeydown={(event) => handleEntryKeydown(event, entry.id)}
|
||
>
|
||
<div class="rss-entry-state" aria-hidden="true">
|
||
<AppIcon name={selectedIds.includes(entry.id) ? 'check' : 'plus'} />
|
||
</div>
|
||
<div class="rss-entry-copy">
|
||
<small
|
||
>{entry.feedName}{entry.publishedAtUtc
|
||
? ` · ${new Date(entry.publishedAtUtc).toLocaleDateString('de-DE')}`
|
||
: ''}</small
|
||
>
|
||
<strong>{entry.title}</strong>
|
||
{#if entry.summary}<p>{entry.summary}</p>{/if}
|
||
<a
|
||
href={entry.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
onclick={(event) => event.stopPropagation()}
|
||
onkeydown={(event) => event.stopPropagation()}>Original öffnen</a
|
||
>
|
||
</div>
|
||
{#if selectedIds.includes(entry.id)}<span class="rss-selected-label">Ausgewählt</span
|
||
>{/if}
|
||
</div>
|
||
{:else}<p class="empty-copy">
|
||
Für diese Quellenauswahl sind keine Beiträge verfügbar.
|
||
</p>{/each}
|
||
</div>
|
||
{#if pageCount > 1}<nav class="rss-pagination" aria-label="RSS-Beitragsseiten">
|
||
<button
|
||
class="secondary-button"
|
||
type="button"
|
||
disabled={page === 1}
|
||
onclick={() => (page -= 1)}>Zurück</button
|
||
>
|
||
<span
|
||
>{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, filteredEntries.length)} von {filteredEntries.length}</span
|
||
>
|
||
<button
|
||
class="secondary-button"
|
||
type="button"
|
||
disabled={page === pageCount}
|
||
onclick={() => (page += 1)}>Weiter</button
|
||
>
|
||
</nav>{/if}
|
||
<div class="rss-category-selection">
|
||
<span class="field-label">Kategorien für die Auswahl</span>
|
||
<small>Ohne Auswahl gilt die Standardkategorie der jeweiligen Quelle.</small>
|
||
<ChoicePicker
|
||
items={categories}
|
||
selectedIds={categoryIds}
|
||
label="Kategorien"
|
||
fieldName="categoryIds"
|
||
onselectionchange={(ids) => (categoryIds = ids)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<footer class="rss-selection-actions">
|
||
<button
|
||
class="text-button danger-text"
|
||
type="submit"
|
||
formaction="?/discardFeeds"
|
||
disabled={!selectedIds.length}>Auswahl verwerfen</button
|
||
>
|
||
<button class="primary-button" type="submit" disabled={!selectedIds.length}>
|
||
Auswahl in den Eingang übernehmen ({selectedIds.length})
|
||
</button>
|
||
</footer>
|
||
</form>
|
||
</dialog>
|