# 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}
{:else}
{/if}
{profile.display_name ?? profile.name ?? ''}
{#if profile.about}
{profile.about}
{/if}
{#if profile.nip05 || profile.website}
{/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}
{#if image}
{/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
← Zurück zur Übersicht
{#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
← Zurück zur Übersicht
#{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}
{publishing ? 'Sende …' : 'Kommentar senden'}
{#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 `