feat(lesereisen): implement lesereisen
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s
All checks were successful
CI / Unit & Component Tests (push) Successful in 4m34s
CI / OCR Service Tests (push) Successful in 27s
CI / Backend Unit Tests (push) Successful in 5m1s
CI / fail2ban Regex (push) Successful in 47s
CI / Semgrep Security Scan (push) Successful in 23s
CI / Compose Bucket Idempotency (push) Successful in 1m11s
This commit was merged in pull request #787.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
import PersonTypeahead from '$lib/person/PersonTypeahead.svelte';
|
||||
import GeschichteListRow from '$lib/geschichte/GeschichteListRow.svelte';
|
||||
import DocumentFilterChip from './DocumentFilterChip.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -11,18 +11,32 @@ let { data }: { data: PageData } = $props();
|
||||
let showPersonPicker = $state(false);
|
||||
|
||||
const selectedPersonIds = $derived(data.personFilters.map((p) => p.id!));
|
||||
const hasFilters = $derived(data.personFilters.length > 0 || !!data.documentFilter);
|
||||
const hasFilters = $derived(data.personFilters.length > 0 || data.documentFilter !== null);
|
||||
|
||||
const emptyMessage = $derived.by(() => {
|
||||
if (data.personFilters.length > 0) {
|
||||
return m.geschichten_empty_for_persons({
|
||||
names: data.personFilters.map((p) => p.displayName).join(' & ')
|
||||
});
|
||||
}
|
||||
if (data.documentFilter) {
|
||||
return m.geschichten_empty_for_document();
|
||||
}
|
||||
return m.geschichten_empty_no_filter();
|
||||
});
|
||||
|
||||
function rebuildUrl(personIds: string[]) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('personId');
|
||||
url.searchParams.delete('documentId');
|
||||
for (const id of personIds) url.searchParams.append('personId', id);
|
||||
return url.pathname + url.search;
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
goto(rebuildUrl([]), { replaceState: true });
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('personId');
|
||||
url.searchParams.delete('documentId');
|
||||
goto(url.pathname + url.search, { replaceState: true });
|
||||
}
|
||||
|
||||
function addPerson(personId: string) {
|
||||
@@ -38,22 +52,16 @@ function removePerson(personId: string) {
|
||||
goto(rebuildUrl(selectedPersonIds.filter((id) => id !== personId)));
|
||||
}
|
||||
|
||||
function authorName(g: { author?: { firstName?: string; lastName?: string; email: string } }) {
|
||||
const a = g.author;
|
||||
if (!a) return '';
|
||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||
return full || a.email || '';
|
||||
}
|
||||
|
||||
function publishedAt(g: { publishedAt?: string }): string | null {
|
||||
if (!g.publishedAt) return null;
|
||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||
function removeDocument() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('documentId');
|
||||
goto(url.pathname + url.search);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||
<header class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="font-serif text-3xl font-bold text-ink">{m.geschichten_index_title()}</h1>
|
||||
<div class="mx-auto max-w-7xl px-4 py-8">
|
||||
<header class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="font-serif text-2xl text-ink">{m.geschichten_index_title()}</h1>
|
||||
{#if data.canBlogWrite}
|
||||
<a
|
||||
href="/geschichten/new"
|
||||
@@ -64,86 +72,81 @@ function publishedAt(g: { publishedAt?: string }): string | null {
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Filter pills -->
|
||||
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={!hasFilters}
|
||||
onclick={clearAll}
|
||||
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:bg-ink aria-pressed:text-primary-fg"
|
||||
>
|
||||
{m.geschichten_filter_all_pill()}
|
||||
</button>
|
||||
|
||||
{#each data.personFilters as p (p.id)}
|
||||
<!-- Editorial list card: filter pills + rows share one surface -->
|
||||
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||
<!-- Filter pills -->
|
||||
<div class="flex flex-wrap items-center gap-2 border-b border-line-2 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed="true"
|
||||
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
|
||||
onclick={() => removePerson(p.id!)}
|
||||
class="inline-flex h-11 items-center gap-2 rounded-full bg-ink px-3 font-sans text-xs font-bold tracking-wider text-primary-fg uppercase"
|
||||
aria-pressed={!hasFilters}
|
||||
onclick={clearAll}
|
||||
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-semibold tracking-wider text-ink-2 uppercase hover:bg-muted aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-fg"
|
||||
>
|
||||
{p.displayName}
|
||||
<span aria-hidden="true">×</span>
|
||||
{m.geschichten_filter_all_pill()}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showPersonPicker}
|
||||
onclick={() => (showPersonPicker = !showPersonPicker)}
|
||||
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-bold tracking-wider text-ink-2 uppercase hover:bg-muted"
|
||||
>
|
||||
+ {m.geschichten_filter_choose_person()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPersonPicker}
|
||||
<div class="mb-4">
|
||||
<PersonTypeahead
|
||||
name="filter-person"
|
||||
label={m.geschichten_filter_choose_person()}
|
||||
compact
|
||||
autofocus
|
||||
onchange={addPerson}
|
||||
/>
|
||||
{#if selectedPersonIds.length > 1}
|
||||
<p class="mt-1 font-sans text-xs text-ink-3">
|
||||
{m.geschichten_filter_and_hint()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Card list -->
|
||||
{#if data.geschichten.length === 0}
|
||||
<div class="rounded border border-line bg-surface p-6 text-center font-sans text-sm text-ink-3">
|
||||
{#if data.personFilters.length > 0}
|
||||
{m.geschichten_empty_for_persons({
|
||||
names: data.personFilters.map((p) => p.displayName).join(' & ')
|
||||
})}
|
||||
{:else}
|
||||
{m.geschichten_empty_no_filter()}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-4">
|
||||
{#each data.geschichten as g (g.id)}
|
||||
<li
|
||||
class="rounded border border-line bg-surface p-5 shadow-sm transition-shadow hover:shadow-md"
|
||||
{#each data.personFilters as p (p.id)}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed="true"
|
||||
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
|
||||
onclick={() => removePerson(p.id!)}
|
||||
class="inline-flex h-11 items-center gap-1.5 rounded-full border border-primary bg-primary px-3 font-sans text-xs font-semibold tracking-wider text-primary-fg uppercase"
|
||||
>
|
||||
<a href="/geschichten/{g.id}" class="block">
|
||||
<h2 class="mb-1 font-serif text-xl font-bold text-ink">{g.title}</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">
|
||||
{authorName(g)}
|
||||
{#if publishedAt(g)}· {m.geschichten_published_on({ date: publishedAt(g)! })}{/if}
|
||||
</p>
|
||||
{#if g.body}
|
||||
<p class="font-serif text-base text-ink-2">{plainExcerpt(g.body, 150)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{p.displayName}
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if data.documentFilter}
|
||||
<DocumentFilterChip
|
||||
id={data.documentFilter.id}
|
||||
title={data.documentFilter.title}
|
||||
onremove={removeDocument}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={showPersonPicker}
|
||||
onclick={() => (showPersonPicker = !showPersonPicker)}
|
||||
class="inline-flex h-11 items-center rounded-full border border-dashed border-line px-3 font-sans text-xs font-semibold text-ink-3 hover:bg-muted"
|
||||
>
|
||||
+ {m.geschichten_filter_choose_person()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPersonPicker}
|
||||
<div class="border-b border-line-2 px-3 py-3">
|
||||
<PersonTypeahead
|
||||
name="filter-person"
|
||||
label={m.geschichten_filter_choose_person()}
|
||||
compact
|
||||
autofocus
|
||||
onchange={addPerson}
|
||||
/>
|
||||
{#if selectedPersonIds.length > 1}
|
||||
<p class="mt-1 font-sans text-xs text-ink-3">
|
||||
{m.geschichten_filter_and_hint()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Rows -->
|
||||
{#if data.geschichten.length === 0}
|
||||
<div class="px-4 py-12 text-center font-serif text-sm text-ink-3 italic">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
{:else}
|
||||
<ul>
|
||||
{#each data.geschichten as g (g.id)}
|
||||
<li class="border-b border-line-2 last:border-b-0">
|
||||
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
||||
<GeschichteListRow geschichte={g} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user