# SvelteKit-SPA Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Eine produktive SvelteKit-SPA bauen, die Jörgs Nostr-Posts (`kind:30023`) live von Public-Relays rendert, auf `svelte.joerg-lohrer.de` deployed wird und später `joerg-lohrer.de` ablösen soll. **Architecture:** SvelteKit mit `adapter-static` (SSR aus, Fallback `index.html`), `applesauce-relay` + `applesauce-loaders` + `applesauce-signers` für Nostr, `marked` + `DOMPurify` + `highlight.js` für Markdown. Konfiguration zur Laufzeit aus `kind:10002` (Relays) und `kind:10063` (Blossom). Legacy-URLs werden via `history.replaceState` auf kanonische kurze Form normalisiert. **Tech Stack:** SvelteKit 2.x · Svelte 5 · TypeScript · Vite · `applesauce-relay` · `applesauce-loaders` · `applesauce-signers` · `nostr-tools` (nip19 Encoding) · `marked` · `DOMPurify` · `highlight.js` · Vitest · Playwright · `adapter-static` **Scope:** Diese Implementation deckt die vollständige SPA-Spec ab — Home mit Liste + Profil, Einzelpost, Tag-Filter, Reactions und NIP-07-Kommentare. Die Publish-Pipeline (Markdown → Events) ist nicht Teil dieses Plans — sie hat eine eigene Spec und einen separaten Plan. **Ausführungsort:** Branch `spa`, Unterordner `app/` des Repos. --- ## Referenzen - **Spec SPA:** [`docs/superpowers/specs/2026-04-15-nostr-page-design.md`](../specs/2026-04-15-nostr-page-design.md) - **Spec Publish-Pipeline:** [`docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`](../specs/2026-04-15-publish-pipeline-design.md) - **Mini-Spike als funktionierendes Referenz-Verhalten:** [`preview/spa-mini/index.html`](../../../preview/spa-mini/index.html) — live unter `https://spa.joerg-lohrer.de/` - **Autoren-Pubkey (npub):** `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9` - **Autoren-Pubkey (hex):** `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41` - **Bootstrap-Relay:** `wss://relay.damus.io` - **Beispiel-Event zum Testen:** `kind:30023`, `d:dezentrale-oep-oer` (existiert bereits auf allen 5 Relays) --- ## File-Structure ``` app/ ├── package.json ├── svelte.config.js ├── vite.config.ts ├── tsconfig.json ├── .gitignore ├── README.md ├── static/ │ ├── favicon.ico │ └── robots.txt ├── src/ │ ├── app.html # HTML-Shell, -Defaults │ ├── app.d.ts # TypeScript-Ambient-Deklarationen │ ├── hooks.client.ts # Globaler Client-Hook (Fehler-Reporting) │ ├── lib/ │ │ ├── nostr/ │ │ │ ├── config.ts # BOOTSTRAP_RELAY, AUTHOR_PUBKEY_HEX, FALLBACK_READ_RELAYS │ │ │ ├── pool.ts # RelayPool Singleton │ │ │ ├── relays.ts # loadOutboxRelays (kind:10002) │ │ │ ├── blossom.ts # loadBlossomServers (kind:10063) — read-only, für später │ │ │ ├── loaders.ts # loadProfile, loadPostList, loadPost, loadReplies, loadReactions │ │ │ ├── signer.ts # NIP-07-Wrapper │ │ │ └── naddr.ts # nip19.naddrEncode Helper │ │ ├── render/ │ │ │ └── markdown.ts # renderMarkdown(md: string): string │ │ ├── url/ │ │ │ └── legacy.ts # parseLegacyUrl, canonicalPostPath │ │ ├── stores/ │ │ │ └── readRelays.ts # derived Store: aktuelle Read-Relay-Liste │ │ └── components/ │ │ ├── ProfileCard.svelte │ │ ├── PostCard.svelte │ │ ├── PostView.svelte │ │ ├── TagChip.svelte │ │ ├── ReplyList.svelte │ │ ├── ReplyItem.svelte │ │ ├── ReplyComposer.svelte │ │ ├── Reactions.svelte │ │ └── LoadingOrError.svelte │ └── routes/ │ ├── +layout.svelte # Shell (Header nur auf /, Breadcrumb ansonsten) │ ├── +layout.ts # export const prerender = false; ssr = false │ ├── +page.svelte # Home: Profil + Beitragsliste │ ├── +error.svelte # Fehlerseite │ ├── [...slug]/+page.svelte # Catch-all: Legacy-Check, dann Einzelpost │ └── tag/ │ └── [name]/+page.svelte # Tag-Filter ├── tests/ │ ├── unit/ │ │ ├── markdown.test.ts │ │ ├── legacy-url.test.ts │ │ ├── naddr.test.ts │ │ └── loaders.test.ts # gegen Mock-Relay │ └── e2e/ │ ├── home.test.ts │ ├── post.test.ts │ ├── legacy-redirect.test.ts │ └── tag.test.ts └── playwright.config.ts ``` --- ## Phase 1 — Setup (Tasks 1–6) ### Task 1: SvelteKit-Projekt initialisieren **Files:** - Create: `app/package.json` - Create: `app/svelte.config.js` - Create: `app/vite.config.ts` - Create: `app/tsconfig.json` - Create: `app/.gitignore` - Create: `app/src/app.html` - Create: `app/src/app.d.ts` - Create: `app/src/routes/+page.svelte` - Create: `app/static/favicon.ico` - Create: `app/static/robots.txt` - [ ] **Step 1.1: Arbeitsverzeichnis vorbereiten und SvelteKit-Skeleton erzeugen** ```sh cd /Users/joerglohrer/repositories/joerglohrerde mkdir -p app cd app # Nicht-interaktiv: minimal TypeScript-Skeleton anlegen npx --yes sv create . --template minimal --types ts --no-add-ons --install npm ``` Expected: Verzeichnis `app/` hat `package.json`, `svelte.config.js`, `vite.config.ts`, `src/`, `static/`, `node_modules/` installiert. - [ ] **Step 1.2: `svelte.config.js` auf adapter-static umstellen** Ersetze den Inhalt von `app/svelte.config.js` durch: ```js import adapter from '@sveltejs/adapter-static'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { preprocess: vitePreprocess(), kit: { adapter: adapter({ pages: 'build', assets: 'build', fallback: 'index.html', precompress: false, strict: false, }), alias: { $lib: 'src/lib', }, }, }; export default config; ``` - [ ] **Step 1.3: adapter-static als Dependency hinzufügen** ```sh cd app npm install --save-dev @sveltejs/adapter-static ``` Expected: `package.json` hat `"@sveltejs/adapter-static"` in `devDependencies`. - [ ] **Step 1.4: Globales SSR deaktivieren via `+layout.ts`** Create `app/src/routes/+layout.ts`: ```ts export const prerender = false; export const ssr = false; export const trailingSlash = 'always'; ``` - [ ] **Step 1.5: Minimale `+page.svelte` für erste Build-Verifikation** Ersetze Inhalt von `app/src/routes/+page.svelte` durch: ```svelte

SvelteKit-SPA bootet

Wird Stück für Stück mit Nostr-Funktionalität gefüllt.

``` - [ ] **Step 1.6: Build testen** ```sh cd app npm run build ``` Expected: `build/index.html` existiert, keine Fehler. - [ ] **Step 1.7: `app/.gitignore` setzen** Create oder überschreibe `app/.gitignore`: ``` node_modules/ build/ .svelte-kit/ package-lock.json .env .env.local *.log ``` Hinweis: `package-lock.json` wird bewusst nicht committed, weil die Repo-weite Policy das so handhabt. Wenn die Policy später ändert, diese Zeile entfernen. - [ ] **Step 1.8: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/ git commit -m "spa: sveltekit-skeleton mit adapter-static initialisiert" ``` --- ### Task 2: Dependencies installieren und Aliases konfigurieren **Files:** - Modify: `app/package.json` - Modify: `app/tsconfig.json` - [ ] **Step 2.1: Runtime-Dependencies installieren** ```sh cd app npm install \ applesauce-core \ applesauce-relay \ applesauce-loaders \ applesauce-signers \ nostr-tools \ marked \ dompurify \ highlight.js \ rxjs ``` - [ ] **Step 2.2: Dev-Dependencies installieren (Tests)** ```sh cd app npm install --save-dev vitest @playwright/test @testing-library/svelte jsdom ``` - [ ] **Step 2.3: Type-Definitionen für DOMPurify** ```sh cd app npm install --save-dev @types/dompurify ``` - [ ] **Step 2.4: Vitest-Konfiguration erweitern in `vite.config.ts`** Ersetze Inhalt von `app/vite.config.ts` durch: ```ts import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ plugins: [sveltekit()], test: { include: ['tests/unit/**/*.{test,spec}.{js,ts}'], environment: 'jsdom', globals: true, }, }); ``` - [ ] **Step 2.5: npm-Scripts ergänzen in `package.json`** Öffne `app/package.json`, ergänze im `"scripts"`-Objekt: ```json "test:unit": "vitest run", "test:e2e": "playwright test", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "deploy:svelte": "../scripts/deploy-svelte.sh" ``` (Die `build`, `preview`, `dev` Scripts erzeugt SvelteKit initial — nur ergänzen.) - [ ] **Step 2.6: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/ git commit -m "spa: runtime- und dev-dependencies installiert" ``` --- ### Task 3: Konfigurations-Modul `config.ts` **Files:** - Create: `app/src/lib/nostr/config.ts` - [ ] **Step 3.1: Config-Konstanten anlegen** Create `app/src/lib/nostr/config.ts`: ```ts /** * Nostr-Konfiguration der SPA. * * Wichtig: Der AUTHOR_PUBKEY_HEX muss synchron zum tatsächlichen * Autorenkonto sein (siehe docs/superpowers/specs/2026-04-15-nostr-page-design.md). */ /** npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9 in hex */ export const AUTHOR_PUBKEY_HEX = '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41'; /** Bootstrap-Relay für das initiale Lesen von kind:10002 */ export const BOOTSTRAP_RELAY = 'wss://relay.damus.io'; /** Fallback, falls kind:10002 nicht geladen werden kann */ export const FALLBACK_READ_RELAYS = [ 'wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net', 'wss://relay.tchncs.de', 'wss://relay.edufeed.org', ]; /** Habla.news-Deep-Link-Basis (für Nutzer ohne JS oder wenn Events fehlen) */ export const HABLA_BASE = 'https://habla.news/a/'; /** Timeout-Werte in ms */ export const RELAY_TIMEOUT_MS = 8000; export const RELAY_HARD_TIMEOUT_MS = 15000; ``` - [ ] **Step 3.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/nostr/config.ts git commit -m "spa: nostr-konfigurations-modul mit pubkey, bootstrap-relay, fallbacks" ``` --- ### Task 4: URL-Parsing-Modul mit TDD **Files:** - Create: `app/src/lib/url/legacy.ts` - Create: `app/tests/unit/legacy-url.test.ts` - [ ] **Step 4.1: Failing test für `parseLegacyUrl`** Create `app/tests/unit/legacy-url.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy'; describe('parseLegacyUrl', () => { it('extrahiert dtag aus der Hugo-URL-Form mit Trailing-Slash', () => { expect(parseLegacyUrl('/2025/03/04/dezentrale-oep-oer.html/')).toBe( 'dezentrale-oep-oer', ); }); it('extrahiert dtag aus der Hugo-URL-Form ohne Trailing-Slash', () => { expect(parseLegacyUrl('/2024/01/26/offenheit-das-wesentliche.html')).toBe( 'offenheit-das-wesentliche', ); }); it('returned null für die kanonische kurze Form', () => { expect(parseLegacyUrl('/dezentrale-oep-oer/')).toBeNull(); }); it('returned null für leeren Pfad', () => { expect(parseLegacyUrl('/')).toBeNull(); }); it('returned null für andere Strukturen', () => { expect(parseLegacyUrl('/tag/OER/')).toBeNull(); expect(parseLegacyUrl('/some/random/path/')).toBeNull(); }); it('decodiert percent-encoded dtags', () => { expect(parseLegacyUrl('/2024/05/12/mit%20leerzeichen.html/')).toBe( 'mit leerzeichen', ); }); }); describe('canonicalPostPath', () => { it('erzeugt // mit encodeURIComponent', () => { expect(canonicalPostPath('dezentrale-oep-oer')).toBe('/dezentrale-oep-oer/'); }); it('kodiert Sonderzeichen', () => { expect(canonicalPostPath('mit leerzeichen')).toBe('/mit%20leerzeichen/'); }); }); ``` - [ ] **Step 4.2: Test ausführen, erwarte FAIL** ```sh cd app npm run test:unit -- legacy-url ``` Expected: Import-Fehler, weil `$lib/url/legacy` nicht existiert. - [ ] **Step 4.3: Implementation** Create `app/src/lib/url/legacy.ts`: ```ts /** * Erkennt Legacy-Hugo-URLs der Form /YYYY/MM/DD/.html oder .../.html/ * und gibt den dtag-Teil zurück. Für alle anderen Pfade: null. */ export function parseLegacyUrl(path: string): string | null { const match = path.match(/^\/\d{4}\/\d{2}\/\d{2}\/([^/]+?)\.html\/?$/); if (!match) return null; return decodeURIComponent(match[1]); } /** * Erzeugt die kanonische kurze Post-URL //. */ export function canonicalPostPath(dtag: string): string { return `/${encodeURIComponent(dtag)}/`; } ``` - [ ] **Step 4.4: Test ausführen, erwarte PASS** ```sh cd app npm run test:unit -- legacy-url ``` Expected: alle 8 Tests grün. - [ ] **Step 4.5: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/url/legacy.ts app/tests/unit/legacy-url.test.ts git commit -m "spa: url-parser für legacy-hugo-urls (tdd)" ``` --- ### Task 5: naddr-Encoder-Modul mit TDD **Files:** - Create: `app/src/lib/nostr/naddr.ts` - Create: `app/tests/unit/naddr.test.ts` - [ ] **Step 5.1: Failing test** Create `app/tests/unit/naddr.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { buildHablaLink } from '$lib/nostr/naddr'; describe('buildHablaLink', () => { it('erzeugt einen habla.news/a/-Link mit naddr1-Bech32', () => { const link = buildHablaLink({ pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41', kind: 30023, identifier: 'dezentrale-oep-oer', relays: ['wss://relay.damus.io'], }); expect(link).toMatch(/^https:\/\/habla\.news\/a\/naddr1[a-z0-9]+$/); }); it('ist deterministisch für gleiche Inputs', () => { const args = { pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41', kind: 30023, identifier: 'foo', relays: ['wss://relay.damus.io'], }; expect(buildHablaLink(args)).toBe(buildHablaLink(args)); }); }); ``` - [ ] **Step 5.2: Test ausführen (FAIL)** ```sh cd app npm run test:unit -- naddr ``` - [ ] **Step 5.3: Implementation** Create `app/src/lib/nostr/naddr.ts`: ```ts import { nip19 } from 'nostr-tools'; import { HABLA_BASE } from './config'; export interface NaddrArgs { pubkey: string; kind: number; identifier: string; relays?: string[]; } export function buildNaddr(args: NaddrArgs): string { return nip19.naddrEncode({ pubkey: args.pubkey, kind: args.kind, identifier: args.identifier, relays: args.relays ?? [], }); } export function buildHablaLink(args: NaddrArgs): string { return `${HABLA_BASE}${buildNaddr(args)}`; } ``` - [ ] **Step 5.4: Test ausführen (PASS)** ```sh cd app npm run test:unit -- naddr ``` - [ ] **Step 5.5: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/nostr/naddr.ts app/tests/unit/naddr.test.ts git commit -m "spa: naddr/habla-link-helper (tdd)" ``` --- ### Task 6: Deploy-Script für svelte.joerg-lohrer.de **Files:** - Create: `scripts/deploy-svelte.sh` - Create: `scripts/README.md` - [ ] **Step 6.1: Deploy-Script anlegen** Create `scripts/deploy-svelte.sh`: ```bash #!/usr/bin/env bash # Deploy: SvelteKit-Build nach svelte.joerg-lohrer.de per FTPS. # Credentials kommen aus ./.env.local (gitignored), Variablen-Prefix SVELTE_FTP_. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT" if [ ! -f .env.local ]; then echo "FEHLER: .env.local fehlt — Credentials ergänzen (siehe .env.example)." >&2 exit 1 fi # nur SVELTE_FTP_* exportieren set -a # shellcheck disable=SC1090 . <(grep -E '^SVELTE_FTP_' .env.local) set +a for v in SVELTE_FTP_HOST SVELTE_FTP_USER SVELTE_FTP_PASS SVELTE_FTP_REMOTE_PATH; do if [ -z "${!v:-}" ]; then echo "FEHLER: $v fehlt in .env.local." >&2 exit 1 fi done BUILD_DIR="$ROOT/app/build" if [ ! -d "$BUILD_DIR" ]; then echo "FEHLER: app/build nicht vorhanden. Bitte vorher 'npm run build' in app/ ausführen." >&2 exit 1 fi echo "Lade Build von $BUILD_DIR nach ftp://$SVELTE_FTP_HOST$SVELTE_FTP_REMOTE_PATH" # pro Datei ein curl-Upload (zuverlässig auf macOS ohne lftp) find "$BUILD_DIR" -type f -print0 | while IFS= read -r -d '' local_file; do rel="${local_file#$BUILD_DIR/}" remote="ftp://$SVELTE_FTP_HOST${SVELTE_FTP_REMOTE_PATH%/}/$rel" echo " → $rel" curl -sSf --ssl-reqd --ftp-create-dirs \ --user "$SVELTE_FTP_USER:$SVELTE_FTP_PASS" \ -T "$local_file" "$remote" done echo "Upload fertig. Live-Check:" curl -sIL "https://svelte.joerg-lohrer.de/" | head -5 ``` - [ ] **Step 6.2: Script ausführbar machen** ```sh chmod +x scripts/deploy-svelte.sh ``` - [ ] **Step 6.3: Scripts-README anlegen** Create `scripts/README.md`: ```markdown # Scripts - **`deploy-svelte.sh`** — deployed den SvelteKit-Build aus `app/build/` nach `svelte.joerg-lohrer.de` via FTPS. Benötigt `.env.local` im Repo-Root mit den Variablen `SVELTE_FTP_HOST`, `SVELTE_FTP_USER`, `SVELTE_FTP_PASS`, `SVELTE_FTP_REMOTE_PATH`. Aufruf: ```sh cd app && npm run build && cd .. && ./scripts/deploy-svelte.sh ``` ``` - [ ] **Step 6.4: Test-Deploy mit dem Minimal-Build aus Task 1** Voraussetzung: `SVELTE_FTP_*` in `.env.local` ist ausgefüllt und `svelte.joerg-lohrer.de` hat SSL aktiv. ```sh cd app && npm run build && cd .. ./scripts/deploy-svelte.sh ``` Expected: Upload läuft durch, `curl -sI https://svelte.joerg-lohrer.de/` liefert HTTP 200 und Inhalt zeigt „SvelteKit-SPA bootet". - [ ] **Step 6.5: `.htaccess` für SPA-Fallback anlegen (analog zu Mini-Spike)** Create `app/static/.htaccess`: ```apache RewriteEngine On # HTTPS forcieren RewriteCond %{HTTPS} !=on RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] # Existierende Datei oder Verzeichnis? Direkt ausliefern. RewriteCond %{REQUEST_FILENAME} -f [OR] RewriteCond %{REQUEST_FILENAME} -d RewriteRule ^ - [L] # Alles andere → SPA-Fallback (SvelteKit mit adapter-static) RewriteRule ^ /index.html [L] ``` Rebuild und redeploy: ```sh cd app && npm run build && cd .. ./scripts/deploy-svelte.sh ``` - [ ] **Step 6.6: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add scripts/ app/static/.htaccess git commit -m "spa: deploy-script und htaccess für svelte.joerg-lohrer.de" ``` --- ## Phase 2 — Datenebene (Tasks 7–14) ### Task 7: Markdown-Renderer mit TDD **Files:** - Create: `app/src/lib/render/markdown.ts` - Create: `app/tests/unit/markdown.test.ts` - [ ] **Step 7.1: Failing test** Create `app/tests/unit/markdown.test.ts`: ```ts import { describe, expect, it } from 'vitest'; import { renderMarkdown } from '$lib/render/markdown'; describe('renderMarkdown', () => { it('rendert einfachen Markdown-Text zu HTML', () => { const html = renderMarkdown('**bold** and *italic*'); expect(html).toContain('bold'); expect(html).toContain('italic'); }); it('entfernt world'); expect(html).not.toContain(' {#if loading && !error}

Lade von Nostr-Relays …

{:else if error}

{error} {#if hablaLink}
In Habla.news öffnen {/if}

{/if} ``` - [ ] **Step 15.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/components/LoadingOrError.svelte git commit -m "spa: loading-or-error-komponente" ``` --- ### Task 16: Globales Styling im `app.html` **Files:** - Modify: `app/src/app.html` - [ ] **Step 16.1: app.html mit CSS-Variablen und Base-Layout** Ersetze Inhalt von `app/src/app.html` durch: ```html Jörg Lohrer %sveltekit.head%
%sveltekit.body%
``` - [ ] **Step 16.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/app.html git commit -m "spa: globales styling mit css-variablen im app.html" ``` --- ### Task 17: `+layout.svelte` mit Container und Bootstrap **Files:** - Create: `app/src/routes/+layout.svelte` - [ ] **Step 17.1: Layout-Komponente mit Relay-Bootstrap** Create `app/src/routes/+layout.svelte`: ```svelte
{@render children()}
``` - [ ] **Step 17.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/routes/+layout.svelte git commit -m "spa: layout mit container und relay-bootstrap" ``` --- ### Task 18: `ProfileCard.svelte` **Files:** - Create: `app/src/lib/components/ProfileCard.svelte` - [ ] **Step 18.1: Komponente anlegen** Create `app/src/lib/components/ProfileCard.svelte`: ```svelte {#if profile}
{#if profile.picture} {profile.display_name {:else}
{/if}
{profile.display_name ?? profile.name ?? ''}
{#if profile.about}
{profile.about}
{/if} {#if profile.nip05 || profile.website}
{#if profile.nip05}{profile.nip05}{/if} {#if profile.nip05 && profile.website}·{/if} {#if profile.website} {profile.website.replace(/^https?:\/\//, '')} {/if}
{/if}
{/if} ``` - [ ] **Step 18.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/components/ProfileCard.svelte git commit -m "spa: profile-card komponente" ``` --- ### Task 19: `PostCard.svelte` (Listenelement) **Files:** - Create: `app/src/lib/components/PostCard.svelte` - [ ] **Step 19.1: Komponente anlegen** Create `app/src/lib/components/PostCard.svelte`: ```svelte
{date}

{title}

{#if summary}

{summary}

{/if}
``` - [ ] **Step 19.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/components/PostCard.svelte git commit -m "spa: post-card listenelement" ``` --- ### Task 20: Home-Page `+page.svelte` **Files:** - Modify: `app/src/routes/+page.svelte` - [ ] **Step 20.1: Home-Page mit Profil + Liste** Ersetze Inhalt von `app/src/routes/+page.svelte` durch: ```svelte

Beiträge

{#each posts as post (post.id)} {/each} ``` - [ ] **Step 20.2: Lokal testen** ```sh cd app npm run dev ``` Öffne `http://localhost:5173/`. Erwartung: Profilkachel oben, „Beiträge"-Überschrift, Liste der publizierten Posts mit Thumbnails. - [ ] **Step 20.3: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/routes/+page.svelte git commit -m "spa: home-page mit profil und beitragsliste" ``` --- ### Task 21: `PostView.svelte` Komponente **Files:** - Create: `app/src/lib/components/PostView.svelte` - [ ] **Step 21.1: Komponente anlegen** Create `app/src/lib/components/PostView.svelte`: ```svelte

{title}

Veröffentlicht am {date} {#if tags.length > 0}
{#each tags as t} {t} {/each}
{/if}
{#if image}

Cover-Bild

{/if} {#if summary}

{summary}

{/if}
{@html bodyHtml}
``` - [ ] **Step 21.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/components/PostView.svelte git commit -m "spa: post-view komponente mit markdown-rendering" ``` --- ### Task 22: Catch-all-Route `[...slug]/+page.svelte` mit Legacy-Normalisierung **Files:** - Create: `app/src/routes/[...slug]/+page.svelte` - Create: `app/src/routes/[...slug]/+page.ts` - [ ] **Step 22.1: Route-Load-Funktion** Create `app/src/routes/[...slug]/+page.ts`: ```ts import { error, redirect } from '@sveltejs/kit'; import { parseLegacyUrl, canonicalPostPath } from '$lib/url/legacy'; import type { PageLoad } from './$types'; export const load: PageLoad = async ({ url }) => { const pathname = url.pathname; // Legacy-Form /YYYY/MM/DD/.html/ → Redirect auf // const legacyDtag = parseLegacyUrl(pathname); if (legacyDtag) { throw redirect(301, canonicalPostPath(legacyDtag)); } // Kanonisch: // — erster Segment des Pfades. const segments = pathname.replace(/^\/+|\/+$/g, '').split('/'); if (segments.length !== 1 || !segments[0]) { throw error(404, 'Seite nicht gefunden'); } return { dtag: decodeURIComponent(segments[0]) }; }; ``` - [ ] **Step 22.2: PostView-Seite** Create `app/src/routes/[...slug]/+page.svelte`: ```svelte {#if post} {/if} ``` - [ ] **Step 22.3: Lokal testen** ```sh cd app npm run dev ``` Öffne `http://localhost:5173/dezentrale-oep-oer/`. Erwartung: Post rendert. Breadcrumb „← Zurück zur Übersicht" funktioniert. Teste auch `http://localhost:5173/2025/03/04/dezentrale-oep-oer.html/` — Browser sollte auf `/dezentrale-oep-oer/` umleiten und Post zeigen. - [ ] **Step 22.4: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/routes/ git commit -m "spa: catch-all-route mit legacy-redirect und postview" ``` --- ## Phase 4 — Tag-Navigation (Tasks 23–25) ### Task 23: Tag-Filter-Loader **Files:** - Modify: `app/src/lib/nostr/loaders.ts` (ergänzen) - [ ] **Step 23.1: `loadPostsByTag` hinzufügen** Am Ende von `app/src/lib/nostr/loaders.ts` anhängen: ```ts /** * Filtert Post-Liste clientseitig nach Tag-Name. * (Relay-seitige #t-Filter werden nicht von allen Relays unterstützt — safer * ist es, die ganze Liste zu laden und lokal zu filtern.) */ export async function loadPostsByTag(tagName: string): Promise { const all = await loadPostList(); const norm = tagName.toLowerCase(); return all.filter((ev) => ev.tags.some((t) => t[0] === 't' && t[1]?.toLowerCase() === norm), ); } ``` - [ ] **Step 23.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/nostr/loaders.ts git commit -m "spa: tag-filter-loader (case-insensitive, client-side)" ``` --- ### Task 24: Tag-Seite **Files:** - Create: `app/src/routes/tag/[name]/+page.ts` - Create: `app/src/routes/tag/[name]/+page.svelte` - [ ] **Step 24.1: Load-Funktion** Create `app/src/routes/tag/[name]/+page.ts`: ```ts import type { PageLoad } from './$types'; export const load: PageLoad = async ({ params }) => { return { tagName: decodeURIComponent(params.name) }; }; ``` - [ ] **Step 24.2: Seite** Create `app/src/routes/tag/[name]/+page.svelte`: ```svelte

#{tagName}

{#each posts as post (post.id)} {/each} ``` - [ ] **Step 24.3: Lokal testen** ```sh cd app npm run dev ``` Öffne `http://localhost:5173/tag/OER/`. Erwartung: Liste aller Posts mit `t`-Tag „OER". - [ ] **Step 24.4: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/routes/tag/ git commit -m "spa: tag-filter-seite" ``` --- ### Task 25: Tag-Link-Sicherheit fixen **Files:** - Modify: `app/src/lib/components/PostView.svelte:63-65` Hintergrund: in Task 21 wurde `href="/tag/{encodeURIComponent(t)}/"` schon gesetzt — sollte passen. Verifizieren. - [ ] **Step 25.1: Verifizieren** Öffne `http://localhost:5173/dezentrale-oep-oer/`. Klick auf Tag „OER". Erwartung: Navigation zu `/tag/OER/`, Liste wird gefiltert. - [ ] **Step 25.2: Keine Änderung nötig, Verifikations-Step. Commit wird übersprungen, wenn nichts geändert wurde.** --- ## Phase 5 — Reactions & NIP-07-Kommentare (Tasks 26–32) ### Task 26: `Reactions.svelte` **Files:** - Create: `app/src/lib/components/Reactions.svelte` - [ ] **Step 26.1: Komponente anlegen** Create `app/src/lib/components/Reactions.svelte`: ```svelte {#if reactions.length > 0}
{#each reactions as r} {displayChar(r.content)} {r.count} {/each}
{/if} ``` - [ ] **Step 26.2: In PostView einbinden** Modify `app/src/lib/components/PostView.svelte` — am Anfang des `
  • {authorNpub} · {date}
    {event.content}
  • ``` - [ ] **Step 27.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/components/ReplyItem.svelte git commit -m "spa: reply-item komponente" ``` --- ### Task 28: `ReplyList.svelte` **Files:** - Create: `app/src/lib/components/ReplyList.svelte` - [ ] **Step 28.1: Komponente anlegen** Create `app/src/lib/components/ReplyList.svelte`: ```svelte

    Kommentare ({replies.length})

    {#if loading}

    Lade Kommentare …

    {:else if replies.length === 0}

    Noch keine Kommentare.

    {:else}
      {#each replies as reply (reply.id)} {/each}
    {/if}
    ``` - [ ] **Step 28.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/components/ReplyList.svelte git commit -m "spa: reply-list komponente" ``` --- ### Task 29: `ReplyComposer.svelte` **Files:** - Create: `app/src/lib/components/ReplyComposer.svelte` - [ ] **Step 29.1: Komponente anlegen** Create `app/src/lib/components/ReplyComposer.svelte`: ```svelte
    {#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}
    ``` - [ ] **Step 29.2: Commit** ```sh cd /Users/joerglohrer/repositories/joerglohrerde git add app/src/lib/components/ReplyComposer.svelte git commit -m "spa: reply-composer mit nip-07 signing" ``` --- ### Task 30: Replies und Composer in PostView integrieren **Files:** - Modify: `app/src/lib/components/PostView.svelte` - Modify: `app/src/routes/[...slug]/+page.svelte` - [ ] **Step 30.1: PostView um Replies + Composer erweitern** Modify `app/src/lib/components/PostView.svelte` — im `