Compare commits
No commits in common. "main" and "hugo-archive" have entirely different histories.
main
...
hugo-archi
|
|
@ -6,8 +6,7 @@
|
||||||
"Bash(git commit -m ':*)",
|
"Bash(git commit -m ':*)",
|
||||||
"Bash(git rm:*)",
|
"Bash(git rm:*)",
|
||||||
"Bash(git commit:*)",
|
"Bash(git commit:*)",
|
||||||
"Bash(git checkout:*)",
|
"Bash(git checkout:*)"
|
||||||
"Bash(git submodule:*)"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,185 +0,0 @@
|
||||||
---
|
|
||||||
name: joerglohrerde-workflow
|
|
||||||
description: Repo-spezifischer Skill für joerglohrerde. Nutze ihn bei jedem Session-Start, um den aktuellen Zustand zu erfassen, Konventionen zu verstehen und wiederkehrende Workflows (Deploy, Publish, Tests, Multilingual) effizient auszuführen.
|
|
||||||
---
|
|
||||||
|
|
||||||
# joerglohrerde — Session-Skill
|
|
||||||
|
|
||||||
Dieses Repo ist die persönliche Webseite von Jörg Lohrer: eine dezentrale
|
|
||||||
Nostr-basierte SvelteKit-SPA, die NIP-23-Langform-Events live von Public-
|
|
||||||
Relays rendert. Seit 2026-04-21 mehrsprachig (DE/EN) via `svelte-i18n` +
|
|
||||||
NIP-33-`a`-Tags.
|
|
||||||
|
|
||||||
## Beim Session-Start IMMER zuerst
|
|
||||||
|
|
||||||
1. **Lies `CLAUDE.md`** — Agent-spezifische Konventionen (Commit-Stil,
|
|
||||||
Deploy-Falle, Globbing-Hinweise).
|
|
||||||
2. **Lies `docs/STATUS.md`** — aktueller Projektstand, Live-URLs.
|
|
||||||
3. **Lies `docs/HANDOFF.md`** — nächste Schritte, Stolperfallen,
|
|
||||||
Alltags-Workflow für neue Posts + Übersetzungen.
|
|
||||||
4. Bei konkreten Aufgaben: zugehörige Spec unter `docs/superpowers/specs/`
|
|
||||||
oder Plan unter `docs/superpowers/plans/`.
|
|
||||||
5. Branch-Check: `git log --oneline -10 main`.
|
|
||||||
|
|
||||||
Dann erst Rückfragen oder Vorschläge formulieren.
|
|
||||||
|
|
||||||
## Live-URLs
|
|
||||||
|
|
||||||
| URL | Rolle |
|
|
||||||
|---|---|
|
|
||||||
| `joerg-lohrer.de` | **Produktion**, SvelteKit-SPA (Cutover 2026-04-18, multilingual seit 2026-04-21) |
|
|
||||||
| `staging.joerg-lohrer.de` | Pre-Prod-Build |
|
|
||||||
| `svelte.joerg-lohrer.de` | Entwicklungs-Deploy-Target (historischer Default) |
|
|
||||||
| `spa.joerg-lohrer.de` | Vanilla-HTML-Spike (historisch) |
|
|
||||||
|
|
||||||
**Wichtig:** `scripts/deploy-svelte.sh` hat `DEPLOY_TARGET=svelte` als
|
|
||||||
Default — das zielt auf `svelte.joerg-lohrer.de`, NICHT auf die
|
|
||||||
Produktion. Für Prod-Deploy IMMER `DEPLOY_TARGET=prod` explizit setzen.
|
|
||||||
|
|
||||||
## Git-Branches
|
|
||||||
|
|
||||||
- `main` — kanonisch, alle Arbeit läuft hier direkt.
|
|
||||||
- `hugo-archive` — Orphan, eingefrorener Hugo-Zustand (Rollback-Option).
|
|
||||||
|
|
||||||
`spa` aus der Pre-Cutover-Phase ist gemerged und historisch.
|
|
||||||
|
|
||||||
## Sprache und Ton
|
|
||||||
|
|
||||||
- Antworten und Commit-Messages auf **Deutsch** (Kundensprache).
|
|
||||||
- Code-Identifier (Variablen, Funktionen, Typen) auf Englisch.
|
|
||||||
- Kurz und konkret — Jörg ist technisch versiert, erwartet keine
|
|
||||||
Grundlagen-Erklärungen.
|
|
||||||
- Commit-Präfixe: `feat`, `fix`, `chore`, `docs`, `test` (conventional).
|
|
||||||
- Co-Author: `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`.
|
|
||||||
|
|
||||||
## Kernkonventionen
|
|
||||||
|
|
||||||
### Content-Struktur
|
|
||||||
|
|
||||||
- Markdown-Posts pro Sprache: `content/posts/<lang>/<slug>/index.md`.
|
|
||||||
- Slug ist global eindeutig (also NICHT identisch zwischen Sprach-Varianten).
|
|
||||||
Der Slug wird zum `d`-Tag des Events und zur URL (`/<slug>/`).
|
|
||||||
- Sprach-Differenzierung über `l`-Tag (NIP-32), nicht über den Slug.
|
|
||||||
- Bidirektionale Verlinkung zwischen Sprach-Varianten via `a:`-Frontmatter,
|
|
||||||
wird als `['a', '<coord>', '', 'translation']` ins Event geschrieben.
|
|
||||||
|
|
||||||
### URL-Schema
|
|
||||||
|
|
||||||
- Post-URL: `/<slug>/` (z. B. `/bibel-selfies/`, `/bible-selfies/`). Keine
|
|
||||||
Sprach-Präfixe in der URL.
|
|
||||||
- Legacy-Hugo-URLs `/YYYY/MM/DD/<dtag>.html/` werden 301-redirected.
|
|
||||||
- Tag-Route: `/tag/<name>/`.
|
|
||||||
|
|
||||||
### Nostr-Konstanten
|
|
||||||
|
|
||||||
- Pubkey (hex): `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41`
|
|
||||||
- npub: `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9`
|
|
||||||
- Bootstrap-Relay: `wss://relay.damus.io`
|
|
||||||
- Relay-Liste: aus `kind:10002` des Autors (zur Laufzeit geladen).
|
|
||||||
- Blossom-Server: aus `kind:10063` des Autors.
|
|
||||||
- Zentralisiert in `app/src/lib/nostr/config.ts` bzw. `.env.local`.
|
|
||||||
|
|
||||||
### Signing
|
|
||||||
|
|
||||||
- **Im Browser (Kommentare):** NIP-07 via Extension (Alby, nos2x).
|
|
||||||
- **Aus der Kommandozeile (Publish):** NIP-46 via Amber-Bunker.
|
|
||||||
- Privater Schlüssel **nie** im Repo, nie in CI-Secrets direkt.
|
|
||||||
|
|
||||||
## Wiederkehrende Kommandos
|
|
||||||
|
|
||||||
### SPA-Entwicklung
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd app
|
|
||||||
npm run dev # Dev-Server localhost:5173
|
|
||||||
npm run check # Type-Check (svelte-check)
|
|
||||||
npm run test:unit # Vitest
|
|
||||||
npm run test:e2e # Playwright
|
|
||||||
npm run build # Prod-Build nach app/build/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Publish-Pipeline
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd publish
|
|
||||||
deno task check # pre-flight (Bunker, Relays, Blossom)
|
|
||||||
deno task publish --dry-run # diff-modus simulation
|
|
||||||
deno task publish # diff-modus real
|
|
||||||
deno task publish --force-all # alle 27 Posts
|
|
||||||
deno task publish --post <slug> # einzelner Post
|
|
||||||
deno task delete --event-id <hex> --reason "…" # NIP-09-Löschung
|
|
||||||
deno task validate-post ../content/posts/<lang>/<dir>/index.md
|
|
||||||
deno task test # Tests (73)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploy
|
|
||||||
|
|
||||||
```sh
|
|
||||||
DEPLOY_TARGET=staging ./scripts/deploy-svelte.sh # Pre-Prod
|
|
||||||
DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh # Prod (joerg-lohrer.de)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Nostr-Status checken
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Alle publizierten kind:30023-Events des Autors (inkl. l-Tag + a-Tags)
|
|
||||||
nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.damus.io 2>/dev/null | jq -c '{d: (.tags[] | select(.[0]=="d") | .[1]), l: (.tags[] | select(.[0]=="l") | .[1]), title: (.tags[] | select(.[0]=="title") | .[1])}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tech-Stack-Eigenheiten, die man kennen muss
|
|
||||||
|
|
||||||
1. **Svelte 5 Runes:** `$props()`-Werte via `$derived()` in lokale Variablen.
|
|
||||||
`$effect(() => { … event.id })` statt `onMount`, wenn bei Prop-Änderung
|
|
||||||
neu geladen werden muss (siehe `[...slug]/+page.svelte`).
|
|
||||||
|
|
||||||
2. **applesauce-relay v5.x API:** RxJS-basiert. `pool.request(relays, filter)`
|
|
||||||
liefert `Observable<NostrEvent>`. Die Loader in `app/src/lib/nostr/loaders.ts`
|
|
||||||
nutzen `toArray() + lastValueFrom + timeout + catchError`-Pattern.
|
|
||||||
|
|
||||||
3. **DOMPurify braucht DOM:** Early-Fail-Guard für Node-Aufrufe im
|
|
||||||
`renderMarkdown`-Helper. SSR ist ohnehin aus (`ssr = false` im Layout).
|
|
||||||
|
|
||||||
4. **All-Inkl-FTPS-Bug:** Data-Connection bricht bei TLS 1.3 ab.
|
|
||||||
`--tls-max 1.2` im curl-Call. Sobald SSH auf All-Inkl verfügbar ist
|
|
||||||
(Premium-Tarif angefragt), Umstellung auf rsync möglich.
|
|
||||||
|
|
||||||
5. **Amber-Bunker-Session:** bei neuer Bunker-URL müssen globale
|
|
||||||
Permissions in Amber auf „Allow + Always" für `get_public_key` und
|
|
||||||
`sign_event` gesetzt werden.
|
|
||||||
|
|
||||||
6. **Forgejo→GitHub Push-Mirror:** `git push` geht nach Forgejo, die
|
|
||||||
Action läuft auf GitHub (nachdem Forgejo gespiegelt hat). Push → Mirror →
|
|
||||||
Action braucht typisch 1–2 Minuten.
|
|
||||||
|
|
||||||
7. **svelte-i18n + activeLocale:** `$t('key')` in Templates, `get(t)('key')`
|
|
||||||
in imperativem Script-Code. `activeLocale` ist der projekteigene Store
|
|
||||||
(persistiert via `localStorage`), `locale` aus svelte-i18n wird
|
|
||||||
automatisch synchronisiert.
|
|
||||||
|
|
||||||
8. **zsh-Globbing:** Pfade mit eckigen Klammern (z. B. `app/src/routes/[...slug]/`)
|
|
||||||
müssen in `git add` in einfachen Anführungszeichen stehen, sonst
|
|
||||||
interpretiert zsh das als Glob-Pattern.
|
|
||||||
|
|
||||||
## Wie mit Jörg arbeiten
|
|
||||||
|
|
||||||
- **Kurze Antworten**, konkrete Optionen, keine Grundlagen-Erklärungen.
|
|
||||||
- Bei mehreren Wegen: 2–3 Varianten mit Empfehlung nennen, nicht alles
|
|
||||||
aufzählen.
|
|
||||||
- Spec-Updates auf `main` committen, dort läuft alle Arbeit.
|
|
||||||
- Nach Feature-Commits: Build + Deploy, damit Jörg live sehen kann.
|
|
||||||
UI-Feedback fängt Layout-Fragen ab, die Tests nicht entdecken.
|
|
||||||
- Vor Subagent-Dispatch: kritische API-Details verifizieren
|
|
||||||
(Plan-Annahmen können veraltet sein).
|
|
||||||
|
|
||||||
## Credentials / Secrets
|
|
||||||
|
|
||||||
Alle in `.env.local` (gitignored):
|
|
||||||
- `BUNKER_URL` — Amber-NIP-46-Pairing für Signaturen
|
|
||||||
- `CLIENT_SECRET_HEX` — identisch mit GitHub-Secret (stabile App-ID in Amber)
|
|
||||||
- `AUTHOR_PUBKEY_HEX`, `BOOTSTRAP_RELAY`
|
|
||||||
- `SVELTE_FTP_*`, `STAGING_FTP_*` — FTPS-Credentials pro Deploy-Target
|
|
||||||
|
|
||||||
Falls neue Bunker-URL nötig (Amber-Session kaputt):
|
|
||||||
- In Amber neue Bunker-URL generieren
|
|
||||||
- In `.env.local` ersetzen
|
|
||||||
- In Amber globale Permissions für die App löschen, sonst hängt der Request
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
name: Publish Nostr Events
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths: ['content/posts/**']
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
force_all:
|
|
||||||
description: 'Publish all posts (--force-all)'
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: denoland/setup-deno@v2
|
|
||||||
with:
|
|
||||||
deno-version: v2.x
|
|
||||||
|
|
||||||
- name: Pre-Flight Check
|
|
||||||
working-directory: ./publish
|
|
||||||
env:
|
|
||||||
BUNKER_URL: ${{ secrets.BUNKER_URL }}
|
|
||||||
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
|
|
||||||
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
|
|
||||||
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
|
|
||||||
run: |
|
|
||||||
deno run --allow-env --allow-read --allow-net src/cli.ts check
|
|
||||||
|
|
||||||
- name: Publish
|
|
||||||
working-directory: ./publish
|
|
||||||
env:
|
|
||||||
BUNKER_URL: ${{ secrets.BUNKER_URL }}
|
|
||||||
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
|
|
||||||
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
|
|
||||||
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
|
|
||||||
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event.inputs.force_all }}" = "true" ]; then
|
|
||||||
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish --force-all
|
|
||||||
else
|
|
||||||
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish
|
|
||||||
fi
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: publish-log
|
|
||||||
path: ./publish/logs/publish-*.json
|
|
||||||
retention-days: 30
|
|
||||||
|
|
@ -1,4 +1 @@
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
logs/
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "themes/tailwind"]
|
||||||
|
path = themes/tailwind
|
||||||
|
url = https://github.com/tomowang/hugo-theme-tailwind.git
|
||||||
105
CLAUDE.md
|
|
@ -1,105 +0,0 @@
|
||||||
# CLAUDE.md — Einstieg für Claude-Sessions
|
|
||||||
|
|
||||||
Dieser Einstieg ist für Claude-Code-Sessions gedacht. Für den inhaltlichen
|
|
||||||
Projektstand siehe [`docs/STATUS.md`](docs/STATUS.md) und
|
|
||||||
[`docs/HANDOFF.md`](docs/HANDOFF.md).
|
|
||||||
|
|
||||||
## Was dieses Repo ist
|
|
||||||
|
|
||||||
Die persönliche Webseite [`joerg-lohrer.de`](https://joerg-lohrer.de/) als
|
|
||||||
SvelteKit-SPA, die Blog-Posts live aus Nostr-Events (NIP-23, `kind:30023`)
|
|
||||||
auf 5 Public-Relays rendert. Seit 2026-04-21 mehrsprachig (DE/EN).
|
|
||||||
|
|
||||||
## Einstiegsreihenfolge
|
|
||||||
|
|
||||||
1. Diese Datei (Agent-Konventionen, Fallstricke).
|
|
||||||
2. [`docs/STATUS.md`](docs/STATUS.md) — wo steht alles gerade.
|
|
||||||
3. [`docs/HANDOFF.md`](docs/HANDOFF.md) — Alltags-Workflow, Stolperfallen.
|
|
||||||
4. Für konkrete Aufgaben: Spec unter `docs/superpowers/specs/`, Plan unter
|
|
||||||
`docs/superpowers/plans/`.
|
|
||||||
|
|
||||||
## Sprache und Ton
|
|
||||||
|
|
||||||
- **Antworten und Commit-Messages auf Deutsch.**
|
|
||||||
- Code-Identifier auf Englisch.
|
|
||||||
- Kurz, konkret, kein Grundlagen-Tutorial. Jörg ist technisch versiert.
|
|
||||||
- Bei mehreren Wegen: 2–3 Varianten mit Empfehlung, nicht alles aufzählen.
|
|
||||||
|
|
||||||
## Commit-Konvention
|
|
||||||
|
|
||||||
- Conventional-Commit-Präfixe: `feat`, `fix`, `chore`, `docs`, `test`.
|
|
||||||
- Imperativ, Deutsch, Body erklärt das *Warum*.
|
|
||||||
- Co-Author immer ergänzen:
|
|
||||||
```
|
|
||||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Kritische Fallstricke
|
|
||||||
|
|
||||||
### 1. Deploy-Target
|
|
||||||
|
|
||||||
`scripts/deploy-svelte.sh` hat `DEPLOY_TARGET=svelte` als Default —
|
|
||||||
das zielt auf `svelte.joerg-lohrer.de`, NICHT auf die Produktion.
|
|
||||||
|
|
||||||
Für Live-Deploy auf `joerg-lohrer.de`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Immer explizit setzen.** Der stumme Default-Fehler ist nur sichtbar,
|
|
||||||
wenn man die Live-Seite kontrolliert. Reproduzierbar als Memory-Entry
|
|
||||||
im Claude-Memory-System.
|
|
||||||
|
|
||||||
### 2. zsh-Globbing mit eckigen Klammern
|
|
||||||
|
|
||||||
SvelteKit-Routen wie `app/src/routes/[...slug]/+page.svelte` enthalten
|
|
||||||
eckige Klammern, die zsh als Glob-Pattern interpretiert. Pfade IMMER in
|
|
||||||
einfachen Anführungszeichen:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git add 'app/src/routes/[...slug]/+page.svelte'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Forgejo → GitHub Push-Mirror
|
|
||||||
|
|
||||||
`git push` landet zuerst auf Forgejo (`forgejo.joerglohrer.synology.me`).
|
|
||||||
Der Forgejo-Mirror synct dann zu GitHub (typisch 30–90 s). Die GitHub-
|
|
||||||
Action (Publish-Pipeline) läuft erst nach dem Mirror. Wer direkt nach
|
|
||||||
`git push` `gh run list` aufruft, sieht evtl. noch keinen neuen Run.
|
|
||||||
|
|
||||||
### 4. Deno-Path-Konventionen
|
|
||||||
|
|
||||||
Publish-Pipeline läuft aus `publish/` (CWD), daher sind Pfade relativ
|
|
||||||
mit `../content/posts/...`. Git-Diff liefert aber repo-root-relative
|
|
||||||
Pfade (`content/posts/...`). `changedPostDirs` normalisiert beides —
|
|
||||||
wenn `posts=0` obwohl Änderungen vorliegen, ist das der Hotspot.
|
|
||||||
|
|
||||||
### 5. Publish-Pipeline erwartet `content/posts/<lang>/<slug>/`
|
|
||||||
|
|
||||||
Die Zwei-Ebenen-Struktur ist Teil der Traversierung. Wer einen Post
|
|
||||||
versehentlich in `content/posts/<slug>/` (ohne Sprach-Ordner) anlegt,
|
|
||||||
wird von der Pipeline ignoriert.
|
|
||||||
|
|
||||||
## Hauptarbeitsbereiche im Repo
|
|
||||||
|
|
||||||
| Pfad | Inhalt |
|
|
||||||
|---|---|
|
|
||||||
| `content/posts/<lang>/<slug>/index.md` | Markdown-Posts pro Sprache |
|
|
||||||
| `app/src/lib/i18n/` | UI-Lokalisierung (svelte-i18n, activeLocale-Store) |
|
|
||||||
| `app/src/lib/nostr/` | Relay-Loader, Translations-Resolving |
|
|
||||||
| `app/src/lib/components/` | Svelte-5-Runes-Komponenten |
|
|
||||||
| `app/src/routes/` | SvelteKit-Routen (Layout, Home, Archiv, Post, Impressum) |
|
|
||||||
| `publish/src/` | Deno-Publish-Pipeline (Deno-Tasks in `publish/deno.jsonc`) |
|
|
||||||
| `publish/tests/` | Deno-Tests für die Pipeline |
|
|
||||||
| `docs/superpowers/specs/` | Produktdesigns, Konventionen |
|
|
||||||
| `docs/superpowers/plans/` | Implementierungspläne (alle erledigt) |
|
|
||||||
| `scripts/deploy-svelte.sh` | FTPS-Deploy |
|
|
||||||
|
|
||||||
## Quick-Links
|
|
||||||
|
|
||||||
- [Produktspezifikation SPA](docs/superpowers/specs/2026-04-15-nostr-page-design.md)
|
|
||||||
- [Produktspezifikation Publish-Pipeline](docs/superpowers/specs/2026-04-15-publish-pipeline-design.md)
|
|
||||||
- [Bild-Metadaten-Konvention](docs/superpowers/specs/2026-04-16-image-metadata-convention.md)
|
|
||||||
- [Multilingual-Design](docs/superpowers/specs/2026-04-21-multilingual-posts-design.md)
|
|
||||||
- [Repo-Workflow-Skill](.claude/skills/joerglohrerde-workflow.md) (ausführlicher, mit Kommandos)
|
|
||||||
113
README.md
|
|
@ -1,112 +1,3 @@
|
||||||
# joerg-lohrer.de
|
# joerglohrerde
|
||||||
|
|
||||||
Persönliche Webseite. Nach einer Transition von einer Hugo-basierten,
|
update
|
||||||
statischen Seite läuft `joerg-lohrer.de` jetzt als SvelteKit-SPA, die
|
|
||||||
Blog-Posts live aus signierten Nostr-Events (NIP-23, `kind:30023`) rendert.
|
|
||||||
|
|
||||||
## Aktueller Stand
|
|
||||||
|
|
||||||
- **`https://joerg-lohrer.de/`** — SvelteKit-SPA, seit 2026-04-18 live. Seit 2026-04-21 **multilingual** (Deutsch + Englisch via NIP-32 `l`-Tag und NIP-33-`a`-Tag-Verlinkung).
|
|
||||||
- **`https://staging.joerg-lohrer.de/`** — Staging (gleicher Build, ein Schritt vor Prod).
|
|
||||||
- **`https://svelte.joerg-lohrer.de/`** — Entwicklungs-Deploy-Target der Pipeline.
|
|
||||||
- **`https://spa.joerg-lohrer.de/`** — Vanilla-HTML-Mini-Spike (Proof of Concept, historisch).
|
|
||||||
|
|
||||||
Detailliert in [`docs/STATUS.md`](docs/STATUS.md).
|
|
||||||
|
|
||||||
## Wie die Seite funktioniert
|
|
||||||
|
|
||||||
1. **Inhalte** liegen als Markdown in `content/posts/<lang>/<slug>/index.md`
|
|
||||||
(z. B. `content/posts/de/<slug>/` oder `content/posts/en/<slug>/`) mit
|
|
||||||
strukturierten Bild-Metadaten im Frontmatter (Alt-Text, Lizenz, Autor:innen).
|
|
||||||
Übersetzungen eines Posts werden über bidirektionale `a:`-Tags im
|
|
||||||
Frontmatter verlinkt — Details in
|
|
||||||
[`docs/superpowers/specs/2026-04-21-multilingual-posts-design.md`](docs/superpowers/specs/2026-04-21-multilingual-posts-design.md).
|
|
||||||
2. **Publish-Pipeline** (`publish/`, Deno) lädt Bilder auf Blossom-Server
|
|
||||||
(content-addressed) und publiziert signierte `kind:30023`-Events via
|
|
||||||
NIP-46-Bunker (Amber) auf 5 Relays — inkl. NIP-32 `l`-Tag (Sprache) und
|
|
||||||
NIP-33 `a`-Tag (Verlinkung zu anderssprachigen Varianten).
|
|
||||||
3. **SvelteKit-SPA** (`app/`) lädt diese Events zur Laufzeit und rendert
|
|
||||||
Post-Liste + Detailseiten. UI-Chrome via `svelte-i18n` (DE/EN), Browser-
|
|
||||||
Locale als Default, Listen nach aktivem Locale gefiltert. Keine
|
|
||||||
Server-Komponente, Static-Hosting reicht.
|
|
||||||
4. **CI**: GitHub Actions triggert die Publish-Pipeline bei Push auf `main`
|
|
||||||
(via Forgejo→GitHub Push-Mirror).
|
|
||||||
|
|
||||||
Identität und Assets:
|
|
||||||
- **Pubkey:** `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9`
|
|
||||||
- **NIP-05:** `joerglohrer@joerg-lohrer.de` (statisches `.well-known/nostr.json`)
|
|
||||||
- **Blossom-Server:** `blossom.edufeed.org`, `blossom.primal.net`
|
|
||||||
- **Relays:** `relay.damus.io`, `nos.lol`, `relay.primal.net`, `relay.tchncs.de`, `relay.edufeed.org`
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
|
|
||||||
- 📍 **Stand und Live-URLs:** [`docs/STATUS.md`](docs/STATUS.md)
|
|
||||||
- 🔜 **Wie es weitergeht:** [`docs/HANDOFF.md`](docs/HANDOFF.md)
|
|
||||||
- 🤖 **Claude-Einstieg:** [`CLAUDE.md`](CLAUDE.md) (Agent-Konventionen, Deploy-Falle, Commit-Stil)
|
|
||||||
- 📐 **SPA-Spec:** [`docs/superpowers/specs/2026-04-15-nostr-page-design.md`](docs/superpowers/specs/2026-04-15-nostr-page-design.md)
|
|
||||||
- 📐 **Publish-Pipeline-Spec:** [`docs/superpowers/specs/2026-04-15-publish-pipeline-design.md`](docs/superpowers/specs/2026-04-15-publish-pipeline-design.md)
|
|
||||||
- 📐 **Bild-Metadaten-Konvention:** [`docs/superpowers/specs/2026-04-16-image-metadata-convention.md`](docs/superpowers/specs/2026-04-16-image-metadata-convention.md)
|
|
||||||
- 📐 **Multilinguale Posts:** [`docs/superpowers/specs/2026-04-21-multilingual-posts-design.md`](docs/superpowers/specs/2026-04-21-multilingual-posts-design.md)
|
|
||||||
- 🛠 **SvelteKit-SPA-Plan:** [`docs/superpowers/plans/2026-04-15-spa-sveltekit.md`](docs/superpowers/plans/2026-04-15-spa-sveltekit.md) (35 Tasks, erledigt)
|
|
||||||
- 🛠 **Publish-Pipeline-Plan:** [`docs/superpowers/plans/2026-04-16-publish-pipeline.md`](docs/superpowers/plans/2026-04-16-publish-pipeline.md) (24 Tasks, erledigt)
|
|
||||||
- 🛠 **Multilingual 1/3 — Pipeline:** [`docs/superpowers/plans/2026-04-21-multilingual-posts-pipeline.md`](docs/superpowers/plans/2026-04-21-multilingual-posts-pipeline.md) (10 Tasks, erledigt)
|
|
||||||
- 🛠 **Multilingual 2/3 — SPA-Auflösung:** [`docs/superpowers/plans/2026-04-21-multilingual-posts-spa.md`](docs/superpowers/plans/2026-04-21-multilingual-posts-spa.md) (8 Tasks, erledigt)
|
|
||||||
- 🛠 **Multilingual 3/3 — UI-i18n:** [`docs/superpowers/plans/2026-04-21-multilingual-posts-i18n.md`](docs/superpowers/plans/2026-04-21-multilingual-posts-i18n.md) (11 Tasks, erledigt)
|
|
||||||
- 🤖 **Claude-Workflow-Skill:** [`.claude/skills/joerglohrerde-workflow.md`](.claude/skills/joerglohrerde-workflow.md)
|
|
||||||
|
|
||||||
## Branches
|
|
||||||
|
|
||||||
- **`main`** — kanonisch. Seit Cutover (2026-04-18) Produktions-Quelle.
|
|
||||||
- **`spa`** — historischer SvelteKit-Arbeitszweig, inzwischen gemerged.
|
|
||||||
- **`hugo-archive`** — eingefrorener Hugo-Zustand als Orphan-Branch.
|
|
||||||
Rollback-Option über `git checkout hugo-archive && hugo build`.
|
|
||||||
|
|
||||||
## Repo-Struktur
|
|
||||||
|
|
||||||
```
|
|
||||||
content/posts/<lang>/<slug>/ Markdown-Posts pro Sprache (26× de, 1× en)
|
|
||||||
content/impressum.md Statisches Impressum (wird von SPA geladen)
|
|
||||||
app/ SvelteKit-SPA (Laufzeit-Renderer)
|
|
||||||
src/lib/i18n/ UI-Lokalisierung (svelte-i18n + Messages)
|
|
||||||
src/lib/nostr/ Relay-Loader, Translations-Resolving
|
|
||||||
publish/ Deno-Publish-Pipeline (Blossom + Nostr)
|
|
||||||
preview/spa-mini/ Vanilla-HTML-Mini-Spike (historische Referenz)
|
|
||||||
scripts/deploy-svelte.sh FTPS-Deploy, Targets: svelte/staging/prod
|
|
||||||
static/ Site-Assets (Favicons, Profilbild, .well-known/)
|
|
||||||
docs/ Specs, Pläne, Status, Handoff, Wiki-Entwürfe
|
|
||||||
.github/workflows/ GitHub-Actions CI (Publish-Pipeline-Trigger)
|
|
||||||
.claude/ Claude-Code-Sessions (Transparenz) + Skills
|
|
||||||
CLAUDE.md Einstiegspunkt für Claude-Sessions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Entwicklung
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# SPA lokal
|
|
||||||
cd app && npm run dev
|
|
||||||
|
|
||||||
# SPA testen
|
|
||||||
cd app && npm run test:unit
|
|
||||||
cd app && npm run test:e2e
|
|
||||||
cd app && npm run check
|
|
||||||
|
|
||||||
# Publish-Pipeline
|
|
||||||
cd publish && deno task check # pre-flight
|
|
||||||
cd publish && deno task publish --dry-run # Simulation
|
|
||||||
cd publish && deno task publish # diff-modus echt
|
|
||||||
cd publish && deno task publish --post <slug> # ein Post
|
|
||||||
cd publish && deno task test # Tests
|
|
||||||
|
|
||||||
# Deploy
|
|
||||||
DEPLOY_TARGET=staging ./scripts/deploy-svelte.sh
|
|
||||||
DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Lizenz
|
|
||||||
|
|
||||||
Inhalte: [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/deed.de)
|
|
||||||
(Namensnennung erwünscht, aber rechtlich nicht erforderlich), sofern nicht
|
|
||||||
anders vermerkt. Drittinhalte sind beim jeweiligen Bild mit Autor:innen und
|
|
||||||
Lizenz gekennzeichnet.
|
|
||||||
|
|
||||||
Code: siehe [LICENSE](LICENSE).
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
# Öffentliche Site-URL für Canonical-Link und og:url-Meta-Tags.
|
|
||||||
# Zur Build-Zeit fest; gilt domain-übergreifend (svelte./staging./haupt-
|
|
||||||
# domain). Für jeden Deploy-Zweck kann eine andere URL gesetzt werden.
|
|
||||||
#
|
|
||||||
# Beispiele:
|
|
||||||
# PUBLIC_SITE_URL=https://svelte.joerg-lohrer.de
|
|
||||||
# PUBLIC_SITE_URL=https://staging.joerg-lohrer.de
|
|
||||||
# PUBLIC_SITE_URL=https://joerg-lohrer.de
|
|
||||||
PUBLIC_SITE_URL=https://joerg-lohrer.de
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
node_modules
|
|
||||||
|
|
||||||
# Output
|
|
||||||
.output
|
|
||||||
.vercel
|
|
||||||
.netlify
|
|
||||||
.wrangler
|
|
||||||
/.svelte-kit
|
|
||||||
/build
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Env
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
!.env.example
|
|
||||||
!.env.test
|
|
||||||
|
|
||||||
# Vite
|
|
||||||
vite.config.js.timestamp-*
|
|
||||||
vite.config.ts.timestamp-*
|
|
||||||
|
|
||||||
# npm
|
|
||||||
package-lock.json
|
|
||||||
*.log
|
|
||||||
test-results/
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
engine-strict=true
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
{
|
|
||||||
"recommendations": ["svelte.svelte-vscode"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
# sv
|
|
||||||
|
|
||||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
|
||||||
|
|
||||||
## Creating a project
|
|
||||||
|
|
||||||
If you're seeing this, you've probably already done this step. Congrats!
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# create a new project
|
|
||||||
npx sv create my-app
|
|
||||||
```
|
|
||||||
|
|
||||||
To recreate this project with the same configuration:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# recreate this project
|
|
||||||
npx sv@0.15.1 create --template minimal --types ts --install npm .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Developing
|
|
||||||
|
|
||||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
|
|
||||||
# or start the server and open the app in a new browser tab
|
|
||||||
npm run dev -- --open
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To create a production version of your app:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
You can preview the production build with `npm run preview`.
|
|
||||||
|
|
||||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
{
|
|
||||||
"name": "app",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.1",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite dev",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
||||||
"test:unit": "vitest run",
|
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"deploy:svelte": "../scripts/deploy-svelte.sh"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@playwright/test": "^1.59.1",
|
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
|
||||||
"@sveltejs/kit": "^2.57.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
||||||
"@testing-library/svelte": "^5.3.1",
|
|
||||||
"@types/dompurify": "^3.0.5",
|
|
||||||
"jsdom": "^29.0.2",
|
|
||||||
"svelte": "^5.55.2",
|
|
||||||
"svelte-check": "^4.4.6",
|
|
||||||
"typescript": "^6.0.2",
|
|
||||||
"vite": "^8.0.7",
|
|
||||||
"vitest": "^4.1.4"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"applesauce-core": "^5.2.0",
|
|
||||||
"applesauce-loaders": "^5.1.0",
|
|
||||||
"applesauce-relay": "^5.2.0",
|
|
||||||
"applesauce-signers": "^5.2.0",
|
|
||||||
"dompurify": "^3.4.0",
|
|
||||||
"highlight.js": "^11.11.1",
|
|
||||||
"marked": "^18.0.0",
|
|
||||||
"nostr-tools": "^2.23.3",
|
|
||||||
"rxjs": "^7.8.2",
|
|
||||||
"svelte-i18n": "^4.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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,13 +0,0 @@
|
||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
|
||||||
// for information about these interfaces
|
|
||||||
declare global {
|
|
||||||
namespace App {
|
|
||||||
// interface Error {}
|
|
||||||
// interface Locals {}
|
|
||||||
// interface PageData {}
|
|
||||||
// interface PageState {}
|
|
||||||
// interface Platform {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="de">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
|
||||||
<meta property="og:title" content="Jörg Lohrer – Blog" />
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:url" content="__SITE_URL__/" />
|
|
||||||
<meta property="og:description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
|
||||||
<link rel="canonical" href="__SITE_URL__/" />
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png" />
|
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="/android-chrome-512x512.png" />
|
|
||||||
<title>Jörg Lohrer</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--fg: #1f2937;
|
|
||||||
--muted: #6b7280;
|
|
||||||
--bg: #fafaf9;
|
|
||||||
--accent: #2563eb;
|
|
||||||
--code-bg: #f3f4f6;
|
|
||||||
--border: #e5e7eb;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--fg: #e5e7eb;
|
|
||||||
--muted: #9ca3af;
|
|
||||||
--bg: #18181b;
|
|
||||||
--accent: #60a5fa;
|
|
||||||
--code-bg: #27272a;
|
|
||||||
--border: #3f3f46;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
html,
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
font:
|
|
||||||
17px/1.55 -apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
sans-serif;
|
|
||||||
color: var(--fg);
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
%sveltekit.head%
|
|
||||||
</head>
|
|
||||||
<body data-sveltekit-preload-data="hover">
|
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
|
|
@ -1,47 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
// CC-Zero-Badge: kombination aus CC-Heart + Zero-Logo, monochrom via
|
|
||||||
// currentColor. Icons aus dem offiziellen CC-Press-Kit
|
|
||||||
// (creativecommons.org/mission/branding/). Inline hier, weil statische
|
|
||||||
// svg-imports mit ?raw in vite problematisch sind.
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span class="cc-badge" aria-hidden="true">
|
|
||||||
<!-- CC-Heart (vereinfachtes herz aus dem offiziellen logo) -->
|
|
||||||
<svg viewBox="0 0 46296 40689" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M23204.91 7530.98c2944.63,-3188.84 6384.04,-4639.01 10366.38,-4077.21 4110.34,579.88 7609.97,3518.41 8854.17,7479.01 957.39,3047.58 559.96,6460.83 -722.09,9573.35 -1993.98,4840.97 -7886.31,11722.09 -18555.24,16532.85 -10668.92,-4810.76 -16561.25,-11691.88 -18555.24,-16532.85 -1282.05,-3112.52 -1679.47,-6525.77 -722.09,-9573.35 1244.19,-3960.6 4743.83,-6899.13 8854.17,-7479.01 3982.46,-561.82 7421.94,888.46 10366.64,4077.48 5.4,5.84 56.52,61.37 56.53,61.36 0.04,0.04 51.9,-56.36 56.79,-61.63zm-56.79 -4522.44c-6431.69,-5048.01 -16512.25,-3730.83 -21147.65,3855.94 -1539.08,2519.03 -2117.14,5447.75 -1981.3,8355.45 235.64,5043.59 2412.75,9452.27 5610.61,13256.78 4306.02,5122.9 10531.26,9148.59 17382.21,12152.72 9.53,4.18 88.63,38.56 136.13,59.69 41.66,-17.53 114.6,-50.41 137.01,-60.3 6815.65,-3004.07 13075.56,-7030.12 17381.33,-12152.12 3198.08,-3804.33 5374.97,-8213.2 5610.61,-13256.78 135.85,-2907.7 -442.2,-5836.43 -1981.3,-8355.45 -4635.4,-7586.77 -14715.95,-8903.95 -21147.65,-3855.94z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M22983.64 21630.19l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.85,-2300.18 -2239.67,-3843.76 -87.17,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.75,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.13,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.43,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12zm13802.46 0l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.86,-2300.18 -2239.67,-3843.76 -87.18,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.74,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.14,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.42,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- CC-Zero (kreis + 0 aus dem cc-0-logo) -->
|
|
||||||
<svg viewBox="-0.5 0.5 64 64" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M31.5,14.08c-10.565,0-13.222,9.969-13.222,18.42c0,8.452,2.656,18.42,13.222,18.42c10.564,0,13.221-9.968,13.221-18.42C44.721,24.049,42.064,14.08,31.5,14.08z M31.5,21.026c0.429,0,0.82,0.066,1.188,0.157c0.761,0.656,1.133,1.561,0.403,2.823l-7.036,12.93c-0.216-1.636-0.247-3.24-0.247-4.437C25.808,28.777,26.066,21.026,31.5,21.026z M36.766,26.987c0.373,1.984,0.426,4.056,0.426,5.513c0,3.723-0.258,11.475-5.69,11.475c-0.428,0-0.822-0.045-1.188-0.136c-0.07-0.021-0.134-0.043-0.202-0.067c-0.112-0.032-0.23-0.068-0.336-0.11c-1.21-0.515-1.972-1.446-0.874-3.093L36.766,26.987z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M31.433,0.5c-8.877,0-16.359,3.09-22.454,9.3c-3.087,3.087-5.443,6.607-7.082,10.532C0.297,24.219-0.5,28.271-0.5,32.5c0,4.268,0.797,8.32,2.397,12.168c1.6,3.85,3.921,7.312,6.969,10.396c3.085,3.049,6.549,5.399,10.398,7.037c3.886,1.602,7.939,2.398,12.169,2.398c4.229,0,8.34-0.826,12.303-2.465c3.962-1.639,7.496-3.994,10.621-7.081c3.011-2.933,5.289-6.297,6.812-10.106C62.73,41,63.5,36.883,63.5,32.5c0-4.343-0.77-8.454-2.33-12.303c-1.562-3.885-3.848-7.32-6.857-10.33C48.025,3.619,40.385,0.5,31.433,0.5z M31.567,6.259c7.238,0,13.412,2.566,18.554,7.709c2.477,2.477,4.375,5.31,5.67,8.471c1.296,3.162,1.949,6.518,1.949,10.061c0,7.354-2.516,13.454-7.506,18.33c-2.592,2.516-5.502,4.447-8.74,5.781c-3.2,1.334-6.498,1.994-9.927,1.994c-3.468,0-6.788-0.653-9.949-1.948c-3.163-1.334-6.001-3.238-8.516-5.716c-2.515-2.514-4.455-5.353-5.826-8.516c-1.333-3.199-2.017-6.498-2.017-9.927c0-3.467,0.684-6.787,2.017-9.949c1.371-3.2,3.312-6.074,5.826-8.628C18.092,8.818,24.252,6.259,31.567,6.259z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.cc-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.15em;
|
|
||||||
color: var(--accent);
|
|
||||||
vertical-align: -0.2em;
|
|
||||||
}
|
|
||||||
.cc-badge svg {
|
|
||||||
width: 1.1em;
|
|
||||||
height: 1.1em;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { externalClientLinks } from '$lib/nostr/naddr';
|
|
||||||
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
dtag: string;
|
|
||||||
}
|
|
||||||
let { dtag }: Props = $props();
|
|
||||||
|
|
||||||
const links = $derived(
|
|
||||||
externalClientLinks({
|
|
||||||
pubkey: AUTHOR_PUBKEY_HEX,
|
|
||||||
kind: 30023,
|
|
||||||
identifier: dtag
|
|
||||||
})
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="external">
|
|
||||||
<span class="label">In Nostr-Client öffnen (für Threads, Reactions, Teilen):</span>
|
|
||||||
<ul>
|
|
||||||
{#each links as l}
|
|
||||||
<li><a href={l.url} target="_blank" rel="noopener">{l.label}</a></li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.external {
|
|
||||||
margin: 2rem 0 1rem;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
background: var(--code-bg);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.label {
|
|
||||||
display: block;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
li a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
li a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { NostrEvent, TranslationInfo } from '$lib/nostr/loaders';
|
|
||||||
import { loadTranslations } from '$lib/nostr/loaders';
|
|
||||||
import { activeLocale } from '$lib/i18n';
|
|
||||||
import type { SupportedLocale } from '$lib/i18n/activeLocale';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
event: NostrEvent;
|
|
||||||
}
|
|
||||||
let { event }: Props = $props();
|
|
||||||
|
|
||||||
let translations: TranslationInfo[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const currentId = event.id;
|
|
||||||
loading = true;
|
|
||||||
translations = [];
|
|
||||||
loadTranslations(event)
|
|
||||||
.then((infos) => {
|
|
||||||
if (event.id !== currentId) return;
|
|
||||||
translations = infos;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (event.id === currentId) loading = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function currentLang(): string {
|
|
||||||
return event.tags.find((tag) => tag[0] === 'l')?.[1] ?? 'de';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Option {
|
|
||||||
code: string;
|
|
||||||
href: string | null; // null = aktueller post, kein klick-ziel
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = $derived.by<Option[]>(() => {
|
|
||||||
const self: Option = { code: currentLang(), href: null };
|
|
||||||
const others: Option[] = translations.map((t) => ({
|
|
||||||
code: t.lang,
|
|
||||||
href: `/${t.slug}/`
|
|
||||||
}));
|
|
||||||
// aktuelle sprache zuerst, dann rest sortiert nach code
|
|
||||||
return [self, ...others.sort((a, b) => a.code.localeCompare(b.code))];
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectOther(code: string, href: string) {
|
|
||||||
activeLocale.set(code as SupportedLocale);
|
|
||||||
// hartes location-setzen, damit svelte-kit-router den post-load triggert
|
|
||||||
window.location.href = href;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !loading && translations.length > 0}
|
|
||||||
<p class="lang-switch" role="group" aria-label="Article language">
|
|
||||||
<span class="icon" aria-hidden="true">📖</span>
|
|
||||||
{#each options as opt, i}
|
|
||||||
{#if opt.href === null}
|
|
||||||
<span class="btn active" aria-current="true">{opt.code.toUpperCase()}</span>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
onclick={() => selectOther(opt.code, opt.href!)}
|
|
||||||
>{opt.code.toUpperCase()}</button>
|
|
||||||
{/if}
|
|
||||||
{#if i < options.length - 1}<span class="sep" aria-hidden="true">|</span>{/if}
|
|
||||||
{/each}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.lang-switch {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: var(--muted);
|
|
||||||
margin: 0.25rem 0 1rem;
|
|
||||||
}
|
|
||||||
.icon {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn:hover:not(.active) {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.btn.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.sep {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { t, activeLocale, SUPPORTED_LOCALES } from '$lib/i18n';
|
|
||||||
import type { SupportedLocale } from '$lib/i18n/activeLocale';
|
|
||||||
|
|
||||||
let current = $state<SupportedLocale>('de');
|
|
||||||
activeLocale.subscribe((v) => (current = v));
|
|
||||||
|
|
||||||
function select(lang: SupportedLocale) {
|
|
||||||
activeLocale.set(lang);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="switcher" role="group" aria-label={$t('lang.switch_aria')}>
|
|
||||||
{#each SUPPORTED_LOCALES as code}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
class:active={current === code}
|
|
||||||
aria-pressed={current === code}
|
|
||||||
onclick={() => select(code)}
|
|
||||||
>{code.toUpperCase()}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.switcher {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.btn.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
interface Props {
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
hablaLink?: string;
|
|
||||||
}
|
|
||||||
let { loading, error, hablaLink }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if loading && !error}
|
|
||||||
<p class="status">Lade von Nostr-Relays …</p>
|
|
||||||
{:else if error}
|
|
||||||
<p class="status status-error">
|
|
||||||
{error}
|
|
||||||
{#if hablaLink}
|
|
||||||
<br />
|
|
||||||
<a href={hablaLink} target="_blank" rel="noopener"> In Habla.news öffnen </a>
|
|
||||||
{/if}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.status {
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--code-bg);
|
|
||||||
color: var(--muted);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.status-error {
|
|
||||||
background: #fee2e2;
|
|
||||||
color: #991b1b;
|
|
||||||
}
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.status-error {
|
|
||||||
background: #450a0a;
|
|
||||||
color: #fca5a5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { NostrEvent } from '$lib/nostr/loaders';
|
|
||||||
import { canonicalPostPath } from '$lib/url/legacy';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
event: NostrEvent;
|
|
||||||
}
|
|
||||||
let { event }: Props = $props();
|
|
||||||
|
|
||||||
function tagValue(e: NostrEvent, name: string): string {
|
|
||||||
return e.tags.find((t) => t[0] === name)?.[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'));
|
|
||||||
const publishedAt = $derived(
|
|
||||||
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
|
|
||||||
);
|
|
||||||
const date = $derived(
|
|
||||||
new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const href = $derived(canonicalPostPath(dtag));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a class="card" {href}>
|
|
||||||
<div
|
|
||||||
class="thumb"
|
|
||||||
style:background-image={image ? `url('${image}')` : undefined}
|
|
||||||
aria-hidden="true"
|
|
||||||
></div>
|
|
||||||
<div class="text">
|
|
||||||
<div class="meta">{date}</div>
|
|
||||||
<h2>{title}</h2>
|
|
||||||
{#if summary}<p class="excerpt">{summary}</p>{/if}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.card {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.card:hover {
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
.thumb {
|
|
||||||
flex: 0 0 120px;
|
|
||||||
aspect-ratio: 1 / 1;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--code-bg) center/cover no-repeat;
|
|
||||||
}
|
|
||||||
.text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 0.3rem;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: var(--fg);
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
.excerpt {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--muted);
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
}
|
|
||||||
@media (max-width: 479px) {
|
|
||||||
.card {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.thumb {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 2 / 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
<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';
|
|
||||||
import ExternalClientLinks from './ExternalClientLinks.svelte';
|
|
||||||
import LanguageAvailability from './LanguageAvailability.svelte';
|
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
event: NostrEvent;
|
|
||||||
}
|
|
||||||
let { event }: Props = $props();
|
|
||||||
|
|
||||||
function tagValue(e: NostrEvent, name: string): string {
|
|
||||||
return e.tags.find((t) => t[0] === name)?.[1] ?? '';
|
|
||||||
}
|
|
||||||
function tagsAll(e: NostrEvent, name: string): string[] {
|
|
||||||
return e.tags.filter((t) => t[0] === name).map((t) => t[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dtag = $derived(tagValue(event, 'd'));
|
|
||||||
let currentLocale = $state('de');
|
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const title = $derived(tagValue(event, 'title') || $t('post.untitled'));
|
|
||||||
const summary = $derived(tagValue(event, 'summary'));
|
|
||||||
const image = $derived(tagValue(event, 'image'));
|
|
||||||
const publishedAt = $derived(
|
|
||||||
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
|
|
||||||
);
|
|
||||||
const date = $derived(
|
|
||||||
new Date(publishedAt * 1000).toLocaleDateString(
|
|
||||||
currentLocale === 'en' ? 'en-US' : 'de-DE',
|
|
||||||
{ year: 'numeric', month: 'long', day: 'numeric' }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
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`;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h1 class="post-title">{title}</h1>
|
|
||||||
<div class="meta">
|
|
||||||
{$t('post.published_on', { values: { date } })}
|
|
||||||
{#if tags.length > 0}
|
|
||||||
<div class="tags">
|
|
||||||
{#each tags as t}
|
|
||||||
<a class="tag" href="/tag/{encodeURIComponent(t)}/">{t}</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LanguageAvailability {event} />
|
|
||||||
|
|
||||||
{#if image}
|
|
||||||
<p class="cover"><img src={image} alt="Cover-Bild" /></p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if summary}
|
|
||||||
<p class="summary">{summary}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<article>{@html bodyHtml}</article>
|
|
||||||
|
|
||||||
{#if dtag}
|
|
||||||
<Reactions {dtag} />
|
|
||||||
<ExternalClientLinks {dtag} />
|
|
||||||
<ReplyComposer {dtag} eventId={event.id} onPublished={handlePublished} />
|
|
||||||
<ReplyList {dtag} optimistic={optimisticReplies} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.post-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
line-height: 1.25;
|
|
||||||
margin: 0 0 0.4rem;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
.post-title {
|
|
||||||
font-size: 2rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.92rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
.tags {
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
.tag {
|
|
||||||
display: inline-block;
|
|
||||||
background: var(--code-bg);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
margin: 0 4px 4px 0;
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: var(--fg);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.tag:hover {
|
|
||||||
background: var(--border);
|
|
||||||
}
|
|
||||||
.cover {
|
|
||||||
max-width: 480px;
|
|
||||||
margin: 1rem auto 1.5rem;
|
|
||||||
}
|
|
||||||
.cover img {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.summary {
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
article :global(img) {
|
|
||||||
max-width: 100%;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
article :global(a) {
|
|
||||||
color: var(--accent);
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
article :global(pre) {
|
|
||||||
background: var(--code-bg);
|
|
||||||
padding: 0.8rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow-x: auto;
|
|
||||||
font-size: 0.88em;
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
article :global(code) {
|
|
||||||
background: var(--code-bg);
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.92em;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
article :global(pre code) {
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
article :global(hr) {
|
|
||||||
border: none;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
article :global(blockquote) {
|
|
||||||
border-left: 3px solid var(--border);
|
|
||||||
padding: 0 0 0 1rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { Profile } from '$lib/nostr/loaders';
|
|
||||||
interface Props {
|
|
||||||
profile: Profile | null;
|
|
||||||
}
|
|
||||||
let { profile }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if profile}
|
|
||||||
<div class="profile">
|
|
||||||
{#if profile.picture}
|
|
||||||
<img class="avatar" src={profile.picture} alt={profile.display_name ?? profile.name ?? ''} />
|
|
||||||
{:else}
|
|
||||||
<div class="avatar"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="info">
|
|
||||||
<div class="name">{profile.display_name ?? profile.name ?? ''}</div>
|
|
||||||
{#if profile.about}
|
|
||||||
<div class="about">{profile.about}</div>
|
|
||||||
{/if}
|
|
||||||
{#if profile.nip05 || profile.website}
|
|
||||||
<div class="meta-line">
|
|
||||||
{#if profile.nip05}<span>{profile.nip05}</span>{/if}
|
|
||||||
{#if profile.nip05 && profile.website}<span class="sep">·</span>{/if}
|
|
||||||
{#if profile.website}
|
|
||||||
<a href={profile.website} target="_blank" rel="noopener">
|
|
||||||
{profile.website.replace(/^https?:\/\//, '')}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.profile {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.avatar {
|
|
||||||
flex: 0 0 80px;
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
.info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.name {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 0.2rem;
|
|
||||||
}
|
|
||||||
.about {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
margin: 0 0 0.3rem;
|
|
||||||
}
|
|
||||||
.meta-line {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.meta-line a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.meta-line a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.sep {
|
|
||||||
margin: 0 0.4rem;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,148 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
|
|
||||||
import { getProfile } from '$lib/nostr/profileCache';
|
|
||||||
import { buildNjumpProfileUrl } from '$lib/nostr/naddr';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
event: NostrEvent;
|
|
||||||
}
|
|
||||||
let { event }: Props = $props();
|
|
||||||
|
|
||||||
const date = $derived(new Date(event.created_at * 1000).toLocaleString('de-DE'));
|
|
||||||
const npubPrefix = $derived(event.pubkey.slice(0, 12) + '…');
|
|
||||||
const profileUrl = $derived(buildNjumpProfileUrl(event.pubkey));
|
|
||||||
|
|
||||||
let profile = $state<Profile | null>(null);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
profile = await getProfile(event.pubkey);
|
|
||||||
} catch {
|
|
||||||
profile = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayName = $derived(profile?.display_name || profile?.name || npubPrefix);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<li class="reply">
|
|
||||||
<a class="header" href={profileUrl} target="_blank" rel="noopener">
|
|
||||||
{#if profile?.picture}
|
|
||||||
<img class="avatar" src={profile.picture} alt={displayName} />
|
|
||||||
{:else}
|
|
||||||
<div class="avatar avatar-placeholder" aria-hidden="true"></div>
|
|
||||||
{/if}
|
|
||||||
<div class="meta">
|
|
||||||
<span class="name">{displayName}</span>
|
|
||||||
<span class="date">{date}</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
<div class="content">{event.content}</div>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.reply {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0.8rem 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.4rem;
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 2px;
|
|
||||||
margin-left: -2px;
|
|
||||||
}
|
|
||||||
.header:hover {
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
.header:hover .name {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
.avatar {
|
|
||||||
flex: 0 0 32px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
background: var(--code-bg);
|
|
||||||
}
|
|
||||||
.avatar-placeholder {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--muted);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.name {
|
|
||||||
color: var(--fg);
|
|
||||||
font-weight: 500;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
margin-left: calc(32px + 0.6rem);
|
|
||||||
}
|
|
||||||
@media (max-width: 479px) {
|
|
||||||
.content {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
// Inline-SVGs als kleine social-icon-leiste. bewusst keine zusätzlichen
|
|
||||||
// image-requests, kein icon-font. hover-color via currentColor + fill=currentColor.
|
|
||||||
|
|
||||||
const AUTHOR_PUBKEY_HEX =
|
|
||||||
'4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
|
|
||||||
|
|
||||||
type Entry = { label: string; href: string; icon: 'nostr' | 'mastodon' | 'linkedin' | 'bluesky' | 'mail' | 'orcid' };
|
|
||||||
|
|
||||||
const entries: Entry[] = [
|
|
||||||
{ label: 'Nostr', href: `https://edufeed.org/p/${AUTHOR_PUBKEY_HEX}`, icon: 'nostr' },
|
|
||||||
{ label: 'Mastodon', href: 'https://reliverse.social/@joerglohrer', icon: 'mastodon' },
|
|
||||||
{ label: 'Bluesky', href: 'https://bsky.app/profile/joerglohrer.bsky.social', icon: 'bluesky' },
|
|
||||||
{ label: 'LinkedIn', href: 'https://www.linkedin.com/in/joerglohrer', icon: 'linkedin' },
|
|
||||||
{ label: 'ORCID', href: 'https://orcid.org/0000-0002-9282-0406', icon: 'orcid' },
|
|
||||||
{ label: 'E-Mail', href: 'mailto:webmaster@joerg-lohrer.de', icon: 'mail' }
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav class="social" aria-label="Soziale Profile und Kontakt">
|
|
||||||
{#each entries as e (e.href)}
|
|
||||||
<a
|
|
||||||
href={e.href}
|
|
||||||
target={e.icon === 'mail' ? undefined : '_blank'}
|
|
||||||
rel={e.icon === 'mail' ? undefined : 'me noopener'}
|
|
||||||
aria-label={e.label}
|
|
||||||
title={e.label}
|
|
||||||
>
|
|
||||||
{#if e.icon === 'nostr'}
|
|
||||||
<!-- Nostr-Logo (Outline) von satscoffee/nostr_icons, CC0 -->
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 875 875"
|
|
||||||
aria-hidden="true"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="40"
|
|
||||||
stroke-miterlimit="10"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m684.72,485.57c.22,12.59-11.93,51.47-38.67,81.3-26.74,29.83-56.02,20.85-58.42,20.16s-3.09-4.46-7.89-3.77-9.6,6.17-18.86,7.2-17.49,1.71-26.06-1.37c-4.46.69-5.14.71-7.2,2.24s-17.83,10.79-21.6,11.47c0,7.2-1.37,44.57,0,55.89s3.77,25.71,7.54,36c3.77,10.29,2.74,10.63,7.54,9.94s13.37.34,15.77,4.11c2.4,3.77,1.37,6.51,5.49,8.23s60.69,17.14,99.43,19.2c26.74.69,42.86,2.74,52.12,19.54,1.37,7.89,7.54,13.03,11.31,14.06s8.23,2.06,12,5.83,1.03,8.23,5.49,11.66c4.46,3.43,14.74,8.57,25.37,13.71,10.63,5.14,15.09,13.37,15.77,16.11s1.71,10.97,1.71,10.97c0,0-8.91,0-10.97-2.06s-2.74-5.83-2.74-5.83c0,0-6.17,1.03-7.54,3.43s.69,2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8,4.11,8.23,5.83,8.23,10.63,10.97s8.23,5.83,8.23,5.83l-7.2,4.46s-4.46,2.06-14.74-.69-11.66-4.46-12.69-10.63,0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77,4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97,0-19.2,3.43-19.89,1.71-26.74-14.06-55.89-19.89-64.12c-13.03,1.03-50.74-.69-50.74-.69,0,0-2.4-.69-17.49,5.83s-36.48,13.76-46.77,19.93-14.4,9.7-16.12,13.13c.12,3-1.23,7.72-2.79,9.06s-12.48,2.42-12.48,2.42c0,0-5.85,5.86-8.25,9.97-6.86,9.6-55.2,125.14-66.52,149.83-13.54,32.57-9.77,27.43-37.71,27.43s-8.06.3-8.06.3c0,0-12.34,5.88-16.8,5.88s-18.86-2.4-26.4,0-16.46,9.26-23.31,10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12,0,0,1.74-6.51,7.91-10.88,8.23-5.83,25.37-16.11,34.63-21.26s17.49-7.89,23.31-9.26,18.51-6.17,30.51-9.94,19.54-8.23,29.83-31.54c10.29-23.31,50.4-111.43,51.43-116.23.63-2.96,3.73-6.48,4.8-15.09.66-5.35-2.49-13.04,1.71-22.63,10.97-25.03,21.6-20.23,26.4-20.23s17.14.34,26.4-1.37,15.43-2.74,24.69-7.89,11.31-8.91,11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2,1.03,8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17,13.03-1.71-18.86-5.14,7.2-2.74-16.11-4.8,8.23-3.43-14.4-5.83,4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46,3.43-4.46,3.43l1.37,12-12.2-6.27-7-11.9s2.36,4.01-9.62,7.53c-20.55,0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89,3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26,25.71-22.97,25.03-16.46,46.29-17.14c21.26-.69,32.91,2.74,46.29,8.23s38.74,13.71,43.89,17.49c11.31-9.94,28.46-19.89,34.29-19.89,1.03-2.4,6.19-12.33,17.96-17.6,35.31-15.81,108.13-34,131.53-35.54,31.2-2.06,7.89-1.37,39.09,2.06,31.2,3.43,54.17,7.54,69.6,12.69,12.58,4.19,25.03,9.6,34.29,2.06,4.33-1.81,11.81-1.34,17.83-5.14,30.69-25.09,34.72-32.35,43.63-41.95s20.14-24.91,22.54-45.14,4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03,2.06-12,5.14-22.29,6.86-30.51s9.94-14.74,19.89-16.46c9.94-1.71,17.83,1.37,22.29,4.8,4.46,3.43,11.65,6.28,13.37,10.29.34,1.71-1.37,6.51,8.23,8.23,9.6,1.71,16.05,4.16,16.05,4.16,0,0,15.64,4.29,3.11,7.73-12.69,2.06-20.52-.71-24.29,1.69s-7.21,10.08-9.61,11.1-7.2.34-12,4.11-9.6,6.86-12.69,14.4-5.49,15.77-3.43,26.74,8.57,31.54,14.4,43.2c5.83,11.66,20.23,40.8,24.34,47.66s15.77,29.49,16.8,53.83,1.03,44.23,0,54.86-10.84,51.65-35.53,85.94c-8.16,14.14-23.21,31.9-24.67,35.03-1.45,3.13-3.02,4.88-1.61,7.65,4.62,9.05,12.87,22.13,14.71,29.22,2.29,6.64,6.99,16.13,7.22,28.72Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'mastodon'}
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M21.58 13.9c-.3 1.53-2.67 3.22-5.4 3.54-1.42.17-2.82.33-4.32.26-2.45-.11-4.39-.58-4.39-.58 0 .24.01.47.05.68.32 2.4 2.39 2.55 4.34 2.61 1.97.07 3.74-.49 3.74-.49l.08 1.78s-1.39.75-3.87.88c-1.37.08-3.07-.03-5.05-.56C2.46 20.86 1.72 16.2 1.61 11.47 1.57 10.07 1.59 8.76 1.59 7.66c0-4.85 3.18-6.27 3.18-6.27 1.6-.73 4.35-1.04 7.21-1.07h.07c2.86.02 5.61.33 7.22 1.07 0 0 3.18 1.42 3.18 6.27 0 0 .04 3.59-.45 6.08-.03.16-.18.16-.42.16zm-3.01-5.53c0-1.18-.3-2.11-.9-2.81-.62-.7-1.43-1.05-2.44-1.05-1.17 0-2.06.45-2.65 1.35l-.58.97-.58-.97c-.59-.9-1.48-1.35-2.65-1.35-1.01 0-1.82.36-2.44 1.05-.6.7-.9 1.63-.9 2.81v5.78h2.3V8.54c0-1.18.5-1.78 1.5-1.78 1.1 0 1.65.71 1.65 2.13v3.09h2.28V8.88c0-1.41.55-2.13 1.65-2.13 1 0 1.5.6 1.5 1.78v5.61h2.3V8.37z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'bluesky'}
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M5.2 3.5c2.4 1.8 5 5.4 5.9 7.4.9-2 3.5-5.6 5.9-7.4 1.8-1.3 4.7-2.3 4.7 1 0 .7-.4 5.6-.6 6.4-.8 2.8-3.6 3.5-6.1 3.1 4.4.7 5.5 3.2 3.1 5.7-4.6 4.8-6.6-1.2-7.1-2.7-.1-.3-.1-.4-.1-.3 0-.1-.1 0-.1.3-.5 1.5-2.5 7.5-7.1 2.7-2.5-2.5-1.3-5 3.1-5.7-2.5.4-5.3-.3-6.1-3.1C-.1 10.1-.5 5.2-.5 4.5c0-3.3 2.9-2.3 4.7-1l1 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'linkedin'}
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14zM8.34 18.34v-8.4H5.67v8.4h2.67zM7 8.67a1.55 1.55 0 1 0 0-3.1 1.55 1.55 0 0 0 0 3.1zm11.34 9.67v-4.6c0-2.46-1.31-3.6-3.06-3.6-1.41 0-2.04.78-2.39 1.32v-1.13h-2.67c.04.75 0 8.01 0 8.01h2.67v-4.47c0-.24.02-.48.09-.65.19-.48.63-.97 1.37-.97.97 0 1.36.74 1.36 1.82v4.27h2.63z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'orcid'}
|
|
||||||
<!-- offizielles ORCID-symbol stark vereinfacht, monochrom via currentColor -->
|
|
||||||
<svg viewBox="0 0 256 256" aria-hidden="true" width="20" height="20">
|
|
||||||
<circle cx="128" cy="128" r="128" fill="currentColor" opacity="0.15" />
|
|
||||||
<circle cx="128" cy="128" r="118" fill="none" stroke="currentColor" stroke-width="10" />
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M86 76a8 8 0 1 1-16 0 8 8 0 0 1 16 0zM72 96h14v96H72V96zm34 0h38c28 0 50 21 50 48s-22 48-50 48h-38V96zm14 14v68h22c22 0 36-14 36-34s-14-34-36-34h-22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'mail'}
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm0 2v.01L12 13l8-6.99V6H4zm0 2.7V18h16V8.7l-8 6.99L4 8.7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.social {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 0.6rem;
|
|
||||||
}
|
|
||||||
.social a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--code-bg);
|
|
||||||
color: var(--muted);
|
|
||||||
transition:
|
|
||||||
color 140ms,
|
|
||||||
background 140ms,
|
|
||||||
transform 140ms;
|
|
||||||
}
|
|
||||||
.social a:hover,
|
|
||||||
.social a:focus-visible {
|
|
||||||
color: var(--accent);
|
|
||||||
background: color-mix(in srgb, var(--accent) 15%, var(--code-bg));
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
.social svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { detectInitialLocale } from './activeLocale';
|
|
||||||
|
|
||||||
describe('detectInitialLocale', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.localStorage?.clear?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('nimmt wert aus localStorage, wenn vorhanden und gültig', () => {
|
|
||||||
const storage = new Map<string, string>([['locale', 'en']]);
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: (k) => storage.get(k) ?? null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'de-DE',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fällt auf navigator.language zurück, wenn storage leer', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'en-US',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalisiert navigator.language (de-AT → de)', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'de-AT',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('de');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fällt auf ersten supported eintrag, wenn navigator unbekannt', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'fr-FR',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('de');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert ungültige werte im storage', () => {
|
|
||||||
const storage = new Map<string, string>([['locale', 'fr']]);
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: (k) => storage.get(k) ?? null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'en-US',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { writable, type Writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export type SupportedLocale = 'de' | 'en';
|
|
||||||
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = ['de', 'en'] as const;
|
|
||||||
const STORAGE_KEY = 'locale';
|
|
||||||
|
|
||||||
interface Storage {
|
|
||||||
getItem: (key: string) => string | null;
|
|
||||||
setItem: (key: string, value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetectArgs {
|
|
||||||
storage: Storage;
|
|
||||||
navigatorLanguage: string | undefined;
|
|
||||||
supported: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectInitialLocale(args: DetectArgs): SupportedLocale {
|
|
||||||
const stored = args.storage.getItem(STORAGE_KEY);
|
|
||||||
if (stored && (args.supported as readonly string[]).includes(stored)) {
|
|
||||||
return stored as SupportedLocale;
|
|
||||||
}
|
|
||||||
const nav = (args.navigatorLanguage ?? '').slice(0, 2).toLowerCase();
|
|
||||||
if ((args.supported as readonly string[]).includes(nav)) {
|
|
||||||
return nav as SupportedLocale;
|
|
||||||
}
|
|
||||||
return args.supported[0] as SupportedLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createActiveLocale(): Writable<SupportedLocale> & { bootstrap: () => void } {
|
|
||||||
const store = writable<SupportedLocale>('de');
|
|
||||||
let bootstrapped = false;
|
|
||||||
|
|
||||||
function bootstrap() {
|
|
||||||
if (bootstrapped) return;
|
|
||||||
bootstrapped = true;
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const initial = detectInitialLocale({
|
|
||||||
storage: window.localStorage,
|
|
||||||
navigatorLanguage: window.navigator.language,
|
|
||||||
supported: SUPPORTED_LOCALES
|
|
||||||
});
|
|
||||||
store.set(initial);
|
|
||||||
store.subscribe((v) => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, v);
|
|
||||||
} catch {
|
|
||||||
// private-mode / quota — ignorieren
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe: store.subscribe,
|
|
||||||
set: store.set,
|
|
||||||
update: store.update,
|
|
||||||
bootstrap
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const activeLocale = createActiveLocale();
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { addMessages, init, locale, _ } from 'svelte-i18n';
|
|
||||||
import de from './messages/de.json';
|
|
||||||
import en from './messages/en.json';
|
|
||||||
import { activeLocale, SUPPORTED_LOCALES } from './activeLocale';
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
export function initI18n(): void {
|
|
||||||
if (initialized) return;
|
|
||||||
initialized = true;
|
|
||||||
addMessages('de', de);
|
|
||||||
addMessages('en', en);
|
|
||||||
init({
|
|
||||||
fallbackLocale: 'de',
|
|
||||||
initialLocale: 'de'
|
|
||||||
});
|
|
||||||
activeLocale.bootstrap();
|
|
||||||
activeLocale.subscribe((l) => {
|
|
||||||
locale.set(l);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { _ as t, locale, activeLocale, SUPPORTED_LOCALES };
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"nav": {
|
|
||||||
"home": "Home",
|
|
||||||
"archive": "Archiv",
|
|
||||||
"imprint": "Impressum",
|
|
||||||
"brand_aria": "Zur Startseite"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"greeting": "Hi 🖖 Willkommen auf meinem Blog 🤗",
|
|
||||||
"latest": "Neueste Beiträge",
|
|
||||||
"more_archive": "Alle Beiträge im Archiv →",
|
|
||||||
"empty": "Keine Posts gefunden auf den abgefragten Relays."
|
|
||||||
},
|
|
||||||
"archive": {
|
|
||||||
"title": "Archiv",
|
|
||||||
"subtitle": "Alle Beiträge, nach Jahr gruppiert.",
|
|
||||||
"doc_title": "Archiv – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"back_to_overview": "← Zurück zur Übersicht",
|
|
||||||
"untitled": "(ohne Titel)",
|
|
||||||
"published_on": "Veröffentlicht am {date}",
|
|
||||||
"not_found": "Post \"{slug}\" nicht gefunden.",
|
|
||||||
"unknown_error": "Unbekannter Fehler"
|
|
||||||
},
|
|
||||||
"imprint": {
|
|
||||||
"doc_title": "Impressum – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"lang": {
|
|
||||||
"switch_aria": "Sprache wechseln"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"nav": {
|
|
||||||
"home": "Home",
|
|
||||||
"archive": "Archive",
|
|
||||||
"imprint": "Imprint",
|
|
||||||
"brand_aria": "Go to homepage"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"greeting": "Hi 🖖 Welcome to my blog 🤗",
|
|
||||||
"latest": "Latest posts",
|
|
||||||
"more_archive": "All posts in the archive →",
|
|
||||||
"empty": "No posts found on the queried relays."
|
|
||||||
},
|
|
||||||
"archive": {
|
|
||||||
"title": "Archive",
|
|
||||||
"subtitle": "All posts, grouped by year.",
|
|
||||||
"doc_title": "Archive – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"back_to_overview": "← Back to overview",
|
|
||||||
"untitled": "(untitled)",
|
|
||||||
"published_on": "Published on {date}",
|
|
||||||
"not_found": "Post \"{slug}\" not found.",
|
|
||||||
"unknown_error": "Unknown error"
|
|
||||||
},
|
|
||||||
"imprint": {
|
|
||||||
"doc_title": "Imprint – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"lang": {
|
|
||||||
"switch_aria": "Switch language"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
// place files you want to import through the `$lib` alias in this folder.
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
* Bootstrap-Relay ist bewusst als erster Eintrag Teil der Liste — ein Ort der Wahrheit.
|
|
||||||
*/
|
|
||||||
export const FALLBACK_READ_RELAYS = [
|
|
||||||
BOOTSTRAP_RELAY,
|
|
||||||
'wss://nos.lol',
|
|
||||||
'wss://relay.primal.net',
|
|
||||||
'wss://relay.tchncs.de',
|
|
||||||
'wss://relay.edufeed.org',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Habla.news-Route für Addressable Events — URL endet auf `/a/`, der
|
|
||||||
* vollständige Deep-Link wird durch Anhängen des `naddr1…`-Bech32 gebildet.
|
|
||||||
*/
|
|
||||||
export const HABLA_BASE = 'https://habla.news/a/';
|
|
||||||
|
|
||||||
/** Soft-Timeout: einzelne Relay-Abfrage darf nicht länger als diese Dauer blockieren. */
|
|
||||||
export const RELAY_TIMEOUT_MS = 8000;
|
|
||||||
|
|
||||||
/** Hard-Timeout: Page-Budget, nach dem eine Route-Abfrage endgültig abbricht. */
|
|
||||||
export const RELAY_HARD_TIMEOUT_MS = 15000;
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { resolveTranslationsFromRefs } from './loaders';
|
|
||||||
import type { NostrEvent } from './loaders';
|
|
||||||
import type { TranslationRef } from './translations';
|
|
||||||
|
|
||||||
function ev(tags: string[][]): NostrEvent {
|
|
||||||
return {
|
|
||||||
id: 'x',
|
|
||||||
pubkey: 'p',
|
|
||||||
created_at: 0,
|
|
||||||
kind: 30023,
|
|
||||||
tags,
|
|
||||||
content: '',
|
|
||||||
sig: 's'
|
|
||||||
} as unknown as NostrEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('resolveTranslationsFromRefs', () => {
|
|
||||||
it('liefert lang/slug/title für jeden aufgelösten ref', async () => {
|
|
||||||
const refs: TranslationRef[] = [
|
|
||||||
{ kind: 30023, pubkey: 'p1', dtag: 'hello' }
|
|
||||||
];
|
|
||||||
const fetcher = async () => [
|
|
||||||
ev([
|
|
||||||
['d', 'hello'],
|
|
||||||
['title', 'Hello World'],
|
|
||||||
['L', 'ISO-639-1'],
|
|
||||||
['l', 'en', 'ISO-639-1']
|
|
||||||
])
|
|
||||||
];
|
|
||||||
const result = await resolveTranslationsFromRefs(refs, fetcher);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ lang: 'en', slug: 'hello', title: 'Hello World' }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert refs, zu denen kein event gefunden wird', async () => {
|
|
||||||
const refs: TranslationRef[] = [
|
|
||||||
{ kind: 30023, pubkey: 'p1', dtag: 'hello' },
|
|
||||||
{ kind: 30023, pubkey: 'p1', dtag: 'missing' }
|
|
||||||
];
|
|
||||||
const fetcher = async (r: TranslationRef) =>
|
|
||||||
r.dtag === 'hello'
|
|
||||||
? [ev([
|
|
||||||
['d', 'hello'],
|
|
||||||
['title', 'Hi'],
|
|
||||||
['l', 'en', 'ISO-639-1']
|
|
||||||
])]
|
|
||||||
: [];
|
|
||||||
const result = await resolveTranslationsFromRefs(refs, fetcher);
|
|
||||||
expect(result).toEqual([{ lang: 'en', slug: 'hello', title: 'Hi' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert events ohne l-tag (sprache unklar)', async () => {
|
|
||||||
const refs: TranslationRef[] = [
|
|
||||||
{ kind: 30023, pubkey: 'p', dtag: 'x' }
|
|
||||||
];
|
|
||||||
const fetcher = async () => [
|
|
||||||
ev([
|
|
||||||
['d', 'x'],
|
|
||||||
['title', 'kein lang-tag']
|
|
||||||
])
|
|
||||||
];
|
|
||||||
const result = await resolveTranslationsFromRefs(refs, fetcher);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leere ref-liste → leere ergebnis-liste', async () => {
|
|
||||||
const fetcher = async () => {
|
|
||||||
throw new Error('should not be called');
|
|
||||||
};
|
|
||||||
expect(await resolveTranslationsFromRefs([], fetcher)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { lastValueFrom, timeout, toArray, EMPTY, tap } from 'rxjs';
|
|
||||||
import { catchError } from 'rxjs/operators';
|
|
||||||
import type { NostrEvent } from 'applesauce-core/helpers/event';
|
|
||||||
import type { Filter as ApplesauceFilter } from 'applesauce-core/helpers/filter';
|
|
||||||
import { pool } from './pool';
|
|
||||||
import { readRelays } from '$lib/stores/readRelays';
|
|
||||||
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
|
|
||||||
import type { TranslationRef } from './translations';
|
|
||||||
|
|
||||||
/** Re-export als sprechenden Alias */
|
|
||||||
export type { NostrEvent };
|
|
||||||
|
|
||||||
/** Profile-Content (kind:0) */
|
|
||||||
export interface Profile {
|
|
||||||
name?: string;
|
|
||||||
display_name?: string;
|
|
||||||
picture?: string;
|
|
||||||
banner?: string;
|
|
||||||
about?: string;
|
|
||||||
website?: string;
|
|
||||||
nip05?: string;
|
|
||||||
lud16?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Filter = ApplesauceFilter;
|
|
||||||
|
|
||||||
interface CollectOpts {
|
|
||||||
onEvent?: (ev: NostrEvent) => void;
|
|
||||||
hardTimeoutMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Startet eine Request-Subscription und sammelt alle gelieferten Events
|
|
||||||
* bis EOSE (pool.request completes nach EOSE) oder Hard-Timeout.
|
|
||||||
*/
|
|
||||||
async function collectEvents(
|
|
||||||
relays: string[],
|
|
||||||
filter: Filter,
|
|
||||||
opts: CollectOpts = {}
|
|
||||||
): Promise<NostrEvent[]> {
|
|
||||||
const events = await lastValueFrom(
|
|
||||||
pool.request(relays, filter).pipe(
|
|
||||||
tap((ev: NostrEvent) => opts.onEvent?.(ev)),
|
|
||||||
timeout(opts.hardTimeoutMs ?? RELAY_HARD_TIMEOUT_MS),
|
|
||||||
toArray(),
|
|
||||||
catchError(() => EMPTY)
|
|
||||||
),
|
|
||||||
{ defaultValue: [] as NostrEvent[] }
|
|
||||||
);
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Dedup per d-Tag: neueste (created_at) wins */
|
|
||||||
function dedupByDtag(events: NostrEvent[]): NostrEvent[] {
|
|
||||||
const byDtag = new Map<string, NostrEvent>();
|
|
||||||
for (const ev of events) {
|
|
||||||
const d = ev.tags.find((t) => t[0] === 'd')?.[1];
|
|
||||||
if (!d) continue;
|
|
||||||
const existing = byDtag.get(d);
|
|
||||||
if (!existing || ev.created_at > existing.created_at) {
|
|
||||||
byDtag.set(d, ev);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...byDtag.values()];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Alle kind:30023-Posts des Autors, neueste zuerst */
|
|
||||||
export async function loadPostList(
|
|
||||||
onEvent?: (ev: NostrEvent) => void
|
|
||||||
): Promise<NostrEvent[]> {
|
|
||||||
const relays = get(readRelays);
|
|
||||||
const events = await collectEvents(
|
|
||||||
relays,
|
|
||||||
{ kinds: [30023], authors: [AUTHOR_PUBKEY_HEX], limit: 200 },
|
|
||||||
{ onEvent }
|
|
||||||
);
|
|
||||||
const deduped = dedupByDtag(events);
|
|
||||||
return deduped.sort((a, b) => {
|
|
||||||
const ap = parseInt(
|
|
||||||
a.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${a.created_at}`,
|
|
||||||
10
|
|
||||||
);
|
|
||||||
const bp = parseInt(
|
|
||||||
b.tags.find((t) => t[0] === 'published_at')?.[1] ?? `${b.created_at}`,
|
|
||||||
10
|
|
||||||
);
|
|
||||||
return bp - ap;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Einzelpost per d-Tag */
|
|
||||||
export async function loadPost(dtag: string): Promise<NostrEvent | null> {
|
|
||||||
const relays = get(readRelays);
|
|
||||||
const events = await collectEvents(relays, {
|
|
||||||
kinds: [30023],
|
|
||||||
authors: [AUTHOR_PUBKEY_HEX],
|
|
||||||
'#d': [dtag],
|
|
||||||
limit: 1
|
|
||||||
});
|
|
||||||
if (events.length === 0) return null;
|
|
||||||
return events.reduce((best, cur) =>
|
|
||||||
cur.created_at > best.created_at ? cur : best
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Profil-Event kind:0 (neueste Version).
|
|
||||||
* Default: Autoren-Pubkey der SPA. Optional: beliebiger Pubkey für
|
|
||||||
* die Anzeige fremder Kommentar-Autoren.
|
|
||||||
*/
|
|
||||||
export async function loadProfile(pubkey: string = AUTHOR_PUBKEY_HEX): Promise<Profile | null> {
|
|
||||||
const relays = get(readRelays);
|
|
||||||
const events = await collectEvents(relays, {
|
|
||||||
kinds: [0],
|
|
||||||
authors: [pubkey],
|
|
||||||
limit: 1
|
|
||||||
});
|
|
||||||
if (events.length === 0) return null;
|
|
||||||
const latest = events.reduce((best, cur) =>
|
|
||||||
cur.created_at > best.created_at ? cur : best
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
return JSON.parse(latest.content) as Profile;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Post-Adresse im `a`-Tag-Format: "30023:<pubkey>:<dtag>" */
|
|
||||||
function eventAddress(pubkey: string, dtag: string): string {
|
|
||||||
return `30023:${pubkey}:${dtag}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Alle kind:1-Replies auf einen Post, chronologisch aufsteigend (älteste zuerst).
|
|
||||||
* Streamt via onEvent, wenn angegeben.
|
|
||||||
*/
|
|
||||||
export async function loadReplies(
|
|
||||||
dtag: string,
|
|
||||||
onEvent?: (ev: NostrEvent) => void
|
|
||||||
): Promise<NostrEvent[]> {
|
|
||||||
const relays = get(readRelays);
|
|
||||||
const address = eventAddress(AUTHOR_PUBKEY_HEX, dtag);
|
|
||||||
const events = await collectEvents(
|
|
||||||
relays,
|
|
||||||
{ kinds: [1], '#a': [address], limit: 500 },
|
|
||||||
{ onEvent }
|
|
||||||
);
|
|
||||||
return events.sort((a, b) => a.created_at - b.created_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<NostrEvent[]> {
|
|
||||||
const all = await loadPostList();
|
|
||||||
const norm = tagName.toLowerCase();
|
|
||||||
return all.filter((ev) =>
|
|
||||||
ev.tags.some((t) => t[0] === 't' && t[1]?.toLowerCase() === norm)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReactionSummary {
|
|
||||||
/** Emoji oder "+"/"-" */
|
|
||||||
content: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregiert kind:7-Reactions auf einen Post.
|
|
||||||
* Gruppiert nach content, zählt Anzahl.
|
|
||||||
*/
|
|
||||||
export async function loadReactions(dtag: string): Promise<ReactionSummary[]> {
|
|
||||||
const relays = get(readRelays);
|
|
||||||
const address = eventAddress(AUTHOR_PUBKEY_HEX, dtag);
|
|
||||||
const events = await collectEvents(relays, {
|
|
||||||
kinds: [7],
|
|
||||||
'#a': [address],
|
|
||||||
limit: 500
|
|
||||||
});
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const ev of events) {
|
|
||||||
const key = ev.content || '+';
|
|
||||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
return [...counts.entries()]
|
|
||||||
.map(([content, count]) => ({ content, count }))
|
|
||||||
.sort((a, b) => b.count - a.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TranslationInfo {
|
|
||||||
lang: string;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure Variante für Tests — erhält die Events via Fetcher statt Relays.
|
|
||||||
*/
|
|
||||||
export async function resolveTranslationsFromRefs(
|
|
||||||
refs: TranslationRef[],
|
|
||||||
fetcher: (ref: TranslationRef) => Promise<NostrEvent[]>
|
|
||||||
): Promise<TranslationInfo[]> {
|
|
||||||
if (refs.length === 0) return [];
|
|
||||||
const results = await Promise.all(refs.map(fetcher));
|
|
||||||
const infos: TranslationInfo[] = [];
|
|
||||||
for (let i = 0; i < refs.length; i++) {
|
|
||||||
const evs = results[i];
|
|
||||||
if (evs.length === 0) continue;
|
|
||||||
const latest = evs.reduce((best, cur) =>
|
|
||||||
cur.created_at > best.created_at ? cur : best
|
|
||||||
);
|
|
||||||
const lang = latest.tags.find((t) => t[0] === 'l')?.[1];
|
|
||||||
if (!lang) continue;
|
|
||||||
const slug = latest.tags.find((t) => t[0] === 'd')?.[1] ?? refs[i].dtag;
|
|
||||||
const title = latest.tags.find((t) => t[0] === 'title')?.[1] ?? '';
|
|
||||||
infos.push({ lang, slug, title });
|
|
||||||
}
|
|
||||||
return infos;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loader: findet die anderssprachigen Varianten eines Posts.
|
|
||||||
* Liefert leere Liste, wenn keine a-Tags mit marker "translation" vorhanden.
|
|
||||||
*/
|
|
||||||
export async function loadTranslations(
|
|
||||||
event: NostrEvent
|
|
||||||
): Promise<TranslationInfo[]> {
|
|
||||||
const { parseTranslationRefs } = await import('./translations');
|
|
||||||
const refs = parseTranslationRefs(event);
|
|
||||||
if (refs.length === 0) return [];
|
|
||||||
const relays = get(readRelays);
|
|
||||||
return resolveTranslationsFromRefs(refs, (ref) =>
|
|
||||||
collectEvents(relays, {
|
|
||||||
kinds: [ref.kind],
|
|
||||||
authors: [ref.pubkey],
|
|
||||||
'#d': [ref.dtag],
|
|
||||||
limit: 1
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
import { nip19 } from 'nostr-tools';
|
|
||||||
import { HABLA_BASE } from './config';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Argumente für NIP-19 addressable-event-Pointer.
|
|
||||||
* Validierung (hex-Länge etc.) wird an `nip19.naddrEncode` delegiert.
|
|
||||||
*/
|
|
||||||
export interface NaddrArgs {
|
|
||||||
pubkey: string;
|
|
||||||
kind: number;
|
|
||||||
identifier: string;
|
|
||||||
relays?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Baut einen `naddr1…`-Bech32-String (NIP-19) für ein addressable Event.
|
|
||||||
* Wird u. a. für Habla.news-Deep-Links genutzt.
|
|
||||||
*/
|
|
||||||
export function buildNaddr(args: NaddrArgs): string {
|
|
||||||
return nip19.naddrEncode({
|
|
||||||
pubkey: args.pubkey,
|
|
||||||
kind: args.kind,
|
|
||||||
identifier: args.identifier,
|
|
||||||
relays: args.relays ?? []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Habla.news-Deep-Link auf ein addressable Event.
|
|
||||||
* Fallback für „Post nicht gefunden" / JS-lose Clients.
|
|
||||||
*/
|
|
||||||
export function buildHablaLink(args: NaddrArgs): string {
|
|
||||||
return `${HABLA_BASE}${buildNaddr(args)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `npub1…`-Bech32-String für einen Pubkey — für Profil-Links außerhalb
|
|
||||||
* der SPA (z. B. njump.me).
|
|
||||||
*/
|
|
||||||
export function buildNpub(pubkeyHex: string): string {
|
|
||||||
return nip19.npubEncode(pubkeyHex);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* njump.me-Profil-URL. Öffnet das Nostr-native Profil-Browser mit
|
|
||||||
* vollständiger Event-Historie.
|
|
||||||
*/
|
|
||||||
export function buildNjumpProfileUrl(pubkeyHex: string): string {
|
|
||||||
return `https://njump.me/${buildNpub(pubkeyHex)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Liste externer Nostr-Clients für „Post öffnen in …"-Links.
|
|
||||||
* Nutzt naddr, damit jeder Client das addressable Event adressieren kann.
|
|
||||||
* EduFeed zuerst — OER/OEP-Bildungscommunity, wichtig für Jörgs Zielgruppe.
|
|
||||||
*/
|
|
||||||
export function externalClientLinks(
|
|
||||||
args: NaddrArgs
|
|
||||||
): { label: string; url: string }[] {
|
|
||||||
const naddr = buildNaddr(args);
|
|
||||||
return [
|
|
||||||
{ label: 'EduFeed', url: `https://edufeed.org/${naddr}` },
|
|
||||||
{ label: 'Habla', url: `https://habla.news/a/${naddr}` },
|
|
||||||
{ label: 'Yakihonne', url: `https://yakihonne.com/article/${naddr}` }
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import { RelayPool } from 'applesauce-relay';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton-Pool für alle Nostr-Requests der SPA.
|
|
||||||
* applesauce-relay verwaltet Reconnects, Subscriptions, deduping intern.
|
|
||||||
*/
|
|
||||||
export const pool = new RelayPool();
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
import type { Profile } from './loaders';
|
|
||||||
import { loadProfile } from './loaders';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sessionsweiter Cache für kind:0-Profile.
|
|
||||||
* Jeder Pubkey wird maximal einmal angefragt; mehrfache parallele
|
|
||||||
* Aufrufe teilen sich dieselbe Promise.
|
|
||||||
*/
|
|
||||||
const cache = new Map<string, Promise<Profile | null>>();
|
|
||||||
|
|
||||||
export function getProfile(pubkey: string): Promise<Profile | null> {
|
|
||||||
const existing = cache.get(pubkey);
|
|
||||||
if (existing) return existing;
|
|
||||||
const pending = loadProfile(pubkey);
|
|
||||||
cache.set(pubkey, pending);
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { lastValueFrom, timeout, toArray, EMPTY } from 'rxjs';
|
|
||||||
import { catchError } from 'rxjs/operators';
|
|
||||||
import type { NostrEvent } from 'applesauce-core/helpers/event';
|
|
||||||
import { pool } from './pool';
|
|
||||||
import {
|
|
||||||
AUTHOR_PUBKEY_HEX,
|
|
||||||
BOOTSTRAP_RELAY,
|
|
||||||
FALLBACK_READ_RELAYS,
|
|
||||||
RELAY_TIMEOUT_MS
|
|
||||||
} from './config';
|
|
||||||
|
|
||||||
export interface OutboxRelay {
|
|
||||||
url: string;
|
|
||||||
/** true = zum Lesen zu nutzen (kein dritter Tag-Wert oder "read") */
|
|
||||||
read: boolean;
|
|
||||||
/** true = zum Schreiben zu nutzen (kein dritter Tag-Wert oder "write") */
|
|
||||||
write: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lädt die NIP-65-Relay-Liste (kind:10002) des Autors vom Bootstrap-Relay.
|
|
||||||
* Fallback auf FALLBACK_READ_RELAYS, wenn das Event nicht innerhalb von
|
|
||||||
* RELAY_TIMEOUT_MS gefunden wird.
|
|
||||||
*
|
|
||||||
* Interpretation des dritten Tag-Werts:
|
|
||||||
* - nicht gesetzt → read + write
|
|
||||||
* - "read" → nur read
|
|
||||||
* - "write" → nur write
|
|
||||||
*/
|
|
||||||
export async function loadOutboxRelays(): Promise<OutboxRelay[]> {
|
|
||||||
const event = await firstEvent();
|
|
||||||
|
|
||||||
if (!event) {
|
|
||||||
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const relays: OutboxRelay[] = [];
|
|
||||||
for (const tag of event.tags) {
|
|
||||||
if (tag[0] !== 'r' || !tag[1]) continue;
|
|
||||||
const mode = tag[2];
|
|
||||||
relays.push({
|
|
||||||
url: tag[1],
|
|
||||||
read: mode !== 'write',
|
|
||||||
write: mode !== 'read'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (relays.length === 0) {
|
|
||||||
return FALLBACK_READ_RELAYS.map((url) => ({ url, read: true, write: true }));
|
|
||||||
}
|
|
||||||
|
|
||||||
return relays;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nur die Read-URLs aus OutboxRelay[] */
|
|
||||||
export function readUrls(relays: OutboxRelay[]): string[] {
|
|
||||||
return relays.filter((r) => r.read).map((r) => r.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Nur die Write-URLs aus OutboxRelay[] */
|
|
||||||
export function writeUrls(relays: OutboxRelay[]): string[] {
|
|
||||||
return relays.filter((r) => r.write).map((r) => r.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Internes --------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fragt das neueste kind:10002-Event vom Bootstrap-Relay ab.
|
|
||||||
* Sammelt alle Events bis EOSE (`pool.request(...)` emittiert nur Events
|
|
||||||
* und completes bei EOSE), nimmt das neueste, oder null falls keines.
|
|
||||||
*/
|
|
||||||
async function firstEvent(): Promise<NostrEvent | null> {
|
|
||||||
try {
|
|
||||||
const events = await lastValueFrom(
|
|
||||||
pool
|
|
||||||
.request([BOOTSTRAP_RELAY], {
|
|
||||||
kinds: [10002],
|
|
||||||
authors: [AUTHOR_PUBKEY_HEX],
|
|
||||||
limit: 1
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
timeout(RELAY_TIMEOUT_MS),
|
|
||||||
toArray(),
|
|
||||||
catchError(() => EMPTY)
|
|
||||||
),
|
|
||||||
{ defaultValue: [] as NostrEvent[] }
|
|
||||||
);
|
|
||||||
if (events.length === 0) return null;
|
|
||||||
return events.reduce((best, cur) =>
|
|
||||||
cur.created_at > best.created_at ? cur : best
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
/**
|
|
||||||
* NIP-07-Wrapper für Browser-Extension-Signer (Alby, nos2x, Flamingo).
|
|
||||||
*
|
|
||||||
* `window.nostr` ist optional — wenn die Extension fehlt, liefern die Helper
|
|
||||||
* null zurück und der Aufrufer zeigt einen Hinweis an.
|
|
||||||
*/
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
nostr?: {
|
|
||||||
getPublicKey(): Promise<string>;
|
|
||||||
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UnsignedEvent {
|
|
||||||
kind: number;
|
|
||||||
tags: string[][];
|
|
||||||
content: string;
|
|
||||||
created_at: number;
|
|
||||||
pubkey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SignedEvent extends UnsignedEvent {
|
|
||||||
id: string;
|
|
||||||
sig: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hasNip07(): boolean {
|
|
||||||
return typeof window !== 'undefined' && !!window.nostr;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPublicKey(): Promise<string | null> {
|
|
||||||
if (!hasNip07()) return null;
|
|
||||||
try {
|
|
||||||
return await window.nostr!.getPublicKey();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signEvent(event: UnsignedEvent): Promise<SignedEvent | null> {
|
|
||||||
if (!hasNip07()) return null;
|
|
||||||
try {
|
|
||||||
return await window.nostr!.signEvent(event);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { parseTranslationRefs } from './translations';
|
|
||||||
import type { NostrEvent } from './loaders';
|
|
||||||
|
|
||||||
function ev(tags: string[][]): NostrEvent {
|
|
||||||
return {
|
|
||||||
id: 'x',
|
|
||||||
pubkey: 'p',
|
|
||||||
created_at: 0,
|
|
||||||
kind: 30023,
|
|
||||||
tags,
|
|
||||||
content: '',
|
|
||||||
sig: 's'
|
|
||||||
} as unknown as NostrEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('parseTranslationRefs', () => {
|
|
||||||
it('extrahiert a-tags mit marker "translation"', () => {
|
|
||||||
const e = ev([
|
|
||||||
['d', 'x'],
|
|
||||||
['a', '30023:abc:other-slug', '', 'translation'],
|
|
||||||
['a', '30023:abc:third-slug', '', 'translation']
|
|
||||||
]);
|
|
||||||
expect(parseTranslationRefs(e)).toEqual([
|
|
||||||
{ kind: 30023, pubkey: 'abc', dtag: 'other-slug' },
|
|
||||||
{ kind: 30023, pubkey: 'abc', dtag: 'third-slug' }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert a-tags ohne marker "translation"', () => {
|
|
||||||
const e = ev([
|
|
||||||
['a', '30023:abc:root-thread', '', 'root'],
|
|
||||||
['a', '30023:abc:x', '', 'reply']
|
|
||||||
]);
|
|
||||||
expect(parseTranslationRefs(e)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert a-tags mit malformed coordinate', () => {
|
|
||||||
const e = ev([
|
|
||||||
['a', 'not-a-coord', '', 'translation'],
|
|
||||||
['a', '30023:abc:ok', '', 'translation']
|
|
||||||
]);
|
|
||||||
expect(parseTranslationRefs(e)).toEqual([
|
|
||||||
{ kind: 30023, pubkey: 'abc', dtag: 'ok' }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leeres tag-array → leere liste', () => {
|
|
||||||
expect(parseTranslationRefs(ev([]))).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import type { NostrEvent } from './loaders';
|
|
||||||
|
|
||||||
export interface TranslationRef {
|
|
||||||
kind: number;
|
|
||||||
pubkey: string;
|
|
||||||
dtag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COORD_RE = /^(\d+):([0-9a-f]+):([a-z0-9][a-z0-9-]*)$/;
|
|
||||||
|
|
||||||
export function parseTranslationRefs(event: NostrEvent): TranslationRef[] {
|
|
||||||
const refs: TranslationRef[] = [];
|
|
||||||
for (const tag of event.tags) {
|
|
||||||
if (tag[0] !== 'a') continue;
|
|
||||||
if (tag[3] !== 'translation') continue;
|
|
||||||
const coord = tag[1];
|
|
||||||
if (typeof coord !== 'string') continue;
|
|
||||||
const m = coord.match(COORD_RE);
|
|
||||||
if (!m) continue;
|
|
||||||
refs.push({
|
|
||||||
kind: parseInt(m[1], 10),
|
|
||||||
pubkey: m[2],
|
|
||||||
dtag: m[3]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return refs;
|
|
||||||
}
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { Marked } from 'marked';
|
|
||||||
import DOMPurify from 'dompurify';
|
|
||||||
import hljs from 'highlight.js/lib/core';
|
|
||||||
import javascript from 'highlight.js/lib/languages/javascript';
|
|
||||||
import bash from 'highlight.js/lib/languages/bash';
|
|
||||||
import typescript from 'highlight.js/lib/languages/typescript';
|
|
||||||
import json from 'highlight.js/lib/languages/json';
|
|
||||||
|
|
||||||
hljs.registerLanguage('javascript', javascript);
|
|
||||||
hljs.registerLanguage('js', javascript);
|
|
||||||
hljs.registerLanguage('typescript', typescript);
|
|
||||||
hljs.registerLanguage('ts', typescript);
|
|
||||||
hljs.registerLanguage('bash', bash);
|
|
||||||
hljs.registerLanguage('sh', bash);
|
|
||||||
hljs.registerLanguage('json', json);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lokaler Marked-Instance, damit die globale `marked`-Singleton nicht
|
|
||||||
* mutiert wird — andere Module können `marked` unbeeinflusst weiterverwenden.
|
|
||||||
* (Spec §3: lokale Ersetzbarkeit der Engine.)
|
|
||||||
*/
|
|
||||||
const markedInstance = new Marked({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true,
|
|
||||||
renderer: {
|
|
||||||
code({ text, lang }) {
|
|
||||||
const language = lang && hljs.getLanguage(lang) ? lang : undefined;
|
|
||||||
const highlighted = language
|
|
||||||
? hljs.highlight(text, { language }).value
|
|
||||||
: hljs.highlightAuto(text).value;
|
|
||||||
const cls = language ? ` language-${language}` : '';
|
|
||||||
return `<pre><code class="hljs${cls}">${highlighted}</code></pre>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rendert einen Markdown-String zu sanitized HTML.
|
|
||||||
* Einziger Export des Moduls — so bleibt Austausch der Engine lokal.
|
|
||||||
*
|
|
||||||
* Nur im Browser/jsdom aufrufen: DOMPurify braucht ein DOM. Die SPA
|
|
||||||
* hat SSR global ausgeschaltet (`+layout.ts: ssr = false`), Vitest läuft
|
|
||||||
* in jsdom — beide Szenarien sind abgedeckt. Ein Aufruf in reiner
|
|
||||||
* Node-Umgebung würde hier laut fehlschlagen statt stumm unsicher
|
|
||||||
* durchzulaufen.
|
|
||||||
*/
|
|
||||||
export function renderMarkdown(md: string): string {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
throw new Error('renderMarkdown: DOM-Kontext erforderlich (Browser oder jsdom).');
|
|
||||||
}
|
|
||||||
const raw = markedInstance.parse(md, { async: false }) as string;
|
|
||||||
return DOMPurify.sanitize(raw);
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { writable, type Readable } from 'svelte/store';
|
|
||||||
import { loadOutboxRelays, readUrls } from '$lib/nostr/relays';
|
|
||||||
import { FALLBACK_READ_RELAYS } from '$lib/nostr/config';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store mit der aktuellen Read-Relay-Liste.
|
|
||||||
* Initial = FALLBACK_READ_RELAYS, damit die SPA sofort abfragen kann;
|
|
||||||
* sobald loadOutboxRelays() fertig ist, wird der Store aktualisiert.
|
|
||||||
*
|
|
||||||
* Singleton-Initialisierung: bootstrapReadRelays() wird genau einmal beim ersten
|
|
||||||
* Import aufgerufen.
|
|
||||||
*/
|
|
||||||
const store = writable<string[]>([...FALLBACK_READ_RELAYS]);
|
|
||||||
let bootstrapped = false;
|
|
||||||
|
|
||||||
export function bootstrapReadRelays(): void {
|
|
||||||
if (bootstrapped) return;
|
|
||||||
bootstrapped = true;
|
|
||||||
loadOutboxRelays()
|
|
||||||
.then((relays) => {
|
|
||||||
const urls = readUrls(relays);
|
|
||||||
if (urls.length > 0) store.set(urls);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// Store behält seinen initialen FALLBACK-Zustand
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const readRelays: Readable<string[]> = store;
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
/**
|
|
||||||
* Erkennt Legacy-Hugo-URLs der Form /YYYY/MM/DD/<dtag>.html oder .../<dtag>.html/
|
|
||||||
* und gibt den dtag-Teil zurück. Für alle anderen Pfade: null.
|
|
||||||
*
|
|
||||||
* Erwartet nur den Pfad ohne Query/Fragment — wenn vorhanden vom Aufrufer
|
|
||||||
* trennen. `decodeURIComponent` wird defensiv gekapselt, damit malformed
|
|
||||||
* Percent-Encoding die SPA beim Boot nicht crasht.
|
|
||||||
*/
|
|
||||||
export function parseLegacyUrl(path: string): string | null {
|
|
||||||
const match = path.match(/^\/\d{4}\/\d{2}\/\d{2}\/([^/]+?)\.html\/?$/);
|
|
||||||
if (!match) return null;
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(match[1]);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Erzeugt die kanonische kurze Post-URL /<dtag>/.
|
|
||||||
*/
|
|
||||||
export function canonicalPostPath(dtag: string): string {
|
|
||||||
return `/${encodeURIComponent(dtag)}/`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { bootstrapReadRelays } from '$lib/stores/readRelays';
|
|
||||||
import { initI18n, t } from '$lib/i18n';
|
|
||||||
import LanguageSwitcher from '$lib/components/LanguageSwitcher.svelte';
|
|
||||||
import CcZeroBadge from '$lib/components/CcZeroBadge.svelte';
|
|
||||||
|
|
||||||
initI18n();
|
|
||||||
|
|
||||||
let { children } = $props();
|
|
||||||
|
|
||||||
// Normalisierter pfad ohne trailing slash für aktiv-erkennung ("/" bleibt "/")
|
|
||||||
const currentPath = $derived((page.url?.pathname ?? '/').replace(/\/$/, '') || '/');
|
|
||||||
|
|
||||||
function isActive(path: string): boolean {
|
|
||||||
const normalized = path.replace(/\/$/, '') || '/';
|
|
||||||
return currentPath === normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
bootstrapReadRelays();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- favicon-Tags liegen in src/app.html — hier nichts nötig. -->
|
|
||||||
|
|
||||||
<header class="site-header">
|
|
||||||
<div class="header-inner">
|
|
||||||
<a href="/" class="brand" aria-label={$t('nav.brand_aria')}>Jörg Lohrer</a>
|
|
||||||
<nav aria-label={$t('nav.brand_aria')}>
|
|
||||||
<a href="/" class:active={isActive('/')}>{$t('nav.home')}</a>
|
|
||||||
<a href="/archiv/" class:active={isActive('/archiv/')}>{$t('nav.archive')}</a>
|
|
||||||
<a href="/impressum/" class:active={isActive('/impressum/')}>{$t('nav.imprint')}</a>
|
|
||||||
<LanguageSwitcher />
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
{@render children()}
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="site-footer">
|
|
||||||
<div class="footer-inner">
|
|
||||||
<span class="footer-license">
|
|
||||||
<a
|
|
||||||
href="https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
target="_blank"
|
|
||||||
rel="license noopener"
|
|
||||||
aria-label="CC0 1.0 Universal Public Domain Dedication"
|
|
||||||
title="CC0 1.0 Universal"
|
|
||||||
>
|
|
||||||
<CcZeroBadge />
|
|
||||||
<span class="cc-label">CC0</span>
|
|
||||||
</a>
|
|
||||||
Jörg Lohrer
|
|
||||||
</span>
|
|
||||||
<span class="footer-sep">·</span>
|
|
||||||
<a href="/impressum/">{$t('nav.imprint')}</a>
|
|
||||||
<span class="footer-sep">·</span>
|
|
||||||
<a
|
|
||||||
href="https://github.com/joerglohrer/joerglohrerde"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
title="Quellcode, Making-of und Nostr-Publish-Pipeline"
|
|
||||||
>Nostr-basiert – Making-of im Repo</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.site-header {
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--bg);
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 10;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
}
|
|
||||||
.header-inner {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.brand {
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
color: var(--fg);
|
|
||||||
text-decoration: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
nav a {
|
|
||||||
color: var(--muted);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
padding: 0.25rem 0;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
transition: color 120ms, border-color 120ms;
|
|
||||||
}
|
|
||||||
nav a:hover {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
nav a.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-bottom-color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
min-height: calc(100vh - 200px);
|
|
||||||
}
|
|
||||||
@media (min-width: 640px) {
|
|
||||||
main {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.site-footer {
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
margin-top: 3rem;
|
|
||||||
}
|
|
||||||
.footer-inner {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 1rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.3rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.footer-inner a {
|
|
||||||
color: var(--muted);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.footer-inner a:hover {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.footer-sep {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
.footer-license a {
|
|
||||||
color: var(--accent);
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25em;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.footer-license a:hover .cc-label {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.cc-label {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
export const prerender = false;
|
|
||||||
export const ssr = false;
|
|
||||||
export const trailingSlash = 'always';
|
|
||||||
|
|
@ -1,182 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { NostrEvent, Profile } from '$lib/nostr/loaders';
|
|
||||||
import { loadPostList } from '$lib/nostr/loaders';
|
|
||||||
import { getProfile } from '$lib/nostr/profileCache';
|
|
||||||
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
|
||||||
import PostCard from '$lib/components/PostCard.svelte';
|
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
|
||||||
import SocialIcons from '$lib/components/SocialIcons.svelte';
|
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
// Lokales Profilbild aus static/ — schneller als der Nostr-kind:0-Roundtrip
|
|
||||||
// fürs kind:0 -> picture-Feld (URL wäre identisch, aber Netzwerk-Latenz).
|
|
||||||
const HERO_AVATAR = '/joerg-profil-2024.webp';
|
|
||||||
const LATEST_COUNT = 5;
|
|
||||||
|
|
||||||
let profile: Profile | null = $state(null);
|
|
||||||
let posts: NostrEvent[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error: string | null = $state(null);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
const [p, list] = await Promise.all([getProfile(AUTHOR_PUBKEY_HEX), loadPostList()]);
|
|
||||||
profile = p;
|
|
||||||
posts = list;
|
|
||||||
loading = false;
|
|
||||||
if (list.length === 0) {
|
|
||||||
error = get(t)('home.empty');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
loading = false;
|
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const p = profile;
|
|
||||||
const name = (p && (p.display_name ?? p.name)) ?? 'Jörg Lohrer';
|
|
||||||
document.title = `${name} – Blog`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const displayName = $derived.by(() => {
|
|
||||||
const p = profile;
|
|
||||||
return (p && (p.display_name ?? p.name)) ?? 'Jörg Lohrer';
|
|
||||||
});
|
|
||||||
const avatarSrc = HERO_AVATAR;
|
|
||||||
const about = $derived.by(() => profile?.about ?? '');
|
|
||||||
const website = $derived.by(() => profile?.website ?? '');
|
|
||||||
let currentLocale = $state('de');
|
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const filtered = $derived.by(() =>
|
|
||||||
posts.filter((p) => {
|
|
||||||
const l = p.tags.find((tag) => tag[0] === 'l')?.[1];
|
|
||||||
return (l ?? 'de') === currentLocale;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const latest = $derived(filtered.slice(0, LATEST_COUNT));
|
|
||||||
const hasMore = $derived(filtered.length > LATEST_COUNT);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section class="hero">
|
|
||||||
<div class="hero-left">
|
|
||||||
<img class="avatar" src={avatarSrc} alt={displayName} />
|
|
||||||
<SocialIcons />
|
|
||||||
</div>
|
|
||||||
<div class="hero-text">
|
|
||||||
<h1 class="hero-name">{displayName}</h1>
|
|
||||||
<p class="hero-greeting">{$t('home.greeting')}</p>
|
|
||||||
{#if about}
|
|
||||||
<p class="hero-about">{about}</p>
|
|
||||||
{/if}
|
|
||||||
{#if website}
|
|
||||||
<div class="meta-line">
|
|
||||||
<a href={website} target="_blank" rel="noopener">
|
|
||||||
{website.replace(/^https?:\/\//, '').replace(/\/$/, '')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="latest">
|
|
||||||
<h2 class="section-title">{$t('home.latest')}</h2>
|
|
||||||
<LoadingOrError {loading} {error} />
|
|
||||||
{#each latest as post (post.id)}
|
|
||||||
<PostCard event={post} />
|
|
||||||
{/each}
|
|
||||||
{#if hasMore}
|
|
||||||
<div class="more">
|
|
||||||
<a href="/archiv/" class="more-link">{$t('home.more_archive')}</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.hero {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.25rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 1rem 0 2rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.hero-left {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
.avatar {
|
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
background: var(--code-bg);
|
|
||||||
border: 2px solid var(--accent);
|
|
||||||
}
|
|
||||||
.hero-text {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.hero-name {
|
|
||||||
margin: 0 0 0.3rem;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.hero-greeting {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.hero-about {
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
.meta-line {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.meta-line a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.meta-line a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.section-title {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.more {
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.more-link {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.more-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
|
||||||
.hero {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
|
||||||
.hero-left {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { NostrEvent } from '$lib/nostr/loaders';
|
|
||||||
import { loadPost } from '$lib/nostr/loaders';
|
|
||||||
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
|
||||||
import { buildHablaLink } from '$lib/nostr/naddr';
|
|
||||||
import PostView from '$lib/components/PostView.svelte';
|
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
const dtag = $derived(data.dtag);
|
|
||||||
|
|
||||||
let post: NostrEvent | null = $state(null);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error: string | null = $state(null);
|
|
||||||
|
|
||||||
const hablaLink = $derived(
|
|
||||||
buildHablaLink({
|
|
||||||
pubkey: AUTHOR_PUBKEY_HEX,
|
|
||||||
kind: 30023,
|
|
||||||
identifier: dtag
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const currentDtag = dtag;
|
|
||||||
post = null;
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
loadPost(currentDtag)
|
|
||||||
.then((p) => {
|
|
||||||
if (currentDtag !== dtag) return;
|
|
||||||
if (!p) {
|
|
||||||
error = get(t)('post.not_found', { values: { slug: currentDtag } });
|
|
||||||
} else {
|
|
||||||
post = p;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (currentDtag !== dtag) return;
|
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (currentDtag === dtag) loading = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>
|
|
||||||
|
|
||||||
<LoadingOrError {loading} {error} {hablaLink} />
|
|
||||||
|
|
||||||
{#if post}
|
|
||||||
<PostView event={post} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.breadcrumb {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.breadcrumb a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.breadcrumb a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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/<dtag>.html/ → Redirect auf /<dtag>/
|
|
||||||
const legacyDtag = parseLegacyUrl(pathname);
|
|
||||||
if (legacyDtag) {
|
|
||||||
throw redirect(301, canonicalPostPath(legacyDtag));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Kanonisch: /<dtag>/ — 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]) };
|
|
||||||
};
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { NostrEvent } from '$lib/nostr/loaders';
|
|
||||||
import { loadPostList } from '$lib/nostr/loaders';
|
|
||||||
import PostCard from '$lib/components/PostCard.svelte';
|
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
let posts: NostrEvent[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error: string | null = $state(null);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
posts = await loadPostList();
|
|
||||||
loading = false;
|
|
||||||
if (posts.length === 0) {
|
|
||||||
error = get(t)('home.empty');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
loading = false;
|
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentLocale = $state('de');
|
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const filtered = $derived.by(() =>
|
|
||||||
posts.filter((p) => {
|
|
||||||
const l = p.tags.find((tag) => tag[0] === 'l')?.[1];
|
|
||||||
return (l ?? 'de') === currentLocale;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Posts nach Jahr gruppieren (neueste zuerst)
|
|
||||||
type YearGroup = { year: number; posts: NostrEvent[] };
|
|
||||||
const groupsByYear = $derived.by<YearGroup[]>(() => {
|
|
||||||
const byYear = new Map<number, NostrEvent[]>();
|
|
||||||
for (const p of filtered) {
|
|
||||||
const ts = Number(p.tags.find((t) => t[0] === 'published_at')?.[1] ?? p.created_at);
|
|
||||||
const year = new Date(ts * 1000).getUTCFullYear();
|
|
||||||
if (!byYear.has(year)) byYear.set(year, []);
|
|
||||||
byYear.get(year)!.push(p);
|
|
||||||
}
|
|
||||||
return [...byYear.entries()]
|
|
||||||
.map(([year, p]) => ({ year, posts: p }))
|
|
||||||
.sort((a, b) => b.year - a.year);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('archive.doc_title')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1 class="title">{$t('archive.title')}</h1>
|
|
||||||
<p class="meta">{$t('archive.subtitle')}</p>
|
|
||||||
|
|
||||||
<LoadingOrError {loading} {error} />
|
|
||||||
|
|
||||||
{#each groupsByYear as group (group.year)}
|
|
||||||
<section class="year-group">
|
|
||||||
<h2 class="year">{group.year}</h2>
|
|
||||||
{#each group.posts as post (post.id)}
|
|
||||||
<PostCard event={post} />
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.title {
|
|
||||||
margin: 0 0 0.3rem;
|
|
||||||
font-size: 1.8rem;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
color: var(--muted);
|
|
||||||
margin: 0 0 2rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.year-group {
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
.year {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
padding-bottom: 0.3rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { renderMarkdown } from '$lib/render/markdown';
|
|
||||||
import impressumRaw from '../../../../content/impressum.md?raw';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
|
|
||||||
// Frontmatter abtrennen, nur Body rendern.
|
|
||||||
// Toleriert trailing spaces auf den ---/--- trenner-zeilen.
|
|
||||||
const match = impressumRaw.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n([\s\S]*)$/);
|
|
||||||
const body = match ? match[1] : impressumRaw;
|
|
||||||
const html = renderMarkdown(body);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('imprint.doc_title')}</title>
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<article class="impressum">
|
|
||||||
{@html html}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.impressum :global(h1) {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.impressum :global(h2) {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin: 2rem 0 0.6rem;
|
|
||||||
}
|
|
||||||
.impressum :global(h3) {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
margin: 1.4rem 0 0.4rem;
|
|
||||||
}
|
|
||||||
.impressum :global(p) {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.impressum :global(a) {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { NostrEvent } from '$lib/nostr/loaders';
|
|
||||||
import { loadPostsByTag } from '$lib/nostr/loaders';
|
|
||||||
import PostCard from '$lib/components/PostCard.svelte';
|
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
|
||||||
|
|
||||||
let { data } = $props();
|
|
||||||
const tagName = $derived(data.tagName);
|
|
||||||
|
|
||||||
let posts: NostrEvent[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error: string | null = $state(null);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
posts = await loadPostsByTag(tagName);
|
|
||||||
loading = false;
|
|
||||||
if (posts.length === 0) {
|
|
||||||
error = `Keine Posts mit Tag "${tagName}" gefunden.`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
loading = false;
|
|
||||||
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
document.title = `#${tagName} – Jörg Lohrer`;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
|
|
||||||
|
|
||||||
<h1 class="tag-title">#{tagName}</h1>
|
|
||||||
|
|
||||||
<LoadingOrError {loading} {error} />
|
|
||||||
|
|
||||||
{#each posts as post (post.id)}
|
|
||||||
<PostCard event={post} />
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.breadcrumb {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.breadcrumb a {
|
|
||||||
color: var(--accent);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.breadcrumb a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
.tag-title {
|
|
||||||
margin: 0 0 1.5rem;
|
|
||||||
font-size: 1.6rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load: PageLoad = async ({ params }) => {
|
|
||||||
return { tagName: decodeURIComponent(params.name) };
|
|
||||||
};
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
RewriteEngine On
|
|
||||||
|
|
||||||
# HTTPS forcieren
|
|
||||||
RewriteCond %{HTTPS} !=on
|
|
||||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
|
||||||
|
|
||||||
# NIP-05-Verifikation: CORS-Header für .well-known/nostr.json, sonst
|
|
||||||
# lehnen nostr-clients die verifizierung ab.
|
|
||||||
<FilesMatch "nostr\.json$">
|
|
||||||
Header set Access-Control-Allow-Origin "*"
|
|
||||||
Header set Content-Type "application/json"
|
|
||||||
</FilesMatch>
|
|
||||||
|
|
||||||
# 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]
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"names": {
|
|
||||||
"joerglohrer": "4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41"
|
|
||||||
},
|
|
||||||
"relays": {
|
|
||||||
"4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41": [
|
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://nos.lol",
|
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.tchncs.de",
|
|
||||||
"wss://relay.edufeed.org"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import adapter from '@sveltejs/adapter-static';
|
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
|
||||||
const config = {
|
|
||||||
preprocess: vitePreprocess(),
|
|
||||||
compilerOptions: {
|
|
||||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
|
||||||
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
|
||||||
},
|
|
||||||
kit: {
|
|
||||||
adapter: adapter({
|
|
||||||
pages: 'build',
|
|
||||||
assets: 'build',
|
|
||||||
fallback: 'index.html',
|
|
||||||
precompress: false,
|
|
||||||
strict: false
|
|
||||||
}),
|
|
||||||
alias: {
|
|
||||||
$lib: 'src/lib'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
test('Home zeigt Hero (Name + Avatar) und neueste Beiträge', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
// Hero: Name als h1
|
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
|
||||||
// Hero: Avatar (lokaler fallback oder nostr-profil)
|
|
||||||
await expect(page.locator('.hero .avatar')).toBeVisible({ timeout: 15_000 });
|
|
||||||
// Neueste-Beiträge-Sektion
|
|
||||||
await expect(page.getByRole('heading', { level: 2, name: /Neueste Beiträge/i })).toBeVisible();
|
|
||||||
// Mindestens ein Post lädt
|
|
||||||
await expect(page.locator('a.card').first()).toBeVisible({ timeout: 15_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Navigation erreicht Archiv und Impressum', async ({ page }) => {
|
|
||||||
await page.goto('/');
|
|
||||||
await page.getByRole('link', { name: 'Archiv', exact: true }).click();
|
|
||||||
await expect(page.getByRole('heading', { level: 1, name: /Archiv/i })).toBeVisible();
|
|
||||||
|
|
||||||
await page.getByRole('link', { name: 'Impressum', exact: true }).first().click();
|
|
||||||
await expect(page.getByRole('heading', { level: 1, name: /Impressum/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gibt null zurück bei malformed percent-encoding (crash-sicher)', () => {
|
|
||||||
expect(parseLegacyUrl('/2024/01/26/%E0.html/')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gibt null zurück für leeren dtag', () => {
|
|
||||||
expect(parseLegacyUrl('/2024/01/26/.html/')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('canonicalPostPath', () => {
|
|
||||||
it('erzeugt /<dtag>/ mit encodeURIComponent', () => {
|
|
||||||
expect(canonicalPostPath('dezentrale-oep-oer')).toBe('/dezentrale-oep-oer/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('kodiert Sonderzeichen', () => {
|
|
||||||
expect(canonicalPostPath('mit leerzeichen')).toBe('/mit%20leerzeichen/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('round-trip parseLegacyUrl → canonicalPostPath', () => {
|
|
||||||
it('Legacy-URL wird zur kanonischen kurzen Form', () => {
|
|
||||||
const dtag = parseLegacyUrl('/2025/03/04/dezentrale-oep-oer.html/');
|
|
||||||
expect(dtag).not.toBeNull();
|
|
||||||
expect(canonicalPostPath(dtag!)).toBe('/dezentrale-oep-oer/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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('<strong>bold</strong>');
|
|
||||||
expect(html).toContain('<em>italic</em>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('entfernt <script>-Tags (DOMPurify)', () => {
|
|
||||||
const html = renderMarkdown('hello <script>alert("x")</script> world');
|
|
||||||
expect(html).not.toContain('<script>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('entfernt javascript:-URLs', () => {
|
|
||||||
const html = renderMarkdown('[click](javascript:alert(1))');
|
|
||||||
expect(html).not.toMatch(/javascript:/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rendert Links mit http:// und erhält das href', () => {
|
|
||||||
const html = renderMarkdown('[nostr](https://nostr.com)');
|
|
||||||
expect(html).toContain('href="https://nostr.com"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rendert horizontale Linie aus ---', () => {
|
|
||||||
const html = renderMarkdown('oben\n\n---\n\nunten');
|
|
||||||
expect(html).toContain('<hr>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rendert fenced code blocks mit hljs-klasse', () => {
|
|
||||||
const html = renderMarkdown('```js\nconst x = 1;\n```');
|
|
||||||
expect(html).toContain('<pre>');
|
|
||||||
expect(html).toContain('<code');
|
|
||||||
expect(html).toContain('class="hljs');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rendert GFM tables', () => {
|
|
||||||
const md = '| a | b |\n|---|---|\n| 1 | 2 |';
|
|
||||||
const html = renderMarkdown(md);
|
|
||||||
expect(html).toContain('<table');
|
|
||||||
expect(html).toContain('<td>1</td>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rendert Bilder', () => {
|
|
||||||
const html = renderMarkdown('');
|
|
||||||
expect(html).toContain('<img');
|
|
||||||
expect(html).toContain('src="https://example.com/img.png"');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Erweiterte XSS-Matrix — relevant ab Reply-Komponenten (3rd-party Content).
|
|
||||||
it('entfernt onerror-Attribute auf inline-HTML-img', () => {
|
|
||||||
const html = renderMarkdown('<img src="x" onerror="alert(1)">');
|
|
||||||
expect(html.toLowerCase()).not.toContain('onerror');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('entfernt onclick-Attribute auf inline-HTML', () => {
|
|
||||||
const html = renderMarkdown('<a href="#" onclick="alert(1)">x</a>');
|
|
||||||
expect(html.toLowerCase()).not.toContain('onclick');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('entfernt iframe-Tags', () => {
|
|
||||||
const html = renderMarkdown('<iframe src="https://evil.com"></iframe>');
|
|
||||||
expect(html.toLowerCase()).not.toContain('<iframe');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('entfernt data:text/html-URLs in Links', () => {
|
|
||||||
const html = renderMarkdown('[x](data:text/html,<script>alert(1)</script>)');
|
|
||||||
expect(html.toLowerCase()).not.toMatch(/href="data:text\/html/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('entfernt vbscript:-URLs', () => {
|
|
||||||
const html = renderMarkdown('<a href="vbscript:msgbox(1)">x</a>');
|
|
||||||
expect(html.toLowerCase()).not.toContain('vbscript:');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('entfernt script-Tag innerhalb svg', () => {
|
|
||||||
const html = renderMarkdown('<svg><script>alert(1)</script></svg>');
|
|
||||||
expect(html.toLowerCase()).not.toContain('<script');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
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));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('funktioniert ohne relays (optional)', () => {
|
|
||||||
const link = buildHablaLink({
|
|
||||||
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
|
|
||||||
kind: 30023,
|
|
||||||
identifier: 'foo',
|
|
||||||
});
|
|
||||||
expect(link).toMatch(/^https:\/\/habla\.news\/a\/naddr1[a-z0-9]+$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('erzeugt unterschiedliche Links für unterschiedliche Inputs', () => {
|
|
||||||
const base = {
|
|
||||||
pubkey: '4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41',
|
|
||||||
kind: 30023,
|
|
||||||
relays: [],
|
|
||||||
};
|
|
||||||
const a = buildHablaLink({ ...base, identifier: 'foo' });
|
|
||||||
const b = buildHablaLink({ ...base, identifier: 'bar' });
|
|
||||||
expect(a).not.toBe(b);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "./.svelte-kit/tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rewriteRelativeImportExtensions": true,
|
|
||||||
"allowJs": true,
|
|
||||||
"checkJs": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"strict": true,
|
|
||||||
"moduleResolution": "bundler"
|
|
||||||
}
|
|
||||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
|
||||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
|
||||||
//
|
|
||||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
|
||||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
|
||||||
import { defineConfig } from 'vitest/config';
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [sveltekit()],
|
|
||||||
test: {
|
|
||||||
include: ['tests/unit/**/*.{test,spec}.{js,ts}', 'src/**/*.{test,spec}.{js,ts}'],
|
|
||||||
environment: 'jsdom',
|
|
||||||
globals: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
+++
|
||||||
|
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||||
|
date = {{ .Date }}
|
||||||
|
draft = true
|
||||||
|
+++
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brand-bluesky"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M6.335 5.144c-1.654 -1.199 -4.335 -2.127 -4.335 .826c0 .59 .35 4.953 .556 5.661c.713 2.463 3.13 2.75 5.444 2.369c-4.045 .665 -4.889 3.208 -2.667 5.41c1.03 1.018 1.913 1.59 2.667 1.59c2 0 3.134 -2.769 3.5 -3.5c.333 -.667 .5 -1.167 .5 -1.5c0 .333 .167 .833 .5 1.5c.366 .731 1.5 3.5 3.5 3.5c.754 0 1.637 -.571 2.667 -1.59c2.222 -2.203 1.378 -4.746 -2.667 -5.41c2.314 .38 4.73 .094 5.444 -2.369c.206 -.708 .556 -5.072 .556 -5.661c0 -2.953 -2.68 -2.025 -4.335 -.826c-2.293 1.662 -4.76 5.048 -5.665 6.856c-.905 -1.808 -3.372 -5.194 -5.665 -6.856z" /></svg>
|
||||||
|
After Width: | Height: | Size: 847 B |
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brand-mastodon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M18.648 15.254c-1.816 1.763 -6.648 1.626 -6.648 1.626a18.262 18.262 0 0 1 -3.288 -.256c1.127 1.985 4.12 2.81 8.982 2.475c-1.945 2.013 -13.598 5.257 -13.668 -7.636l-.026 -1.154c0 -3.036 .023 -4.115 1.352 -5.633c1.671 -1.91 6.648 -1.666 6.648 -1.666s4.977 -.243 6.648 1.667c1.329 1.518 1.352 2.597 1.352 5.633s-.456 4.074 -1.352 4.944z" /><path d="M12 11.204v-2.926c0 -1.258 -.895 -2.278 -2 -2.278s-2 1.02 -2 2.278v4.722m4 -4.722c0 -1.258 .895 -2.278 2 -2.278s2 1.02 2 2.278v4.722" /></svg>
|
||||||
|
After Width: | Height: | Size: 787 B |
|
|
@ -0,0 +1,7 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
class="nostro"
|
||||||
|
d="m 21.219688,10.632199 v 9.782376 c 0,0.36788 -0.298537,0.666422 -0.666421,0.666422 h -7.997031 c -0.367884,0 -0.666422,-0.298542 -0.666422,-0.666422 v -1.82178 c 0.03645,-2.233152 0.272682,-4.372276 0.768675,-5.34546 0.297363,-0.58532 0.78748,-0.903839 1.350469,-1.074263 1.063686,-0.319694 2.93013,-0.10108 3.722312,-0.138691 0,0 2.392997,0.09521 2.392997,-1.259967 0,-1.0907187 -1.069562,-1.0049187 -1.069562,-1.0049187 -1.178867,0.03055 -2.076831,-0.04937 -2.658626,-0.278557 C 15.421721,9.1077763 15.388811,8.4049219 15.385285,8.1698534 15.337094,5.4548117 11.333878,5.1292411 7.8066742,5.8027121 3.9503743,6.536127 7.8489842,12.063765 7.8489842,19.44257 v 0.984936 c -0.00706,0.362004 -0.299712,0.654662 -0.6640682,0.654662 h -3.95973 c -0.3678829,0 -0.6664195,-0.298534 -0.6664195,-0.666415 V 3.4966903 c 0,-0.3678822 0.2985366,-0.6664194 0.6664195,-0.6664194 h 3.7223109 c 0.3678822,0 0.6664189,0.2985372 0.6664189,0.6664194 0,0.546534 0.6147044,0.8509478 1.0589839,0.5324305 1.3387143,-0.9590801 3.0570663,-1.4703543 4.9799263,-1.4703543 4.307633,0 7.564508,2.5105327 7.564508,8.0734325 z M 14.068901,8.6470424 c 0,-0.7874805 -0.638211,-1.4256909 -1.425691,-1.4256909 -0.787479,0 -1.425692,0.6382104 -1.425692,1.4256909 0,0.7874809 0.638213,1.4256916 1.425692,1.4256916 0.78748,0 1.425691,-0.6382107 1.425691,-1.4256916 z"
|
||||||
|
id="nostr"
|
||||||
|
style="fill:currentColor;fill-opacity:1;stroke-width:0.5" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
|
|
@ -17,7 +17,7 @@ Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Rich
|
||||||
Mein Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte ich keinen Einfluss habe. Deshalb kann ich für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
|
Mein Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte ich keinen Einfluss habe. Deshalb kann ich für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
|
||||||
|
|
||||||
### Urheberrecht
|
### Urheberrecht
|
||||||
Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Soweit nicht anders angegeben, stelle ich eigene Inhalte und Werke unter der Creative-Commons-Lizenz [CC0 1.0 Universal (Public Domain Dedication)](https://creativecommons.org/publicdomain/zero/1.0/deed.de) zur Verfügung — sie dürfen ohne Rückfrage für jeden Zweck, auch kommerziell, kopiert, bearbeitet, verbreitet und weiterverwendet werden. Eine Namensnennung ist rechtlich nicht erforderlich, aber ich freue mich natürlich, wenn Du mich als Quelle nennst. Wo eine abweichende Lizenz gilt, ist sie beim jeweiligen Inhalt vermerkt. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Solltest Du trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitte ich um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Inhalte umgehend entfernen.
|
Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen jedoch nicht der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind sowohl für den privaten, als auch für den kommerziellen Gebrauch unter Namensnennung und der Creative Commons Lizenz [CC BY-SA](https://creativecommons.org/licenses/by-sa/4.0/deed.de) gestattet. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Solltest Du trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten ich um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Inhalte umgehend entfernen.
|
||||||
|
|
||||||
### Datenschutz
|
### Datenschutz
|
||||||
Die Nutzung meiner Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf meiner Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder eMail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Deine ausdrückliche Zustimmung nicht an Dritte weitergegeben. Ich weise darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich. Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten durch Dritte zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit ausdrücklich widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-Mails, vor.
|
Die Nutzung meiner Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf meiner Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder eMail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Deine ausdrückliche Zustimmung nicht an Dritte weitergegeben. Ich weise darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich. Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten durch Dritte zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit ausdrücklich widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-Mails, vor.
|
||||||
|
|
@ -11,16 +11,6 @@ author: Jörg Lohrer
|
||||||
slug: "premium-freemium-mium-mium-mium"
|
slug: "premium-freemium-mium-mium-mium"
|
||||||
lang: de
|
lang: de
|
||||||
dir: ltr
|
dir: ltr
|
||||||
images:
|
|
||||||
- file: my-very-hungry-caterpillar.jpg
|
|
||||||
role: cover
|
|
||||||
alt: "Kleine Raupe aus Papier gefaltet, Anspielung auf das Kinderbuch 'Die kleine Raupe Nimmersatt'"
|
|
||||||
license: "https://creativecommons.org/licenses/by-nc-sa/3.0/"
|
|
||||||
authors:
|
|
||||||
- name: "Relly Annett-Baker"
|
|
||||||
source_url: "https://www.flickr.com/photos/fizzkitten/4454153264/"
|
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
|
@ -8,13 +8,13 @@ date: "2013-05-29"
|
||||||
slug: "erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive"
|
slug: "erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive"
|
||||||
lang: de
|
lang: de
|
||||||
dir: ltr
|
dir: ltr
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Erlebnispädagogik im Handbuch Jugend – Evangelische Perspektiven
|
# Erlebnispädagogik im Handbuch Jugend – Evangelische Perspektiven
|
||||||
|
|
||||||
Das [**Handbuch Jugend – Evangelische Perspektiven**](http://ci-muenster.de/bookshop/artikel/buecher/Bildung-im-Kindes-und-Jugendalter/A40097_Handbuch_Jugend_2013.php), welches 2013 erschienen ist, hat auf Seite 343-345 folgenden Artikel von mir zur Erlebnispädagogik abgedruckt.
|
Das
|
||||||
|
[](http://ci-muenster.de/bookshop/artikel/buecher/Bildung-im-Kindes-und-Jugendalter/A40097_Handbuch_Jugend_2013.php)
|
||||||
|
**Handbuch Jugend – Evangelische Perspektiven**, welches 2013 erschienen ist, hat auf Seite 343-345 folgenden Artikel von mir zur Erlebnispädagogik abgedruckt.
|
||||||
[CC BY](http://creativecommons.org/licenses/by/2.0/de/) Jörg Lohrer
|
[CC BY](http://creativecommons.org/licenses/by/2.0/de/) Jörg Lohrer
|
||||||
|
|
||||||
## Erlebnispädagogik
|
## Erlebnispädagogik
|
||||||
|
|
@ -44,10 +44,14 @@ Die Wirkungsforschung erlebnispädagogischer Lernarrangements untersucht deren S
|
||||||
Im Kontext evangelischer Jugendbildung stellt sich bei der Verwendung erlebnispädagogischer Methoden immer auch die Frage nach einer Verknüpfung von christlichen Inhalten mit der handlungsorientierten Pädagogik. Die Assoziation von Natur und Schöpfung ist beispielsweise ebenso naheliegend wie die Reflexion von religiösen Erfahrungen in erlebnispädagogischen Lernsettings. Inwiefern sich dabei Unverfügbares inszenieren lässt, bleibt dabei ebenso eine Herausforderung, wie bei der oben beschriebenen Wirkungsforschung. In den vergangenen Jahren haben sich einige Konzepte zur Erprobung erlebnispädagogischer Methoden im christlichen Kontext entwickelt, die zunehmend auch theologisch und religionsdidaktisch reflektiert werden. Eine überregionale Organisation dieser Einzelinitiativen auf Bundesebene wäre der nächste Schritt hin zu einem evangelischen Profil erlebnispädagogischer Jugendbildungsarbeit.
|
Im Kontext evangelischer Jugendbildung stellt sich bei der Verwendung erlebnispädagogischer Methoden immer auch die Frage nach einer Verknüpfung von christlichen Inhalten mit der handlungsorientierten Pädagogik. Die Assoziation von Natur und Schöpfung ist beispielsweise ebenso naheliegend wie die Reflexion von religiösen Erfahrungen in erlebnispädagogischen Lernsettings. Inwiefern sich dabei Unverfügbares inszenieren lässt, bleibt dabei ebenso eine Herausforderung, wie bei der oben beschriebenen Wirkungsforschung. In den vergangenen Jahren haben sich einige Konzepte zur Erprobung erlebnispädagogischer Methoden im christlichen Kontext entwickelt, die zunehmend auch theologisch und religionsdidaktisch reflektiert werden. Eine überregionale Organisation dieser Einzelinitiativen auf Bundesebene wäre der nächste Schritt hin zu einem evangelischen Profil erlebnispädagogischer Jugendbildungsarbeit.
|
||||||
|
|
||||||
### Literatur
|
### Literatur
|
||||||
- Großer, Achim/Oberländer, Rainer/Lohrer, Jörg/Wiedmayer, Jörg (2005/2011): Sinn gesucht – Gott erfahren. Erlebnispädagogik im christlichen Kontext (Band 1 (2005)/Band 2 (2011)). Stuttgart: Buch und Musik.
|

|
||||||
- Heckmair, Bernd/Michl, Werner (2008): Erleben und Lernen. Einführung in die Erlebnispädagogik. München: Reinhardt.
|
Großer, Achim/Oberländer, Rainer/Lohrer, Jörg/Wiedmayer, Jörg (2005/2011): Sinn gesucht – Gott erfahren. Erlebnispädagogik im christlichen Kontext (Band 1 (2005)/Band 2 (2011)). Stuttgart: Buch und Musik.
|
||||||
- Reiners, Annette (2007): Praktische Erlebnispädagogik. Augsburg: ZIEL.
|

|
||||||
- Pum, Viktoria/Pirner, Manfred L./Lohrer, Jörg (Hrsg.) (2011): Erlebnispädagogik im christlichen Kontext. Dokumentation einer Tagung der Evangelischen Akademie Bad Boll, 2. bis 4. März 2009. Bad Boll: Evangelische Akademie.
|
Heckmair, Bernd/Michl, Werner (2008): Erleben und Lernen. Einführung in die Erlebnispädagogik. München: Reinhardt.
|
||||||
|

|
||||||
|
Reiners, Annette (2007): Praktische Erlebnispädagogik. Augsburg: ZIEL.
|
||||||
|

|
||||||
|
Pum, Viktoria/Pirner, Manfred L./Lohrer, Jörg (Hrsg.) (2011): Erlebnispädagogik im christlichen Kontext. Dokumentation einer Tagung der Evangelischen Akademie Bad Boll, 2. bis 4. März 2009. Bad Boll: Evangelische Akademie.
|
||||||
|
|
||||||
#### Links
|
#### Links
|
||||||
- Bundesverband Individual- und Erlebnispädagogik e.V. (BE): [https://www.bundesverband-erlebnispaedagogik.de/](https://www.bundesverband-erlebnispaedagogik.de/)
|
- Bundesverband Individual- und Erlebnispädagogik e.V. (BE): [https://www.bundesverband-erlebnispaedagogik.de/](https://www.bundesverband-erlebnispaedagogik.de/)
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
layout: post
|
||||||
|
title: "Telegram Bot für Octopi"
|
||||||
|
description: "Schnittstelle zwischen Telegram und OctoPrint"
|
||||||
|
image: octopi1.png
|
||||||
|
cover:
|
||||||
|
image: octopi1.png
|
||||||
|
tags: [ "Telegram", "Octopi", "Raspberry", "3DDruck" ]
|
||||||
|
date: "2017-10-23"
|
||||||
|
author: Jörg Lohrer
|
||||||
|
slug: "telegram-octopi"
|
||||||
|
lang: de
|
||||||
|
dir: ltr
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Das [OctoPrint-Telegram-Plugin](http://plugins.octoprint.org/plugins/telegram/) schafft eine Schnittstelle zwischen Telegram und OctoPrint.
|
||||||
|
Hier die Anleitung auf Englisch: [https://github.com/fabianonline/OctoPrint-Telegram/blob/stable/README.md](https://github.com/fabianonline/OctoPrint-Telegram/blob/stable/README.md)
|
||||||
|
|
||||||
|
Das dauert eine Weile:
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
Token eingeben:
|
||||||
|

|
||||||
|
|
||||||
|
Heisst aber nicht, dass jetzt alles gleich klappt:
|
||||||
|

|
||||||
|
|
||||||
|
Es müssen dem Benutzer noch die Rechte “Command” und “Notify” gegeben werden:
|
||||||
|

|
||||||
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 158 KiB |
|
|
@ -0,0 +1,46 @@
|
||||||
|
---
|
||||||
|
layout: post
|
||||||
|
title: "Lutherkürbis - Reformation an Halloween"
|
||||||
|
description: "Schablone und Bastelanleitung für einen Kürbis zur Reformation"
|
||||||
|
image: kuerbis-titelbild.jpg
|
||||||
|
cover:
|
||||||
|
image: kuerbis-titelbild.jpg
|
||||||
|
tags: [ "Lutherrose", "Reformation", "Halloween", "Luther" ]
|
||||||
|
date: "2017-10-31"
|
||||||
|
author: Jörg Lohrer
|
||||||
|
slug: "lutherkuerbis"
|
||||||
|
lang: de
|
||||||
|
dir: ltr
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Lutherkürbis - Reformation an Halloween
|
||||||
|
|
||||||
|
Das Symbol der Lutherrose ist auch heute noch in vielen Wappen enthalten und wird als Dekoelement genutzt ([Quelle: epd/imago](https://www.t-online.de/leben/familie/id_65982142/lutherrose-entstehung-und-bedeutung.html))
|
||||||
|
|
||||||
|
Aus einer [Fotovorlage der Lutherrose](https://duckduckgo.com/?q=lutherrose&t=h_&iax=images&ia=images) wird mit einem [Online-Konverter eine Schablone als verlustfrei skalierbare Vektorgrafik](https://image.online-convert.com/convert-to-svg) erzeugt:
|
||||||
|
|
||||||
|
[](https://material.rpi-virtuell.de/wp-content/uploads/2018/10/Lutherrose.pdf)
|
||||||
|
[Lutherrose PDF-Vorlage](https://material.rpi-virtuell.de/wp-content/uploads/2018/10/Lutherrose.pdf)
|
||||||
|
|
||||||
|
## Bastel-Anleitung
|
||||||
|
Einen Kürbis aufschneiden:
|
||||||
|

|
||||||
|
|
||||||
|
entkernen und aushöhlen:
|
||||||
|

|
||||||
|
|
||||||
|
Schablone aufbringen:
|
||||||
|

|
||||||
|
|
||||||
|
Ausschneiden:
|
||||||
|

|
||||||
|
|
||||||
|
Mit Kerze oder elektrischem Licht ausstatten:
|
||||||
|

|
||||||
|
|
||||||
|
Fertig!
|
||||||
|
|
||||||
|
Diese Idee inklusive der Schablone steht unter [CC0-Lizenz](https://creativecommons.org/publicdomain/zero/1.0/deed.de). Du darfst das Werk kopieren, verändern, verbreiten und aufführen, sogar zu kommerziellen Zwecken, ohne um weitere Erlaubnis bitten zu müssen.
|
||||||
|
#### Weitere Quellen
|
||||||
|
* How to Make a Paper Cut-Out Luther Rose [YouTube](https://www.youtube.com/watch?v=b5FCaNZPU98) | [PDF](http://www.kellyklages.com/lutherrose.pdf)
|
||||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 3.8 MiB |
|
|
@ -7,25 +7,10 @@ cover:
|
||||||
image: cura-plugin-change-filment-at-z.png
|
image: cura-plugin-change-filment-at-z.png
|
||||||
tags: [ "QR-Code", "3DDruck" ]
|
tags: [ "QR-Code", "3DDruck" ]
|
||||||
date: "2019-03-26"
|
date: "2019-03-26"
|
||||||
slug: "pflanzenschild-qr-code"
|
slug: "Pflanzenschild-QR-Code"
|
||||||
author: Jörg Lohrer
|
author: Jörg Lohrer
|
||||||
lang: de
|
lang: de
|
||||||
dir: ltr
|
dir: ltr
|
||||||
images:
|
|
||||||
- file: cura-plugin-change-filment-at-z.png
|
|
||||||
role: cover
|
|
||||||
alt: "Screenshot des Cura-Slicers mit aktiviertem 'Change Filament at Z'-Plugin — Konfiguration eines Filamentwechsels in bestimmten Layern"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: qr-code-pflanzenschild.jpg
|
|
||||||
alt: "Dreieckiges 3D-gedrucktes Pflanzenschild mit aufgedrucktem zweifarbigem QR-Code, steckt in einem Pflanztopf"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 446 KiB After Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |