|
| 1 | +# 🔗 Kurzlink-Feature (URL Shortener via Nostr) |
| 2 | + |
| 3 | +**Version:** 1.0 |
| 4 | +**Status:** ✅ COMPLETE (26 Unit-Tests) |
| 5 | +**Datum:** 21. Februar 2026 |
| 6 | +**Branch:** `feature/urlshortener` |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## 📋 Übersicht & Motivation |
| 11 | + |
| 12 | +### Das Problem |
| 13 | + |
| 14 | +Die vollständigen Board-URLs enthalten einen langen `naddr`-String (Nostr Addressable Identifier), z.B.: |
| 15 | + |
| 16 | +``` |
| 17 | +https://kanban.edufeed.org/cardsboard/naddr1qqpkucttqy28wumn8ghj7un9d3shjtn... |
| 18 | +``` |
| 19 | + |
| 20 | +Solche URLs sind: |
| 21 | +- ❌ Schwer mündlich zu kommunizieren |
| 22 | +- ❌ Nicht merkbar |
| 23 | +- ❌ QR-Codes werden unnötig groß und schwer scanbar |
| 24 | +- ❌ Ungeeignet für Social-Media-Posts |
| 25 | + |
| 26 | +### Die Lösung |
| 27 | + |
| 28 | +Dezentrale Kurzlinks über **Nostr Addressable Events (Kind 30491)** nach NIP-33. |
| 29 | +Ein kurzer Slug wird auf den vollen `naddr`-String gemappt und auf Nostr-Relays publiziert. |
| 30 | + |
| 31 | +``` |
| 32 | +Vorher: https://kanban.edufeed.org/cardsboard/naddr1qqpkucttqy28wumn8ghj7... |
| 33 | +Nachher: https://kanban.edufeed.org/b/mein-projekt |
| 34 | +``` |
| 35 | + |
| 36 | +**Vorteile:** |
| 37 | +- 🚀 Kurze, merkbare URLs |
| 38 | +- 🌐 Dezentral — kein URL-Shortener-Service nötig |
| 39 | +- 🔁 Deterministisch — Slug wird aus Board-Name generiert |
| 40 | +- ✏️ Editierbar — Nutzer kann Slug vor Publizierung anpassen |
| 41 | +- 📱 Kompakte QR-Codes |
| 42 | + |
| 43 | +--- |
| 44 | + |
| 45 | +## 🚀 Quick Start (Benutzer-Anleitung) |
| 46 | + |
| 47 | +### Kurzlink erstellen |
| 48 | + |
| 49 | +1. Board öffnen → **Share-Button** (Toolbar oben rechts) klicken |
| 50 | +2. Im Dialog ist der **Kurzlink-Tab** bereits aktiv |
| 51 | +3. Ein Slug wird automatisch aus dem Board-Namen generiert (z.B. `mein-projekt`) |
| 52 | +4. Optional: Slug im Eingabefeld bearbeiten |
| 53 | +5. Auf **Kopieren**, **Öffnen** oder **QR-Code** klicken |
| 54 | + - Der Kurzlink wird beim ersten Klick automatisch auf Nostr publiziert |
| 55 | + - Danach wird die gewählte Aktion ausgeführt |
| 56 | +6. Grüne ✓-Markierung zeigt: Slug ist publiziert |
| 57 | + |
| 58 | +### Kurzlink verwenden |
| 59 | + |
| 60 | +Jeder mit dem Link (z.B. `https://kanban.edufeed.org/b/mein-projekt`) wird automatisch zum Board weitergeleitet. |
| 61 | + |
| 62 | +--- |
| 63 | + |
| 64 | +## 🔧 Technische Architektur |
| 65 | + |
| 66 | +### Nostr Event-Struktur (Kind 30491) |
| 67 | + |
| 68 | +| Feld | Wert | Beschreibung | |
| 69 | +|------|------|--------------| |
| 70 | +| `kind` | `30491` | Addressable Event (NIP-33) | |
| 71 | +| `d`-Tag | Slug | z.B. `"mein-projekt"` — macht Event per Slug adressierbar | |
| 72 | +| `r`-Tag | naddr-String | Maschinenlesbarer Board-Link | |
| 73 | +| `a`-Tag | `30301:<pubkey>:<boardId>` | Querverweis zum Board-Event | |
| 74 | +| `title`-Tag | Board-Titel | Für Discovery/Suche | |
| 75 | +| `content` | naddr-String | Human-Readable Fallback | |
| 76 | + |
| 77 | +**Beispiel-Event:** |
| 78 | +```json |
| 79 | +{ |
| 80 | + "kind": 30491, |
| 81 | + "tags": [ |
| 82 | + ["d", "mein-projekt"], |
| 83 | + ["r", "naddr1qqpkucttqy28wumn8ghj7..."], |
| 84 | + ["a", "30301:abc123:board-d-tag"], |
| 85 | + ["title", "Mein Projekt"] |
| 86 | + ], |
| 87 | + "content": "naddr1qqpkucttqy28wumn8ghj7..." |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +### Datenfluss |
| 92 | + |
| 93 | +``` |
| 94 | +ShareDialog (UI) |
| 95 | + │ |
| 96 | + ├─ slugifyBoardName() → Auto-Slug aus Board-Name |
| 97 | + │ |
| 98 | + ├─ [User bearbeitet Slug optional] |
| 99 | + │ |
| 100 | + └─ ensurePublished() → Beim Klick auf Copy/Open/QR |
| 101 | + │ |
| 102 | + ├─ boardStore.publishShortlink(slug) |
| 103 | + │ ├─ nip19.naddrEncode() → naddr generieren |
| 104 | + │ ├─ createShortlinkEvent() → Kind 30491 Event |
| 105 | + │ └─ event.publish() → Auf öffentliche Relays |
| 106 | + │ |
| 107 | + └─ Aktion ausführen (Copy/Open/QR) |
| 108 | +``` |
| 109 | + |
| 110 | +### Resolver-Route (`/b/[slug]`) |
| 111 | + |
| 112 | +``` |
| 113 | +Browser: /b/mein-projekt |
| 114 | + │ |
| 115 | + ├─ +page.ts → slug aus URL extrahieren, prerender=false |
| 116 | + │ |
| 117 | + └─ +page.svelte (onMount) |
| 118 | + ├─ NDK-Bereitschaft abwarten (max. 5s) |
| 119 | + ├─ resolveShortlinkBySlug(slug, ndk) |
| 120 | + │ ├─ Kind 30491, #d=[slug] auf Nostr suchen |
| 121 | + │ └─ Last-Write-Wins bei mehreren Ergebnissen |
| 122 | + └─ goto('/cardsboard/<naddr>', { replaceState: true }) |
| 123 | +``` |
| 124 | + |
| 125 | +**Status-States der Resolver-Seite:** |
| 126 | +- `loading` — Slug wird auf Nostr gesucht (mit Fortschrittsanzeige) |
| 127 | +- `not-found` — Kein Event mit diesem Slug gefunden |
| 128 | +- `error` — NDK nicht bereit oder anderer Fehler |
| 129 | + |
| 130 | +--- |
| 131 | + |
| 132 | +## 📁 Betroffene Dateien |
| 133 | + |
| 134 | +| Datei | Änderung | |
| 135 | +|-------|----------| |
| 136 | +| `src/lib/utils/nostrEvents.ts` | 5 neue Funktionen + `EVENT_KINDS.SHORTLINK = 30491` | |
| 137 | +| `src/lib/stores/kanbanStore.svelte.ts` | `publishShortlink(slug)` Methode | |
| 138 | +| `src/lib/components/board/ShareDialog.svelte` | Kurzlink-Tab mit Auto-Publish UX | |
| 139 | +| `src/routes/b/[slug]/+page.ts` | SvelteKit Load-Funktion | |
| 140 | +| `src/routes/b/[slug]/+page.svelte` | Resolver-Seite (Loading/Error/NotFound) | |
| 141 | +| `src/lib/utils/nostrEvents.spec.ts` | 17 neue Unit-Tests | |
| 142 | + |
| 143 | +--- |
| 144 | + |
| 145 | +## 📚 API-Referenz |
| 146 | + |
| 147 | +### `slugifyBoardName(boardName: string): string` |
| 148 | + |
| 149 | +Generiert einen URL-freundlichen Slug aus einem Board-Namen. |
| 150 | + |
| 151 | +- Umlaute → ASCII (ä→ae, ö→oe, ü→ue, ß→ss) — **vor** NFD-Normalisierung |
| 152 | +- Diakritische Zeichen entfernen |
| 153 | +- Nicht-alphanumerische Zeichen → Bindestrich |
| 154 | +- Max. 48 Zeichen |
| 155 | + |
| 156 | +```typescript |
| 157 | +slugifyBoardName('Mein Tolles Board') // → "mein-tolles-board" |
| 158 | +slugifyBoardName('Übung für Schüler') // → "uebung-fuer-schueler" |
| 159 | +slugifyBoardName('Café & Résumé') // → "cafe-resume" |
| 160 | +``` |
| 161 | + |
| 162 | +### `createShortlinkEvent(slug, naddr, boardId, authorPubkey, boardTitle?, ndk): NDKEvent` |
| 163 | + |
| 164 | +Erstellt ein unsigniertes Kind 30491 Event. |
| 165 | + |
| 166 | +| Parameter | Typ | Beschreibung | |
| 167 | +|-----------|-----|--------------| |
| 168 | +| `slug` | `string` | Das Kürzel (wird zum d-Tag) | |
| 169 | +| `naddr` | `string` | Vollständiger naddr-String | |
| 170 | +| `boardId` | `string` | Board d-Tag | |
| 171 | +| `authorPubkey` | `string` | Hex-Pubkey des Board-Autors | |
| 172 | +| `boardTitle` | `string \| undefined` | Optionaler Board-Titel | |
| 173 | +| `ndk` | `NDK` | NDK-Instanz | |
| 174 | + |
| 175 | +### `resolveShortlink(slug, authorPubkey, ndk): Promise<string | null>` |
| 176 | + |
| 177 | +Löst einen Slug auf, wenn der Author bekannt ist (schneller, gezielter Filter). |
| 178 | + |
| 179 | +### `resolveShortlinkBySlug(slug, ndk): Promise<{ naddr, authorPubkey } | null>` |
| 180 | + |
| 181 | +Löst einen Slug auf **ohne** Author-Kenntnis. Sucht über alle Autoren, nimmt das neueste Event (Last-Write-Wins). |
| 182 | + |
| 183 | +### `boardStore.publishShortlink(slug): Promise<boolean>` |
| 184 | + |
| 185 | +Publiziert ein Shortlink-Event für das aktuelle Board. |
| 186 | +- Generiert naddr via `nip19.naddrEncode()` |
| 187 | +- Publiziert auf öffentliche Relays |
| 188 | +- Gibt `true` bei Erfolg zurück |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +## 🧪 Testing |
| 193 | + |
| 194 | +### Unit-Tests (17 Tests in `nostrEvents.spec.ts`) |
| 195 | + |
| 196 | +``` |
| 197 | +✅ slugifyBoardName |
| 198 | + - Lowercase + Bindestriche |
| 199 | + - Umlaute (ä→ae, ö→oe, ü→ue, ß→ss) |
| 200 | + - Diakritische Zeichen (Café → cafe) |
| 201 | + - Max. 48 Zeichen |
| 202 | + - Leerer String |
| 203 | + - Sonderzeichen |
| 204 | +
|
| 205 | +✅ createShortlinkEvent |
| 206 | + - Kind 30491, d/r/a-Tags |
| 207 | + - Without title → kein title-Tag |
| 208 | + - Content = naddr |
| 209 | +
|
| 210 | +✅ resolveShortlink (4 Tests) |
| 211 | + - Erfolgreiche Auflösung via r-Tag |
| 212 | + - Fallback auf Content wenn kein r-Tag |
| 213 | + - Nicht gefunden → null |
| 214 | + - Leerer Content ohne r-Tag → null |
| 215 | +
|
| 216 | +✅ resolveShortlinkBySlug (5 Tests) |
| 217 | + - Erfolgreiche Auflösung ohne Author-Kenntnis |
| 218 | + - Nicht gefunden (leere Menge) → null |
| 219 | + - Last-Write-Wins bei 3 konkurrierenden Events |
| 220 | + - Fallback auf Content ohne r-Tag |
| 221 | + - fetchEvents → null → null |
| 222 | +``` |
| 223 | + |
| 224 | +--- |
| 225 | + |
| 226 | +## ⚠️ Fehlerbehebung |
| 227 | + |
| 228 | +### Kurzlink wird nicht gefunden |
| 229 | + |
| 230 | +**Mögliche Ursachen:** |
| 231 | +- Board-Autor ist nicht angemeldet (Shortlink braucht signiertes Event) |
| 232 | +- Relays sind nicht erreichbar |
| 233 | +- Slug wurde noch nicht publiziert (grüne ✓-Markierung fehlt) |
| 234 | + |
| 235 | +### QR-Code wird nicht generiert |
| 236 | + |
| 237 | +- QR-Button klicken → Shortlink wird automatisch publiziert → QR wird erzeugt |
| 238 | +- Wenn Slug geändert wird, muss QR erneut generiert werden |
| 239 | + |
| 240 | +### Slug-Kollision |
| 241 | + |
| 242 | +Addressable Events (NIP-33) sind pro Author + d-Tag unique. Verschiedene Autoren können denselben Slug haben. `resolveShortlinkBySlug` nimmt das neueste Event (Last-Write-Wins). |
| 243 | + |
| 244 | +--- |
| 245 | + |
| 246 | +## 🔗 Referenzen |
| 247 | + |
| 248 | +- [Nostr NIP-33: Addressable Events](https://github.com/nostr-protocol/nips/blob/master/33.md) |
| 249 | +- [NIP-19: naddr Encoding](https://github.com/nostr-protocol/nips/blob/master/19.md) |
| 250 | +- [Share-Link Feature (Token-basiert)](./SHARELINK.md) — Verwandtes Feature für Board-Sharing via komprimiertem Token |
| 251 | +- [Kanban-NIP Event Schema](../../Kanban-NIP.md) — Kind 30301/30302 Board/Card Events |
0 commit comments