Files
sendewerk/src/lib/components/RssSelectionDialog.svelte
T
2026-10-26 21:20:55 +01:00

220 lines
6.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>