diff --git a/frontend/src/lib/components/GeschichtenCard.svelte b/frontend/src/lib/components/GeschichtenCard.svelte new file mode 100644 index 00000000..6b8a0d4c --- /dev/null +++ b/frontend/src/lib/components/GeschichtenCard.svelte @@ -0,0 +1,87 @@ + + +{#if geschichten.length > 0} +
+
+

+ {m.geschichten_card_heading()} +

+ {#if canWrite} + + {m.geschichten_card_write_action()} + + {/if} +
+ + + + {#if hasOverflow} + + {/if} +
+{/if} diff --git a/frontend/src/lib/utils/stripHtml.spec.ts b/frontend/src/lib/utils/stripHtml.spec.ts new file mode 100644 index 00000000..4dcc7308 --- /dev/null +++ b/frontend/src/lib/utils/stripHtml.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { plainExcerpt, stripHtml } from './stripHtml'; + +describe('stripHtml', () => { + it('returns empty string for null/undefined/empty', () => { + expect(stripHtml(null)).toBe(''); + expect(stripHtml(undefined)).toBe(''); + expect(stripHtml('')).toBe(''); + }); + + it('strips tags and preserves visible text', () => { + expect(stripHtml('

Hello world

')).toBe('Hello world'); + }); + + it('strips nested HTML', () => { + expect(stripHtml('

A

B

')).toBe('AB'); + }); +}); + +describe('plainExcerpt', () => { + it('returns full text when under the limit', () => { + expect(plainExcerpt('

short

', 80)).toBe('short'); + }); + + it('truncates at the boundary with an ellipsis', () => { + const html = '

' + 'a'.repeat(100) + '

'; + const out = plainExcerpt(html, 20); + expect(out.length).toBeLessThanOrEqual(21); // 20 chars + ellipsis + expect(out.endsWith('…')).toBe(true); + }); + + it('breaks at a word boundary when possible', () => { + const out = plainExcerpt('

The quick brown fox jumps over

', 18); + expect(out).toBe('The quick brown…'); + }); +}); diff --git a/frontend/src/lib/utils/stripHtml.ts b/frontend/src/lib/utils/stripHtml.ts new file mode 100644 index 00000000..00ce62ff --- /dev/null +++ b/frontend/src/lib/utils/stripHtml.ts @@ -0,0 +1,23 @@ +/** + * Strip HTML tags from a string and return the plain text. + * Uses DOMParser in the browser, falls back to a regex strip on the server + * (where DOMParser is not available without isomorphic-dompurify's JSDOM). + */ +export function stripHtml(html: string | null | undefined): string { + if (!html) return ''; + if (typeof DOMParser === 'function') { + const doc = new DOMParser().parseFromString(html, 'text/html'); + return (doc.body.textContent ?? '').trim(); + } + return html.replace(/<[^>]*>/g, '').trim(); +} + +/** + * Strip HTML and truncate to a maximum length, appending an ellipsis when + * the source exceeds it. Used for editorial story excerpts. + */ +export function plainExcerpt(html: string | null | undefined, max = 80): string { + const text = stripHtml(html); + if (text.length <= max) return text; + return text.slice(0, max).replace(/\s+\S*$/, '') + '…'; +}