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:
parent
c089d9e429
commit
3b0f059cea
|
|
@ -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
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"status": "passed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue