diff --git a/app/playwright.config.ts b/app/playwright.config.ts
new file mode 100644
index 0000000..676a7b3
--- /dev/null
+++ b/app/playwright.config.ts
@@ -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
+});
diff --git a/app/src/lib/components/PostView.svelte b/app/src/lib/components/PostView.svelte
index 0b06b2a..f838d01 100644
--- a/app/src/lib/components/PostView.svelte
+++ b/app/src/lib/components/PostView.svelte
@@ -1,6 +1,10 @@
+
+{#if reactions.length > 0}
+
+ {#each reactions as r}
+
+ {displayChar(r.content)}
+ {r.count}
+
+ {/each}
+
+{/if}
+
+
diff --git a/app/src/lib/components/ReplyComposer.svelte b/app/src/lib/components/ReplyComposer.svelte
new file mode 100644
index 0000000..49918b2
--- /dev/null
+++ b/app/src/lib/components/ReplyComposer.svelte
@@ -0,0 +1,148 @@
+
+
+
+ {#if !nip07}
+
+ Um zu kommentieren, benötigst du eine Nostr-Extension
+ (Alby,
+ nos2x), oder
+ kommentiere direkt in einem Nostr-Client.
+
+ {:else}
+
+
+
+
+ {#if error}
{error}
{/if}
+ {#if info}
{info}
{/if}
+ {/if}
+
+
+
diff --git a/app/src/lib/components/ReplyItem.svelte b/app/src/lib/components/ReplyItem.svelte
new file mode 100644
index 0000000..63d746f
--- /dev/null
+++ b/app/src/lib/components/ReplyItem.svelte
@@ -0,0 +1,44 @@
+
+
+
+
+ {authorNpub}
+ ·
+ {date}
+
+ {event.content}
+
+
+
diff --git a/app/src/lib/components/ReplyList.svelte b/app/src/lib/components/ReplyList.svelte
new file mode 100644
index 0000000..3bff962
--- /dev/null
+++ b/app/src/lib/components/ReplyList.svelte
@@ -0,0 +1,68 @@
+
+
+
+ Kommentare ({merged.length})
+ {#if loading}
+ Lade Kommentare …
+ {:else if merged.length === 0}
+ Noch keine Kommentare.
+ {:else}
+
+ {#each merged as reply (reply.id)}
+
+ {/each}
+
+ {/if}
+
+
+
diff --git a/app/test-results/.last-run.json b/app/test-results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/app/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/app/tests/e2e/home.test.ts b/app/tests/e2e/home.test.ts
new file mode 100644
index 0000000..443ed31
--- /dev/null
+++ b/app/tests/e2e/home.test.ts
@@ -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 });
+});
diff --git a/app/tests/e2e/post.test.ts b/app/tests/e2e/post.test.ts
new file mode 100644
index 0000000..1f32c9b
--- /dev/null
+++ b/app/tests/e2e/post.test.ts
@@ -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 });
+});