Compare commits

...

No commits in common. "hugo-archive" and "main" have entirely different histories.

653 changed files with 21780 additions and 18228 deletions

View File

@ -6,7 +6,8 @@
"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:*)"
] ]
} }
} }

View File

@ -0,0 +1,185 @@
---
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 12 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: 23 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

57
.github/workflows/publish.yml vendored Normal file
View File

@ -0,0 +1,57 @@
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

3
.gitignore vendored
View File

@ -1 +1,4 @@
**/.DS_Store **/.DS_Store
.env
.env.local
logs/

3
.gitmodules vendored
View File

@ -1,3 +0,0 @@
[submodule "themes/tailwind"]
path = themes/tailwind
url = https://github.com/tomowang/hugo-theme-tailwind.git

View File

105
CLAUDE.md Normal file
View File

@ -0,0 +1,105 @@
# 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: 23 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 3090 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
View File

@ -1,3 +1,112 @@
# joerglohrerde # joerg-lohrer.de
update Persönliche Webseite. Nach einer Transition von einer Hugo-basierten,
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).

9
app/.env.example Normal file
View File

@ -0,0 +1,9 @@
# Ö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

28
app/.gitignore vendored Normal file
View File

@ -0,0 +1,28 @@
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
app/.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

3
app/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

42
app/README.md Normal file
View File

@ -0,0 +1,42 @@
# 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.

43
app/package.json Normal file
View File

@ -0,0 +1,43 @@
{
"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"
}
}

13
app/playwright.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: 'tests/e2e',
use: { baseURL: 'http://localhost:5173' },
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
timeout: 120_000
},
timeout: 60_000
});

13
app/src/app.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
// 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 {};

66
app/src/app.html Normal file
View File

@ -0,0 +1,66 @@
<!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>

View File

@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,47 @@
<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>

View File

@ -0,0 +1,56 @@
<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>

View File

@ -0,0 +1,107 @@
<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>

View File

@ -0,0 +1,48 @@
<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>

View File

@ -0,0 +1,40 @@
<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>

View File

@ -0,0 +1,94 @@
<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>

View File

@ -0,0 +1,174 @@
<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>

View File

@ -0,0 +1,82 @@
<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>

View File

@ -0,0 +1,58 @@
<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>

View File

@ -0,0 +1,148 @@
<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>

View File

@ -0,0 +1,100 @@
<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>

View File

@ -0,0 +1,68 @@
<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>

View File

@ -0,0 +1,118 @@
<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>

View File

@ -0,0 +1,65 @@
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');
});
});

View File

@ -0,0 +1,61 @@
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();

23
app/src/lib/i18n/index.ts Normal file
View File

@ -0,0 +1,23 @@
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 };

View File

@ -0,0 +1,32 @@
{
"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"
}
}

View File

@ -0,0 +1,32 @@
{
"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
app/src/lib/index.ts Normal file
View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@ -0,0 +1,37 @@
/**
* 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;

View File

@ -0,0 +1,74 @@
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([]);
});
});

View File

@ -0,0 +1,244 @@
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
})
);
}

View File

@ -0,0 +1,66 @@
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}` }
];
}

View File

@ -0,0 +1,7 @@
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();

View File

@ -0,0 +1,17 @@
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;
}

View File

@ -0,0 +1,95 @@
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;
}
}

View File

@ -0,0 +1,50 @@
/**
* 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;
}
}

View File

@ -0,0 +1,51 @@
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([]);
});
});

View File

@ -0,0 +1,27 @@
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;
}

View File

@ -0,0 +1,53 @@
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);
}

View File

@ -0,0 +1,29 @@
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;

24
app/src/lib/url/legacy.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* 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)}/`;
}

View File

@ -0,0 +1,168 @@
<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>

View File

@ -0,0 +1,3 @@
export const prerender = false;
export const ssr = false;
export const trailingSlash = 'always';

182
app/src/routes/+page.svelte Normal file
View File

@ -0,0 +1,182 @@
<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>

View File

@ -0,0 +1,70 @@
<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>

View File

@ -0,0 +1,21 @@
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]) };
};

View File

@ -0,0 +1,92 @@
<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>

View File

@ -0,0 +1,41 @@
<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>

View File

@ -0,0 +1,59 @@
<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>

View File

@ -0,0 +1,5 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params }) => {
return { tagName: decodeURIComponent(params.name) };
};

20
app/static/.htaccess Normal file
View File

@ -0,0 +1,20 @@
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]

View File

@ -0,0 +1,14 @@
{
"names": {
"joerglohrer": "4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41"
},
"relays": {
"4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41": [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://relay.tchncs.de",
"wss://relay.edufeed.org"
]
}
}

View File

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

View File

Before

Width:  |  Height:  |  Size: 231 KiB

After

Width:  |  Height:  |  Size: 231 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

Before

Width:  |  Height:  |  Size: 844 B

After

Width:  |  Height:  |  Size: 844 B

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

2
app/static/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-agent: *
Allow: /

25
app/svelte.config.js Normal file
View File

@ -0,0 +1,25 @@
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;

View File

@ -0,0 +1,22 @@
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();
});

View File

@ -0,0 +1,16 @@
import { expect, test } from '@playwright/test';
test('Einzelpost rendert Titel und Markdown-Body', async ({ page }) => {
await page.goto('/dezentrale-oep-oer/');
// Titel steht einmal als .post-title (H1 außerhalb des Artikels),
// und nochmal im Markdown-Body des Events — wir prüfen den ersten.
await expect(page.locator('h1.post-title')).toBeVisible({ timeout: 15_000 });
await expect(page.locator('h1.post-title')).toContainText('Gemeinsam die Bildungszukunft');
await expect(page.locator('article')).toContainText('Open Educational');
});
test('Legacy-URL wird auf kurze Form umgeleitet', async ({ page }) => {
await page.goto('/2025/03/04/dezentrale-oep-oer.html/');
await expect(page).toHaveURL(/\/dezentrale-oep-oer\/$/);
await expect(page.locator('h1.post-title')).toBeVisible({ timeout: 15_000 });
});

View File

@ -0,0 +1,61 @@
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/');
});
});

View File

@ -0,0 +1,81 @@
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('![alt](https://example.com/img.png)');
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');
});
});

View File

@ -0,0 +1,44 @@
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);
});
});

20
app/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"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
}

11
app/vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
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
}
});

View File

@ -1,5 +0,0 @@
+++
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
date = {{ .Date }}
draft = true
+++

BIN
assets/.DS_Store vendored

Binary file not shown.

BIN
assets/icons/.DS_Store vendored

Binary file not shown.

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 847 B

View File

@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 787 B

View File

@ -1,7 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -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. 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. 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.
### 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.

View File

@ -1,32 +0,0 @@
---
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:
![](octopi1.png)
Token eingeben:
![](octopi2.png)
Heisst aber nicht, dass jetzt alles gleich klappt:
![](octopi3.png)
Es müssen dem Benutzer noch die Rechte “Command” und “Notify” gegeben werden:
![](octopi4.png)

View File

@ -1,46 +0,0 @@
---
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:
[![](lutherrose.png)](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:
![](kuerbis-aufschneiden.jpg)
entkernen und aushöhlen:
![](kuerbis-entkernen.jpg)
Schablone aufbringen:
![](schablone-aufbringen.jpg)
Ausschneiden:
![](kuerbis-ausschneiden.jpg)
Mit Kerze oder elektrischem Licht ausstatten:
![](kuerbis-titelbild.jpg)
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)

View File

@ -1,59 +0,0 @@
---
layout: post
title: "Jojos Schoko-Zimt-Schnecken"
description: "Rezept und Backanleitung"
author: Jörg Lohrer
image: schneckennudeln-titel.jpg
cover:
image: schneckennudeln-titel.jpg
tags: [ "Schneckennudel", "Hefeteig", "Schoko", "Zimt" ]
date: "2023-02-26"
slug: "jojos-schoko-zimt-schnecken"
lang: de
dir: ltr
---
# Schoko-Schnecken
## Hefeteig
**200g Milch** handwarm
**½ Pk Vanillezucker
60g Zucker
22g Hefe**
verrühren
**1 Ei Größe L** dazu
in der Teigknetmaschine
**120g Weizenmehl 405
380g Dinkelmehl 630**
dazu und wenn es ein fester Teig ist
**5g Salz** zugeben
und
**60g Butter** kalt in Streifen schneiden und
5-10 Minuten einketen (-> Fenstertest)
danach Teig 30-60 Minuten ruhen/gehen lassen
## Füllung
in der Zwischenzeit
**10g brauner Zucker
30g Rohrohrzucker
50g weißer Zucker
100g Butter
½ Pk Vanillezucker
2 Teelöffel Zimt
5 Teelöffel Kaba**
verkneten zu einer cremigen Masse
Hefeteig ausrollen und mit der Füllung bestreichen
Nach Belieben noch **Raspel Schokolade** darauf verteilen
![](Hefeteig-mit-Fuellung.jpg)
einrollen, in 16 Stücke schneiden und in Kuchenform setzen:
![](16-Schneckennudeln.jpg)
Mit Frischhaltefolie abdecken und weitere ca. 30 Minuten gehen lassen, dann mit Eimilch abstreichen:
![](hefeschnecken-in-capelle-backform.jpg)
Backofen auf 220°Celsius Ober-/Unterhitze vorheizen.
In den Ofen und dabei auf 180° reduzieren.
Nach 10 Minuten auf 160° reduzien:
![](schneckennudeln-im-ofen.jpg)
weitere 25 Minuten backen oder bis eine Kerntemperatur von 92° erreicht ist. Fertig:
![](schneckennudeln-fertig.jpg)

View File

@ -1,54 +0,0 @@
---
layout: post
title: "Hefefreuden - Dampfnudeln & Minihefezopf"
description: "Rezept und Backanleitung"
author: Jörg Lohrer
image: Hefefreuden.jpg
cover:
image: Hefefreuden.jpg
tags: [ "Dampfnudel", "Hefeteig", "Hefezopf" ]
date: "2023-04-07"
slug: "Dampfnudeln"
lang: de
dir: ltr
---
# Dampfnudeln & Minihefezopf
## Zutaten
- 400 ml Milch
- 120g Zucker
- 1 Pkg Hefe (42g)
- 3 Eier (ca 150g Vollei)
- 1000g Mehl (300g Weizen Type 405 & 700g Dinkel Type 630)
- 10 g Salz
- 120 g Butter
## Rezept
- Milch, Zucker, Hefe handwarm mischen
- Eier dazu und in der Knetmaschine 5 Minuten lang das Mehl unterkneten
- Salz dazu und die in Streifen geschnittene Butter weitere 10 Minuten verkneten
- Mindestens 30 Minuten gehen lassen
![](Hefeteig.jpg)
### Dampfnudeln
- 6 x 135g Stücke vom Teig abstechen, rundschleifen und auf gelochtes Dampfgarblech aufsetzen:
![](Dampfnudeln-auf-Lochblech.jpg)
- weitere 30 Minuten gehen lassen
- dann bei 100°Celsius für 30 Minuten dampfgaren
- fertig
![](Dampfnudeln-im-Dampfgarer.jpg)
- mit Vanillesoße servieren ![](Dampfnudel-mit-Vanillesosse.jpg)
### Hefezopf
- Die restlichen ca 900-1000g Teig in 3 gleiche Teile abwiegen und zu einem Zopf flechten
- Mit Küchenhandtuch abgedeckt mindestens 30 Minuten gehen lassen
- Mit Ei abstreichen und im auf 220° vorgeheizten Backofen direkt bei einschießen auf 180° reduzieren
- Nach 10 Minuten Back-Temperatur auf 160° reduzieren
- Entweder backen bis 93° Kerntemperatur erreicht ist oder nach ca weiteren 30 Minuten
![](Hefezopf.jpg)

View File

@ -11,6 +11,16 @@ 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>"
--- ---

View File

@ -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 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.
[![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3847400746&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)](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,14 +44,10 @@ 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
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3866870493&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21) - 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.
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.
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3497022934&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21) - Reiners, Annette (2007): Praktische Erlebnispädagogik. Augsburg: ZIEL.
Heckmair, Bernd/Michl, Werner (2008): Erleben und Lernen. Einführung in die Erlebnispädagogik. München: Reinhardt. - 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.
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3940562866&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
Reiners, Annette (2007): Praktische Erlebnispädagogik. Augsburg: ZIEL.
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3936369348&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
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/)

View File

@ -0,0 +1,59 @@
---
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
images:
- file: octopi1.png
role: cover
alt: "Screenshot der OctoPrint-Plugin-Verwaltung während der Installation des Telegram-Plugins — Fortschrittsanzeige läuft"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: octopi2.png
alt: "Screenshot der Konfigurationsmaske des OctoPrint-Telegram-Plugins mit Eingabefeld für den Telegram-Bot-Token"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: octopi3.png
alt: "Screenshot der OctoPrint-Telegram-Plugin-Oberfläche nach erfolgreichem Token-Eintrag — Benutzerliste wird angezeigt, Rechte fehlen noch"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: octopi4.png
alt: "Screenshot der Benutzer-Rechte-Konfiguration mit gesetzten Häkchen bei 'Command' und 'Notify'"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
# a:
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
---
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:
![](octopi1.png)
Token eingeben:
![](octopi2.png)
Heisst aber nicht, dass jetzt alles gleich klappt:
![](octopi3.png)
Es müssen dem Benutzer noch die Rechte “Command” und “Notify” gegeben werden:
![](octopi4.png)

View File

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

View File

@ -0,0 +1,87 @@
---
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
images:
- file: kuerbis-titelbild.jpg
role: cover
alt: "Fertig geschnitzter Kürbis mit dem Muster einer Lutherrose, innen beleuchtet — glüht warm in der Dunkelheit"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: lutherrose.png
alt: "Schwarz-weiße Vektorgrafik der Lutherrose: Kreuz im Herzen, umgeben von fünfblättriger Rose in einem Ring — als Schnitzschablone aufbereitet"
caption: "Vektorisierte Schablone, abgeleitet von einer Fotovorlage aus dem Web"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
modifications: "Vektorisierung per online-convert.com aus gemeinfreier Fotovorlage; Originalurheber der Fotovorlage unbekannt"
- file: kuerbis-aufschneiden.jpg
alt: "Hände schneiden mit großem Messer den Deckel von einem orangenen Kürbis ab"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: kuerbis-entkernen.jpg
alt: "Mit einem Löffel wird das Fruchtfleisch und die Kerne aus dem Inneren des aufgeschnittenen Kürbis herausgekratzt"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: schablone-aufbringen.jpg
alt: "Papier-Schablone mit Lutherrosen-Motiv wird auf die Außenhaut des entkernten Kürbis geklebt"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: kuerbis-ausschneiden.jpg
alt: "Mit einem Schnitzwerkzeug wird die Lutherrose entlang der Schablone aus der Kürbishaut herausgeschnitten"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
# a:
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
---
# 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:
[![](lutherrose.png)](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:
![](kuerbis-aufschneiden.jpg)
entkernen und aushöhlen:
![](kuerbis-entkernen.jpg)
Schablone aufbringen:
![](schablone-aufbringen.jpg)
Ausschneiden:
![](kuerbis-ausschneiden.jpg)
Mit Kerze oder elektrischem Licht ausstatten:
![](kuerbis-titelbild.jpg)
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)

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Some files were not shown because too many files have changed in this diff Show More