feat(geschichten-list): editorial list card per spec R-1/LR-1
Single white list card with the person-filter pills inside, rows split into an author meta column (avatar, name, date, REISE badge) and a content column (serif title, two-line excerpt). Badge moves into the meta column on desktop and next to the title on mobile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
import { plainExcerpt } from '$lib/shared/utils/extractText';
|
||||||
|
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||||
import { formatAuthorName, formatPublishedAt } from './utils';
|
import { formatAuthorName, formatPublishedAt } from './utils';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
@@ -18,25 +19,72 @@ const publishedAt = $derived(formatPublishedAt(geschichte.publishedAt, 'short'))
|
|||||||
const authorName = $derived(formatAuthorName(geschichte.author));
|
const authorName = $derived(formatAuthorName(geschichte.author));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href="/geschichten/{geschichte.id}" class="block">
|
<a
|
||||||
<div class="mb-1 flex items-center gap-1.5">
|
href="/geschichten/{geschichte.id}"
|
||||||
<h2 class="font-serif text-xl font-bold text-ink">{geschichte.title}</h2>
|
class="group flex min-h-[44px] transition-colors hover:bg-canvas/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
|
>
|
||||||
|
<!-- Meta column (desktop) -->
|
||||||
|
<div class="hidden w-28 shrink-0 flex-col items-start gap-1 border-r border-line-2 p-3 sm:flex">
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-7 w-7 items-center justify-center rounded-full font-sans text-[9px] font-bold text-white"
|
||||||
|
style="background-color: {personAvatarColor(authorName)}"
|
||||||
|
>
|
||||||
|
{getInitials(authorName)}
|
||||||
|
</span>
|
||||||
|
<span class="font-sans text-xs leading-tight font-semibold text-ink">{authorName}</span>
|
||||||
|
{#if publishedAt}
|
||||||
|
<span class="font-sans text-xs text-ink-3">{publishedAt}</span>
|
||||||
|
{/if}
|
||||||
{#if isJourney}
|
{#if isJourney}
|
||||||
<span
|
<span
|
||||||
data-testid="journey-badge"
|
data-testid="journey-badge"
|
||||||
style="font-size: 0.75rem"
|
aria-label="Lesereise"
|
||||||
class="inline-block rounded-full bg-journey-tint px-2 py-0.5 text-xs font-bold tracking-wider text-journey uppercase"
|
style="font-size: 10px"
|
||||||
|
class="inline-flex items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-[10px] font-bold tracking-wide text-journey uppercase"
|
||||||
>
|
>
|
||||||
{m.journey_badge_list()}
|
{m.journey_badge_list()}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-3 font-sans text-xs text-ink-3">
|
|
||||||
{authorName}
|
<!-- Content column -->
|
||||||
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
<div class="min-w-0 flex-1 p-3 sm:px-4">
|
||||||
</p>
|
<!-- Compact meta line (mobile only) -->
|
||||||
{#if geschichte.body}
|
<div class="mb-1 flex items-center gap-1.5 sm:hidden">
|
||||||
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
<span
|
||||||
<p class="font-serif text-base text-ink-2">{plainExcerpt(geschichte.body, 150)}</p>
|
aria-hidden="true"
|
||||||
{/if}
|
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-full font-sans text-[7px] font-bold text-white"
|
||||||
|
style="background-color: {personAvatarColor(authorName)}"
|
||||||
|
>
|
||||||
|
{getInitials(authorName)}
|
||||||
|
</span>
|
||||||
|
<span class="font-sans text-xs font-semibold text-ink">{authorName}</span>
|
||||||
|
{#if publishedAt}
|
||||||
|
<span class="ml-auto font-sans text-xs text-ink-3">{publishedAt}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-1 flex items-center gap-1.5">
|
||||||
|
<h2 class="font-serif text-[15px] leading-snug text-ink group-hover:underline">
|
||||||
|
{geschichte.title}
|
||||||
|
</h2>
|
||||||
|
{#if isJourney}
|
||||||
|
<span
|
||||||
|
data-testid="journey-badge-mobile"
|
||||||
|
aria-hidden="true"
|
||||||
|
style="font-size: 10px"
|
||||||
|
class="inline-flex shrink-0 items-center rounded-sm border border-journey-border bg-journey-tint px-1.5 py-px font-sans text-[10px] font-bold tracking-wide text-journey uppercase sm:hidden"
|
||||||
|
>
|
||||||
|
{m.journey_badge_list()}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if geschichte.body}
|
||||||
|
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
||||||
|
<p class="line-clamp-2 font-sans text-xs leading-relaxed text-ink-3">
|
||||||
|
{plainExcerpt(geschichte.body, 150)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -38,8 +38,8 @@ function removePerson(personId: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto max-w-4xl px-4 py-8">
|
<div class="mx-auto max-w-4xl px-4 py-8">
|
||||||
<header class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
<header class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 class="font-serif text-3xl font-bold text-ink">{m.geschichten_index_title()}</h1>
|
<h1 class="font-serif text-2xl text-ink">{m.geschichten_index_title()}</h1>
|
||||||
{#if data.canBlogWrite}
|
{#if data.canBlogWrite}
|
||||||
<a
|
<a
|
||||||
href="/geschichten/new"
|
href="/geschichten/new"
|
||||||
@@ -50,78 +50,79 @@ function removePerson(personId: string) {
|
|||||||
{/if}
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Filter pills -->
|
<!-- Editorial list card: filter pills + rows share one surface -->
|
||||||
<div class="mb-6 flex flex-wrap items-center gap-2">
|
<div class="overflow-hidden rounded-sm border border-line bg-surface shadow-sm">
|
||||||
<button
|
<!-- Filter pills -->
|
||||||
type="button"
|
<div class="flex flex-wrap items-center gap-2 border-b border-line-2 px-3 py-2.5">
|
||||||
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)}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-pressed="true"
|
aria-pressed={!hasFilters}
|
||||||
aria-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
|
onclick={clearAll}
|
||||||
onclick={() => removePerson(p.id!)}
|
class="inline-flex h-11 items-center rounded-full border border-line px-3 font-sans text-xs font-semibold text-ink-2 hover:bg-muted aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-fg"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
{p.displayName}
|
{m.geschichten_filter_all_pill()}
|
||||||
<span aria-hidden="true">×</span>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
|
||||||
|
|
||||||
<button
|
{#each data.personFilters as p (p.id)}
|
||||||
type="button"
|
<button
|
||||||
aria-expanded={showPersonPicker}
|
type="button"
|
||||||
onclick={() => (showPersonPicker = !showPersonPicker)}
|
aria-pressed="true"
|
||||||
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-label={m.geschichten_filter_remove_chip({ name: p.displayName })}
|
||||||
>
|
onclick={() => removePerson(p.id!)}
|
||||||
+ {m.geschichten_filter_choose_person()}
|
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 text-primary-fg"
|
||||||
</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"
|
|
||||||
>
|
>
|
||||||
<!-- plaintext for JOURNEY, sanitised-HTML→text for STORY; never {@html} -->
|
{p.displayName}
|
||||||
<GeschichteListRow geschichte={g} />
|
<span aria-hidden="true">×</span>
|
||||||
</li>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
|
||||||
{/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">
|
||||||
|
{#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>
|
||||||
|
{#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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user