spa(phase 5, tasks 26-32): reactions, replies, nip-07 kommentare, e2e

Neue Komponenten unter $lib/components/:
- Reactions.svelte: lädt kind:7-Aggregation via loadReactions, rendert
  Chips mit Emoji + Count. +/- werden zu 👍/👎 gemappt.
- ReplyItem.svelte: einzelner Kommentar mit Author-Npub-Prefix + Datum.
- ReplyList.svelte: lädt kind:1-Replies, merged mit optimistic-Props
  (dedup per id), sortiert chronologisch.
- ReplyComposer.svelte: Textarea + Senden-Button. Nutzt NIP-07-Wrapper
  (getPublicKey, signEvent), baut kind:1-Event mit a/e/p-Tags, pusht
  via pool.publish() zu allen Read-Relays. Fehlertolerant: zeigt
  Hinweis, wenn NIP-07-Extension fehlt.

Integration in PostView: Reactions, Composer, ReplyList unterhalb des
Artikel-Bodys. Optimistisches Reply-Pattern: Composer.onPublished
pushed signed event in PostView-local $state, ReplyList merged mit
fetched events.

Playwright-E2E:
- playwright.config.ts mit Dev-Server-Auto-Start
- home.test.ts: Profil + Beitragsliste sichtbar
- post.test.ts: Titel + Body + Legacy-URL-Redirect

Alle 3 E2E-Tests grün. npm run check: 600 files, 0 errors.
Deploy live auf svelte.joerg-lohrer.de (Phase 5 inklusive).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jörg Lohrer 2026-04-15 17:51:57 +02:00
parent c089d9e429
commit 3b0f059cea
9 changed files with 377 additions and 0 deletions

13
app/playwright.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
use: { baseURL: 'http://localhost:5173' },
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
timeout: 120_000
},
timeout: 60_000
});

View File

@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders'; import type { NostrEvent } from '$lib/nostr/loaders';
import type { SignedEvent } from '$lib/nostr/signer';
import { renderMarkdown } from '$lib/render/markdown'; import { renderMarkdown } from '$lib/render/markdown';
import Reactions from './Reactions.svelte';
import ReplyList from './ReplyList.svelte';
import ReplyComposer from './ReplyComposer.svelte';
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
@ -14,6 +18,7 @@
return e.tags.filter((t) => t[0] === name).map((t) => t[1]); return e.tags.filter((t) => t[0] === name).map((t) => t[1]);
} }
const dtag = $derived(tagValue(event, 'd'));
const title = $derived(tagValue(event, 'title') || '(ohne Titel)'); const title = $derived(tagValue(event, 'title') || '(ohne Titel)');
const summary = $derived(tagValue(event, 'summary')); const summary = $derived(tagValue(event, 'summary'));
const image = $derived(tagValue(event, 'image')); const image = $derived(tagValue(event, 'image'));
@ -30,6 +35,13 @@
const tags = $derived(tagsAll(event, 't')); const tags = $derived(tagsAll(event, 't'));
const bodyHtml = $derived(renderMarkdown(event.content)); const bodyHtml = $derived(renderMarkdown(event.content));
// Optimistisch gesendete Replies: der Composer pusht sie rein,
// ReplyList merged sie mit den vom Relay geladenen Replies (dedup per id).
let optimisticReplies: NostrEvent[] = $state([]);
function handlePublished(signed: SignedEvent) {
optimisticReplies = [...optimisticReplies, signed as unknown as NostrEvent];
}
$effect(() => { $effect(() => {
document.title = `${title} Jörg Lohrer`; document.title = `${title} Jörg Lohrer`;
}); });
@ -57,6 +69,12 @@
<article>{@html bodyHtml}</article> <article>{@html bodyHtml}</article>
{#if dtag}
<Reactions {dtag} />
<ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} />
<ReplyList {dtag} optimistic={optimisticReplies} />
{/if}
<style> <style>
.post-title { .post-title {
font-size: 1.5rem; font-size: 1.5rem;

View File

@ -0,0 +1,58 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { ReactionSummary } from '$lib/nostr/loaders';
import { loadReactions } from '$lib/nostr/loaders';
interface Props {
dtag: string;
}
let { dtag }: Props = $props();
let reactions: ReactionSummary[] = $state([]);
onMount(async () => {
try {
reactions = await loadReactions(dtag);
} catch {
reactions = [];
}
});
function displayChar(c: string): string {
if (c === '+' || c === '') return '👍';
if (c === '-') return '👎';
return c;
}
</script>
{#if reactions.length > 0}
<div class="reactions">
{#each reactions as r}
<span class="reaction">
<span class="emoji">{displayChar(r.content)}</span>
<span class="count">{r.count}</span>
</span>
{/each}
</div>
{/if}
<style>
.reactions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1.5rem 0;
}
.reaction {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.6rem;
background: var(--code-bg);
border-radius: 999px;
font-size: 0.9rem;
}
.count {
color: var(--muted);
}
</style>

View File

@ -0,0 +1,148 @@
<script lang="ts">
import { get } from 'svelte/store';
import {
hasNip07,
getPublicKey,
signEvent,
type SignedEvent,
type UnsignedEvent
} from '$lib/nostr/signer';
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
import { pool } from '$lib/nostr/pool';
import { readRelays } from '$lib/stores/readRelays';
interface Props {
/** d-Tag des Posts, auf den geantwortet wird */
dtag: string;
/** Event-ID des ursprünglichen Posts (für e-Tag) */
eventId: string;
/** Callback, wenn ein Reply erfolgreich publiziert wurde */
onPublished?: (ev: SignedEvent) => void;
}
let { dtag, eventId, onPublished }: Props = $props();
let text = $state('');
let publishing = $state(false);
let error: string | null = $state(null);
let info: string | null = $state(null);
const nip07 = hasNip07();
async function submit() {
error = null;
info = null;
if (!text.trim()) {
error = 'Leeres Kommentar — nichts zu senden.';
return;
}
publishing = true;
try {
const pubkey = await getPublicKey();
if (!pubkey) {
error = 'Nostr-Extension (z. B. Alby) hat den Pubkey nicht geliefert.';
return;
}
const unsigned: UnsignedEvent = {
kind: 1,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
['a', `30023:${AUTHOR_PUBKEY_HEX}:${dtag}`],
['e', eventId, '', 'root'],
['p', AUTHOR_PUBKEY_HEX]
],
content: text.trim()
};
const signed = await signEvent(unsigned);
if (!signed) {
error = 'Signatur wurde abgelehnt oder ist fehlgeschlagen.';
return;
}
const relays = get(readRelays);
const results = await pool.publish(relays, signed);
const okCount = results.filter((r) => r.ok).length;
if (okCount === 0) {
error = 'Kein Relay hat den Kommentar akzeptiert.';
return;
}
info = `Kommentar gesendet (${okCount}/${results.length} Relays).`;
text = '';
onPublished?.(signed);
} catch (e) {
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
} finally {
publishing = false;
}
}
</script>
<div class="composer">
{#if !nip07}
<p class="hint">
Um zu kommentieren, benötigst du eine Nostr-Extension
(<a href="https://getalby.com" target="_blank" rel="noopener">Alby</a>,
<a href="https://github.com/fiatjaf/nos2x" target="_blank" rel="noopener">nos2x</a>), oder
kommentiere direkt in einem Nostr-Client.
</p>
{:else}
<textarea
bind:value={text}
placeholder="Dein Kommentar …"
rows="4"
disabled={publishing}
></textarea>
<div class="actions">
<button type="button" onclick={submit} disabled={publishing || !text.trim()}>
{publishing ? 'Sende …' : 'Kommentar senden'}
</button>
</div>
{#if error}<p class="error">{error}</p>{/if}
{#if info}<p class="info">{info}</p>{/if}
{/if}
</div>
<style>
.composer {
margin: 1.5rem 0;
}
textarea {
width: 100%;
padding: 0.6rem;
font: inherit;
color: inherit;
background: var(--code-bg);
border: 1px solid var(--border);
border-radius: 4px;
resize: vertical;
}
.actions {
margin-top: 0.5rem;
display: flex;
justify-content: flex-end;
}
button {
padding: 0.4rem 1rem;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font: inherit;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hint {
font-size: 0.9rem;
color: var(--muted);
}
.error {
color: #991b1b;
font-size: 0.9rem;
}
.info {
color: #065f46;
font-size: 0.9rem;
}
</style>

View File

@ -0,0 +1,44 @@
<script lang="ts">
import type { NostrEvent } from '$lib/nostr/loaders';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
const date = $derived(new Date(event.created_at * 1000).toLocaleString('de-DE'));
const authorNpub = $derived(event.pubkey.slice(0, 12) + '…');
</script>
<li class="reply">
<div class="meta">
<span class="author">{authorNpub}</span>
<span class="sep">·</span>
<span class="date">{date}</span>
</div>
<div class="content">{event.content}</div>
</li>
<style>
.reply {
list-style: none;
padding: 0.8rem 0;
border-bottom: 1px solid var(--border);
}
.meta {
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 0.3rem;
}
.author {
font-family: monospace;
}
.sep {
margin: 0 0.4rem;
opacity: 0.5;
}
.content {
white-space: pre-wrap;
word-wrap: break-word;
}
</style>

View File

@ -0,0 +1,68 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent } from '$lib/nostr/loaders';
import { loadReplies } from '$lib/nostr/loaders';
import ReplyItem from './ReplyItem.svelte';
interface Props {
dtag: string;
/**
* Optimistisch hinzugefügte Events (z. B. frisch gesendete Kommentare).
* Werden vor dem Rendern zur geladenen Liste gemerged, dedupliziert per id.
*/
optimistic?: NostrEvent[];
}
let { dtag, optimistic = [] }: Props = $props();
let fetched: NostrEvent[] = $state([]);
let loading = $state(true);
const merged = $derived.by(() => {
const byId = new Map<string, NostrEvent>();
for (const ev of fetched) byId.set(ev.id, ev);
for (const ev of optimistic) byId.set(ev.id, ev);
return [...byId.values()].sort((a, b) => a.created_at - b.created_at);
});
onMount(async () => {
try {
fetched = await loadReplies(dtag);
} finally {
loading = false;
}
});
</script>
<section class="replies">
<h3>Kommentare ({merged.length})</h3>
{#if loading}
<p class="hint">Lade Kommentare …</p>
{:else if merged.length === 0}
<p class="hint">Noch keine Kommentare.</p>
{:else}
<ul>
{#each merged as reply (reply.id)}
<ReplyItem event={reply} />
{/each}
</ul>
{/if}
</section>
<style>
.replies {
margin: 2rem 0;
}
h3 {
font-size: 1.1rem;
margin: 0 0 0.8rem;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
.hint {
color: var(--muted);
font-size: 0.9rem;
}
</style>

View File

@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

@ -0,0 +1,8 @@
import { expect, test } from '@playwright/test';
test('Home zeigt Profil und mindestens einen Post', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { level: 1, name: /Beiträge/ })).toBeVisible();
await expect(page.locator('.profile .name')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('a.card').first()).toBeVisible({ timeout: 15_000 });
});

View File

@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test';
test('Einzelpost rendert Titel und Markdown-Body', async ({ page }) => {
await page.goto('/dezentrale-oep-oer/');
// Titel steht einmal als .post-title (H1 außerhalb des Artikels),
// und nochmal im Markdown-Body des Events — wir prüfen den ersten.
await expect(page.locator('h1.post-title')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('h1.post-title')).toContainText('Gemeinsam die Bildungszukunft');
await expect(page.locator('article')).toContainText('Open Educational');
});
test('Legacy-URL wird auf kurze Form umgeleitet', async ({ page }) => {
await page.goto('/2025/03/04/dezentrale-oep-oer.html/');
await expect(page).toHaveURL(/\/dezentrale-oep-oer\/$/);
await expect(page.locator('h1.post-title')).toBeVisible({ timeout: 15_000 });
});