feat(geschichte-detail): avatar metabar + doc reference cards per spec R-2/LR-2
Detail header gains the author avatar with a two-line author block; journeys say 'zusammengestellt am' instead of 'veröffentlicht am'. Bearbeiten/Löschen move into the metabar for stories too (were at the article bottom). StoryReader renders real document reference cards (icon, title, date · von X an Y) instead of a placeholder link, person chips get avatar initials, and journey items lose the doubled spacing. Shared formatDocumentMetaLine() in geschichte/utils feeds both readers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1042,6 +1042,7 @@
|
|||||||
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
|
"geschichten_empty_no_filter": "Es gibt noch keine veröffentlichten Geschichten.",
|
||||||
"geschichten_back_to_index": "Zurück zu Geschichten",
|
"geschichten_back_to_index": "Zurück zu Geschichten",
|
||||||
"geschichten_published_on": "veröffentlicht am {date}",
|
"geschichten_published_on": "veröffentlicht am {date}",
|
||||||
|
"journey_compiled_on": "zusammengestellt am {date}",
|
||||||
"geschichten_persons_section": "Personen in dieser Geschichte",
|
"geschichten_persons_section": "Personen in dieser Geschichte",
|
||||||
"geschichten_documents_section": "Erwähnte Dokumente",
|
"geschichten_documents_section": "Erwähnte Dokumente",
|
||||||
"geschichten_document_link_placeholder": "Dokument öffnen",
|
"geschichten_document_link_placeholder": "Dokument öffnen",
|
||||||
|
|||||||
@@ -1042,6 +1042,7 @@
|
|||||||
"geschichten_empty_no_filter": "There are no published stories yet.",
|
"geschichten_empty_no_filter": "There are no published stories yet.",
|
||||||
"geschichten_back_to_index": "Back to stories",
|
"geschichten_back_to_index": "Back to stories",
|
||||||
"geschichten_published_on": "published on {date}",
|
"geschichten_published_on": "published on {date}",
|
||||||
|
"journey_compiled_on": "compiled on {date}",
|
||||||
"geschichten_persons_section": "People in this story",
|
"geschichten_persons_section": "People in this story",
|
||||||
"geschichten_documents_section": "Referenced documents",
|
"geschichten_documents_section": "Referenced documents",
|
||||||
"geschichten_document_link_placeholder": "Open document",
|
"geschichten_document_link_placeholder": "Open document",
|
||||||
|
|||||||
@@ -1042,6 +1042,7 @@
|
|||||||
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
|
"geschichten_empty_no_filter": "Aún no hay historias publicadas.",
|
||||||
"geschichten_back_to_index": "Volver a Historias",
|
"geschichten_back_to_index": "Volver a Historias",
|
||||||
"geschichten_published_on": "publicada el {date}",
|
"geschichten_published_on": "publicada el {date}",
|
||||||
|
"journey_compiled_on": "recopilada el {date}",
|
||||||
"geschichten_persons_section": "Personas en esta historia",
|
"geschichten_persons_section": "Personas en esta historia",
|
||||||
"geschichten_documents_section": "Documentos mencionados",
|
"geschichten_documents_section": "Documentos mencionados",
|
||||||
"geschichten_document_link_placeholder": "Abrir documento",
|
"geschichten_document_link_placeholder": "Abrir documento",
|
||||||
|
|||||||
@@ -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 { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
import { formatDocumentMetaLine } from './utils';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||||
@@ -14,16 +15,7 @@ let { item }: Props = $props();
|
|||||||
// Safe: JourneyReader filters out items where document === null before rendering this component.
|
// Safe: JourneyReader filters out items where document === null before rendering this component.
|
||||||
const doc = $derived(item.document!);
|
const doc = $derived(item.document!);
|
||||||
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
const formattedDate = $derived(doc.documentDate ? formatDate(doc.documentDate, 'short') : null);
|
||||||
const metaLine = $derived.by(() => {
|
const metaLine = $derived(formatDocumentMetaLine(doc));
|
||||||
const parts: string[] = [];
|
|
||||||
if (formattedDate) parts.push(formattedDate);
|
|
||||||
if (doc.senderName && doc.receiverName) {
|
|
||||||
parts.push(m.journey_item_meta_from_to({ sender: doc.senderName, receiver: doc.receiverName }));
|
|
||||||
} else if (doc.senderName) {
|
|
||||||
parts.push(doc.senderName);
|
|
||||||
}
|
|
||||||
return parts.join(' · ');
|
|
||||||
});
|
|
||||||
const openAriaLabel = $derived(
|
const openAriaLabel = $derived(
|
||||||
formattedDate
|
formattedDate
|
||||||
? m.journey_item_open_aria({ date: formattedDate })
|
? m.journey_item_open_aria({ date: formattedDate })
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ const validItems = $derived(
|
|||||||
{m.journey_empty_state()}
|
{m.journey_empty_state()}
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ol class="flex list-none flex-col gap-4">
|
<ol class="flex list-none flex-col">
|
||||||
{#each validItems as item (item.id)}
|
{#each validItems as item (item.id)}
|
||||||
<li>
|
<li>
|
||||||
{#if item.document != null}
|
{#if item.document != null}
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { safeHtml } from '$lib/shared/utils/sanitize';
|
import { safeHtml } from '$lib/shared/utils/sanitize';
|
||||||
|
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||||
|
import { formatDocumentMetaLine } from './utils';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type GeschichteView = components['schemas']['GeschichteView'];
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
geschichte: GeschichteView;
|
geschichte: GeschichteView;
|
||||||
canBlogWrite: boolean;
|
|
||||||
ondelete?: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let { geschichte: g, canBlogWrite, ondelete }: Props = $props();
|
let { geschichte: g }: Props = $props();
|
||||||
|
|
||||||
const sanitized = $derived(safeHtml(g.body));
|
const sanitized = $derived(safeHtml(g.body));
|
||||||
|
|
||||||
|
const documentItems = $derived(g.items.filter((i) => i.document));
|
||||||
|
|
||||||
function personName(p: { firstName?: string; lastName?: string }): string {
|
function personName(p: { firstName?: string; lastName?: string }): string {
|
||||||
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
||||||
}
|
}
|
||||||
@@ -47,8 +49,15 @@ function personName(p: { firstName?: string; lastName?: string }): string {
|
|||||||
<a
|
<a
|
||||||
href="/persons/{p.id}"
|
href="/persons/{p.id}"
|
||||||
style="display: inline-flex; min-height: 44px"
|
style="display: inline-flex; min-height: 44px"
|
||||||
class="inline-flex min-h-[44px] items-center rounded-full bg-muted px-3 py-2.5 font-sans text-sm text-ink hover:bg-accent-bg focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="inline-flex min-h-[44px] items-center gap-2 rounded-full border border-line bg-surface px-3 py-1.5 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full font-sans text-[8px] font-bold text-white"
|
||||||
|
style="background-color: {personAvatarColor(p.id)}"
|
||||||
|
>
|
||||||
|
{getInitials(personName(p))}
|
||||||
|
</span>
|
||||||
{personName(p)}
|
{personName(p)}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -58,19 +67,50 @@ function personName(p: { firstName?: string; lastName?: string }): string {
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Dokumente (JourneyItems) -->
|
<!-- Dokumente (JourneyItems) -->
|
||||||
{#if g.items && g.items.some((i) => i.document)}
|
{#if documentItems.length > 0}
|
||||||
<section class="mt-8 border-t border-line pt-6">
|
<section class="mt-8 border-t border-line pt-6">
|
||||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-2 uppercase">
|
||||||
{m.geschichten_documents_section()}
|
{m.geschichten_documents_section()}
|
||||||
</h2>
|
</h2>
|
||||||
<ul class="flex flex-col gap-2">
|
<ul class="flex flex-col gap-2">
|
||||||
{#each g.items.filter((i) => i.document) as item (item.id)}
|
{#each documentItems as item (item.id)}
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/documents/{item.document!.id}"
|
href="/documents/{item.document!.id}"
|
||||||
class="block rounded border border-line bg-surface px-4 py-3 font-serif text-base text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="flex items-start gap-3 rounded-sm border border-line bg-surface p-3 transition-shadow hover:shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{m.geschichten_document_link_placeholder()}
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded bg-muted"
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4 text-ink-3" viewBox="0 0 10 12" fill="none">
|
||||||
|
<rect
|
||||||
|
x="1"
|
||||||
|
y="1"
|
||||||
|
width="8"
|
||||||
|
height="10"
|
||||||
|
rx="1"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 4h4M3 6.5h4M3 9h2"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width=".8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0">
|
||||||
|
<span class="block font-sans text-sm leading-snug font-semibold text-ink">
|
||||||
|
{item.document!.title}
|
||||||
|
</span>
|
||||||
|
{#if formatDocumentMetaLine(item.document!)}
|
||||||
|
<span class="block font-sans text-xs text-ink-3">
|
||||||
|
{formatDocumentMetaLine(item.document!)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
{#if item.note}
|
{#if item.note}
|
||||||
<!-- plaintext — do NOT use {@html} here -->
|
<!-- plaintext — do NOT use {@html} here -->
|
||||||
@@ -81,22 +121,3 @@ function personName(p: { firstName?: string; lastName?: string }): string {
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Author actions -->
|
|
||||||
{#if canBlogWrite}
|
|
||||||
<div class="mt-10 flex items-center gap-3 border-t border-line pt-6">
|
|
||||||
<a
|
|
||||||
href="/geschichten/{g.id}/edit"
|
|
||||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
>
|
|
||||||
{m.btn_edit()}
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => ondelete?.()}
|
|
||||||
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
|
||||||
>
|
|
||||||
{m.btn_delete()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
const { default: StoryReader } = await import('./StoryReader.svelte');
|
const { default: StoryReader } = await import('./StoryReader.svelte');
|
||||||
@@ -24,38 +23,28 @@ const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView
|
|||||||
...overrides
|
...overrides
|
||||||
});
|
});
|
||||||
|
|
||||||
const ctx = () => new Map([[CONFIRM_KEY, createConfirmService()]]);
|
|
||||||
|
|
||||||
describe('StoryReader', () => {
|
describe('StoryReader', () => {
|
||||||
it('renders body HTML content', async () => {
|
it('renders body HTML content', async () => {
|
||||||
render(StoryReader, {
|
render(StoryReader, { props: { geschichte: baseGeschichte() } });
|
||||||
context: ctx(),
|
|
||||||
props: { geschichte: baseGeschichte(), canBlogWrite: false }
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
|
await expect.element(page.getByText(/Im Jahr 1923/)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits persons section when persons array is empty', async () => {
|
it('omits persons section when persons array is empty', async () => {
|
||||||
render(StoryReader, {
|
render(StoryReader, { props: { geschichte: baseGeschichte({ persons: [] }) } });
|
||||||
context: ctx(),
|
|
||||||
props: { geschichte: baseGeschichte({ persons: [] }), canBlogWrite: false }
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
|
await expect.element(page.getByText(/Personen in dieser Geschichte/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders persons section with firstName + lastName joined', async () => {
|
it('renders persons section with firstName + lastName joined', async () => {
|
||||||
render(StoryReader, {
|
render(StoryReader, {
|
||||||
context: ctx(),
|
|
||||||
props: {
|
props: {
|
||||||
geschichte: baseGeschichte({
|
geschichte: baseGeschichte({
|
||||||
persons: [
|
persons: [
|
||||||
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
|
{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' },
|
||||||
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
|
{ id: 'p2', firstName: 'Karl', lastName: 'Müller' }
|
||||||
]
|
]
|
||||||
}),
|
})
|
||||||
canBlogWrite: false
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,79 +54,50 @@ describe('StoryReader', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('omits documents section when no items have documents', async () => {
|
it('omits documents section when no items have documents', async () => {
|
||||||
render(StoryReader, {
|
render(StoryReader, { props: { geschichte: baseGeschichte({ items: [] }) } });
|
||||||
context: ctx(),
|
|
||||||
props: { geschichte: baseGeschichte({ items: [] }), canBlogWrite: false }
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Erwähnte Dokumente')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders documents section for items with documents', async () => {
|
it('renders document reference cards with title and link for items with documents', async () => {
|
||||||
render(StoryReader, {
|
render(StoryReader, {
|
||||||
context: ctx(),
|
|
||||||
props: {
|
props: {
|
||||||
geschichte: baseGeschichte({
|
geschichte: baseGeschichte({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'i1',
|
id: 'i1',
|
||||||
position: 0,
|
position: 0,
|
||||||
document: { id: 'd1', title: 'Brief 1', datePrecision: 'FULL' },
|
document: {
|
||||||
|
id: 'd1',
|
||||||
|
title: 'Brief vom 12. Juli 1938',
|
||||||
|
documentDate: '1938-07-12',
|
||||||
|
senderName: 'Franz Raddatz',
|
||||||
|
receiverName: 'Emma Müller',
|
||||||
|
datePrecision: 'DAY',
|
||||||
|
receiverCount: 1
|
||||||
|
} as unknown as NonNullable<components['schemas']['JourneyItemView']['document']>,
|
||||||
note: 'Wichtiger Brief'
|
note: 'Wichtiger Brief'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}),
|
})
|
||||||
canBlogWrite: false
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
|
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
|
||||||
await expect.element(page.getByText('Dokument öffnen')).toBeVisible();
|
await expect.element(page.getByText('Brief vom 12. Juli 1938')).toBeVisible();
|
||||||
|
await expect.element(page.getByText(/von Franz Raddatz an Emma Müller/)).toBeVisible();
|
||||||
await expect.element(page.getByText('Wichtiger Brief')).toBeVisible();
|
await expect.element(page.getByText('Wichtiger Brief')).toBeVisible();
|
||||||
});
|
|
||||||
|
|
||||||
it('shows edit/delete actions when canBlogWrite is true', async () => {
|
const link = document.querySelector<HTMLAnchorElement>('a[href="/documents/d1"]');
|
||||||
render(StoryReader, {
|
expect(link).not.toBeNull();
|
||||||
context: ctx(),
|
|
||||||
props: { geschichte: baseGeschichte(), canBlogWrite: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.element(page.getByRole('link', { name: /bearbeiten/i }))
|
|
||||||
.toHaveAttribute('href', '/geschichten/g1/edit');
|
|
||||||
await expect.element(page.getByRole('button', { name: /löschen/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hides edit/delete actions when canBlogWrite is false', async () => {
|
|
||||||
render(StoryReader, {
|
|
||||||
context: ctx(),
|
|
||||||
props: { geschichte: baseGeschichte(), canBlogWrite: false }
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect.element(page.getByRole('link', { name: /bearbeiten/i })).not.toBeInTheDocument();
|
|
||||||
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clicking delete button calls ondelete prop', async () => {
|
|
||||||
const ondelete = vi.fn().mockResolvedValue(undefined);
|
|
||||||
render(StoryReader, {
|
|
||||||
context: ctx(),
|
|
||||||
props: { geschichte: baseGeschichte(), canBlogWrite: true, ondelete }
|
|
||||||
});
|
|
||||||
|
|
||||||
await userEvent.click(page.getByRole('button', { name: /löschen/i }));
|
|
||||||
|
|
||||||
expect(ondelete).toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('person chip link meets 44px touch-target minimum height', async () => {
|
it('person chip link meets 44px touch-target minimum height', async () => {
|
||||||
render(StoryReader, {
|
render(StoryReader, {
|
||||||
context: ctx(),
|
|
||||||
props: {
|
props: {
|
||||||
geschichte: baseGeschichte({
|
geschichte: baseGeschichte({
|
||||||
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
|
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
|
||||||
}),
|
})
|
||||||
canBlogWrite: false
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,15 +106,26 @@ describe('StoryReader', () => {
|
|||||||
expect(rect?.height).toBeGreaterThanOrEqual(44);
|
expect(rect?.height).toBeGreaterThanOrEqual(44);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('person chip shows avatar initials', async () => {
|
||||||
|
render(StoryReader, {
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({
|
||||||
|
persons: [{ id: 'p1', firstName: 'Helene', lastName: 'Schmidt' }]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const chip = document.querySelector<HTMLAnchorElement>('a[href="/persons/p1"]');
|
||||||
|
expect(chip?.textContent).toContain('HS');
|
||||||
|
});
|
||||||
|
|
||||||
it('XSS: Story body is sanitised — injected payload does not execute', async () => {
|
it('XSS: Story body is sanitised — injected payload does not execute', async () => {
|
||||||
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
|
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
|
||||||
render(StoryReader, {
|
render(StoryReader, {
|
||||||
context: ctx(),
|
|
||||||
props: {
|
props: {
|
||||||
geschichte: baseGeschichte({
|
geschichte: baseGeschichte({
|
||||||
body: '<img src=x onerror="(window as any).__xss_story=1">'
|
body: '<img src=x onerror="(window as any).__xss_story=1">'
|
||||||
}),
|
})
|
||||||
canBlogWrite: false
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
type AuthorSummary = { firstName?: string; lastName?: string };
|
type AuthorSummary = { firstName?: string; lastName?: string };
|
||||||
|
type DocumentMeta = { documentDate?: string; senderName?: string; receiverName?: string };
|
||||||
type AuthorView = { displayName: string };
|
type AuthorView = { displayName: string };
|
||||||
|
|
||||||
export function formatAuthorName(author: AuthorSummary | null | undefined): string {
|
export function formatAuthorName(author: AuthorSummary | null | undefined): string {
|
||||||
@@ -21,3 +23,15 @@ export function formatPublishedAt(
|
|||||||
if (!publishedAt) return null;
|
if (!publishedAt) return null;
|
||||||
return formatDate(publishedAt.slice(0, 10), style);
|
return formatDate(publishedAt.slice(0, 10), style);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "12.07.1938 · von Franz an Emma" — shared by JourneyItemCard and the story doc-reference cards. */
|
||||||
|
export function formatDocumentMetaLine(doc: DocumentMeta): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (doc.documentDate) parts.push(formatDate(doc.documentDate, 'short'));
|
||||||
|
if (doc.senderName && doc.receiverName) {
|
||||||
|
parts.push(m.journey_item_meta_from_to({ sender: doc.senderName, receiver: doc.receiverName }));
|
||||||
|
} else if (doc.senderName) {
|
||||||
|
parts.push(doc.senderName);
|
||||||
|
}
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { goto } from '$app/navigation';
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
|
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
|
||||||
|
import { getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
||||||
import { csrfFetch } from '$lib/shared/cookies';
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
|
import { parseBackendError, getErrorMessage } from '$lib/shared/errors';
|
||||||
@@ -61,15 +62,34 @@ async function handleDelete() {
|
|||||||
{m.journey_badge_detail()}
|
{m.journey_badge_detail()}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<h1 id="geschichte-title" class="mb-4 font-serif text-3xl leading-tight font-bold text-ink">
|
<h1 id="geschichte-title" class="mb-4 font-serif text-3xl leading-tight text-ink">
|
||||||
{g.title}
|
{g.title}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="border-subtle mb-4 flex items-center gap-3 border-b pb-4">
|
<div class="mb-4 flex items-center gap-3 border-b border-line-2 pb-4">
|
||||||
<p class="font-sans text-sm text-ink-3">
|
{#if authorName}
|
||||||
{authorName}
|
<span
|
||||||
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
aria-hidden="true"
|
||||||
</p>
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-sans text-xs font-bold text-white"
|
||||||
{#if isJourney && data.canBlogWrite}
|
style="background-color: {personAvatarColor(g.author?.id ?? authorName)}"
|
||||||
|
>
|
||||||
|
{getInitials(authorName)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
{#if authorName}
|
||||||
|
<p class="font-sans text-sm leading-tight font-semibold text-ink">{authorName}</p>
|
||||||
|
{/if}
|
||||||
|
{#if publishedAt}
|
||||||
|
<p class="font-sans text-xs text-ink-3">
|
||||||
|
{#if isJourney}
|
||||||
|
{m.journey_compiled_on({ date: publishedAt })}
|
||||||
|
{:else}
|
||||||
|
{m.geschichten_published_on({ date: publishedAt })}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if data.canBlogWrite}
|
||||||
<div class="ml-auto flex items-center gap-3">
|
<div class="ml-auto flex items-center gap-3">
|
||||||
<a
|
<a
|
||||||
href="/geschichten/{g.id}/edit"
|
href="/geschichten/{g.id}/edit"
|
||||||
@@ -101,7 +121,7 @@ async function handleDelete() {
|
|||||||
{#if isJourney}
|
{#if isJourney}
|
||||||
<JourneyReader geschichte={g} />
|
<JourneyReader geschichte={g} />
|
||||||
{:else}
|
{:else}
|
||||||
<StoryReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
|
<StoryReader geschichte={g} />
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ const baseGeschichte = (overrides: Partial<GeschichteView> = {}): GeschichteView
|
|||||||
});
|
});
|
||||||
|
|
||||||
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
const baseData = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
user: undefined,
|
||||||
|
canWrite: false,
|
||||||
|
canAnnotate: false,
|
||||||
geschichte: baseGeschichte(),
|
geschichte: baseGeschichte(),
|
||||||
canBlogWrite: false,
|
canBlogWrite: false,
|
||||||
...overrides
|
...overrides
|
||||||
@@ -176,8 +179,28 @@ describe('geschichten/[id] page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
|
await expect.element(page.getByText('Erwähnte Dokumente')).toBeVisible();
|
||||||
await expect.element(page.getByText('Dokument öffnen')).toBeVisible();
|
await expect.element(page.getByText('Brief 1923')).toBeVisible();
|
||||||
await expect.element(page.getByText('Brief aus 1923')).toBeVisible();
|
await expect.element(page.getByText('Brief aus 1923')).toBeVisible();
|
||||||
|
expect(document.querySelector('a[href="/documents/d1"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JOURNEY shows "zusammengestellt am" instead of "veröffentlicht am"', async () => {
|
||||||
|
render(GeschichtePage, {
|
||||||
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||||
|
props: { data: baseData({ geschichte: baseGeschichte({ type: 'JOURNEY' }) }) }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText(/zusammengestellt am/i)).toBeVisible();
|
||||||
|
await expect.element(page.getByText(/veröffentlicht am/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the author avatar initials in the meta bar', async () => {
|
||||||
|
render(GeschichtePage, {
|
||||||
|
context: new Map([[CONFIRM_KEY, createConfirmService()]]),
|
||||||
|
props: { data: baseData() }
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('AS', { exact: true })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders edit and delete actions when canBlogWrite is true', async () => {
|
it('renders edit and delete actions when canBlogWrite is true', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user