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">
|
||||
import type { NostrEvent } from '$lib/nostr/loaders';
|
||||
import type { SignedEvent } from '$lib/nostr/signer';
|
||||
import { renderMarkdown } from '$lib/render/markdown';
|
||||
import Reactions from './Reactions.svelte';
|
||||
import ReplyList from './ReplyList.svelte';
|
||||
import ReplyComposer from './ReplyComposer.svelte';
|
||||
|
||||
interface Props {
|
||||
event: NostrEvent;
|
||||
|
|
@ -14,6 +18,7 @@
|
|||
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 summary = $derived(tagValue(event, 'summary'));
|
||||
const image = $derived(tagValue(event, 'image'));
|
||||
|
|
@ -30,6 +35,13 @@
|
|||
const tags = $derived(tagsAll(event, 't'));
|
||||
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(() => {
|
||||
document.title = `${title} – Jörg Lohrer`;
|
||||
});
|
||||
|
|
@ -57,6 +69,12 @@
|
|||
|
||||
<article>{@html bodyHtml}</article>
|
||||
|
||||
{#if dtag}
|
||||
<Reactions {dtag} />
|
||||
<ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} />
|
||||
<ReplyList {dtag} optimistic={optimisticReplies} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.post-title {
|
||||
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