Compare commits
No commits in common. "main" and "spa" have entirely different histories.
|
|
@ -1,89 +1,80 @@
|
||||||
---
|
---
|
||||||
name: joerglohrerde-workflow
|
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.
|
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) effizient auszuführen.
|
||||||
---
|
---
|
||||||
|
|
||||||
# joerglohrerde — Session-Skill
|
# joerglohrerde — Session-Skill
|
||||||
|
|
||||||
Dieses Repo ist die persönliche Webseite von Jörg Lohrer: eine dezentrale
|
Dieses Repo ist die persönliche Webseite von Jörg Lohrer — in Transition
|
||||||
Nostr-basierte SvelteKit-SPA, die NIP-23-Langform-Events live von Public-
|
von Hugo zu einer dezentralen Nostr-basierten SvelteKit-SPA.
|
||||||
Relays rendert. Seit 2026-04-21 mehrsprachig (DE/EN) via `svelte-i18n` +
|
|
||||||
NIP-33-`a`-Tags.
|
|
||||||
|
|
||||||
## Beim Session-Start IMMER zuerst
|
## Beim Session-Start IMMER zuerst
|
||||||
|
|
||||||
1. **Lies `CLAUDE.md`** — Agent-spezifische Konventionen (Commit-Stil,
|
1. **Lies `docs/STATUS.md`** — aktueller Projektstand, live-URLs, Branches.
|
||||||
Deploy-Falle, Globbing-Hinweise).
|
2. **Lies `docs/HANDOFF.md`** — was wartet, nächste Schritte, Stolperfallen.
|
||||||
2. **Lies `docs/STATUS.md`** — aktueller Projektstand, Live-URLs.
|
3. Bei konkreten Aufgaben: entsprechende Spec unter `docs/superpowers/specs/`
|
||||||
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/`.
|
oder Plan unter `docs/superpowers/plans/`.
|
||||||
5. Branch-Check: `git log --oneline -10 main`.
|
4. Branch-Zustand prüfen: `git log --oneline -10 spa main hugo-archive`.
|
||||||
|
|
||||||
Dann erst Rückfragen oder Vorschläge formulieren.
|
Dann erst Rückfragen oder Vorschläge formulieren.
|
||||||
|
|
||||||
## Live-URLs
|
## Drei Live-Webseiten
|
||||||
|
|
||||||
| URL | Rolle |
|
| URL | Inhalt | Wann anfassen |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `joerg-lohrer.de` | **Produktion**, SvelteKit-SPA (Cutover 2026-04-18, multilingual seit 2026-04-21) |
|
| `joerg-lohrer.de` | Hugo-Seite (alt) | nur im finalen Cutover |
|
||||||
| `staging.joerg-lohrer.de` | Pre-Prod-Build |
|
| `spa.joerg-lohrer.de` | Vanilla-Mini-Spike | als Referenz, aber nicht weiterentwickeln |
|
||||||
| `svelte.joerg-lohrer.de` | Entwicklungs-Deploy-Target (historischer Default) |
|
| `svelte.joerg-lohrer.de` | SvelteKit-SPA | **Haupt-Arbeitsziel** |
|
||||||
| `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
|
## Git-Branches
|
||||||
|
|
||||||
- `main` — kanonisch, alle Arbeit läuft hier direkt.
|
- `main` — kanonisch (Content, Specs, Pläne, Deploy-Scripts)
|
||||||
- `hugo-archive` — Orphan, eingefrorener Hugo-Zustand (Rollback-Option).
|
- `spa` — aktueller Arbeitszweig mit allen SvelteKit-Commits
|
||||||
|
- `hugo-archive` — Orphan, eingefrorener Hugo-Zustand
|
||||||
|
|
||||||
`spa` aus der Pre-Cutover-Phase ist gemerged und historisch.
|
Specs und Pläne gehören auf `main`; SvelteKit-Code auf `spa`. Typischer
|
||||||
|
Workflow: committe Spec-Updates auf `main`, merge `main` → `spa` um
|
||||||
|
sie überall zu haben.
|
||||||
|
|
||||||
## Sprache und Ton
|
## Sprache und Ton
|
||||||
|
|
||||||
- Antworten und Commit-Messages auf **Deutsch** (Kundensprache).
|
- Antworten und Commit-Messages auf **Deutsch** (Kundensprache).
|
||||||
- Code-Identifier (Variablen, Funktionen, Typen) auf Englisch.
|
- Code-Kommentare auch auf Deutsch (wenn überhaupt).
|
||||||
|
- Identifier, Variablen, Funktionen auf **Englisch**.
|
||||||
- Kurz und konkret — Jörg ist technisch versiert, erwartet keine
|
- Kurz und konkret — Jörg ist technisch versiert, erwartet keine
|
||||||
Grundlagen-Erklärungen.
|
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
|
## Kernkonventionen
|
||||||
|
|
||||||
### Content-Struktur
|
### Kanonisches URL-Schema
|
||||||
|
|
||||||
- Markdown-Posts pro Sprache: `content/posts/<lang>/<slug>/index.md`.
|
- Post-URL ist **kurz**: `/<dtag>/` (z. B. `/dezentrale-oep-oer/`).
|
||||||
- Slug ist global eindeutig (also NICHT identisch zwischen Sprach-Varianten).
|
- Legacy-Hugo-URLs `/YYYY/MM/DD/<dtag>.html/` werden per SvelteKit-Load
|
||||||
Der Slug wird zum `d`-Tag des Events und zur URL (`/<slug>/`).
|
auf die kurze Form 301-redirected (Backlink-Kompatibilität).
|
||||||
- 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>/`.
|
- Tag-Route: `/tag/<name>/`.
|
||||||
|
|
||||||
|
### Slug-Regel
|
||||||
|
|
||||||
|
Alle Slugs sind lowercase (Frontmatter `slug:`). Commit `d17410f` hat das
|
||||||
|
normalisiert. Keine Runtime-Transformation, beim Publishen 1:1 übernehmen.
|
||||||
|
|
||||||
### Nostr-Konstanten
|
### Nostr-Konstanten
|
||||||
|
|
||||||
- Pubkey (hex): `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41`
|
- Pubkey (hex): `4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41`
|
||||||
- npub: `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9`
|
- npub: `npub1f7jar3qnu269uyx5p0e4v24hqxjnxysxudvujza2ur5ehltvdeqsly2fx9`
|
||||||
- Bootstrap-Relay: `wss://relay.damus.io`
|
- Bootstrap-Relay: `wss://relay.damus.io`
|
||||||
- Relay-Liste: aus `kind:10002` des Autors (zur Laufzeit geladen).
|
- Vollständige Relay-Liste: aus `kind:10002` des Autors (on-the-fly).
|
||||||
- Blossom-Server: aus `kind:10063` des Autors.
|
- Blossom-Server: aus `kind:10063` des Autors.
|
||||||
- Zentralisiert in `app/src/lib/nostr/config.ts` bzw. `.env.local`.
|
|
||||||
|
Zentralisiert in `app/src/lib/nostr/config.ts`.
|
||||||
|
|
||||||
### Signing
|
### Signing
|
||||||
|
|
||||||
- **Im Browser (Kommentare):** NIP-07 via Extension (Alby, nos2x).
|
- **Im Browser (Kommentare):** NIP-07 via Extension (Alby, nos2x).
|
||||||
- **Aus der Kommandozeile (Publish):** NIP-46 via Amber-Bunker.
|
- **Aus der Kommandozeile (Publish):** NIP-46 via Amber-Bunker. Bunker-URL
|
||||||
- Privater Schlüssel **nie** im Repo, nie in CI-Secrets direkt.
|
in `.env.local` als `BUNKER_URL`.
|
||||||
|
- Privater Schlüssel **nie** im Repo, nie in CI-Secrets, nie in einer
|
||||||
|
Pipeline-Umgebung direkt.
|
||||||
|
|
||||||
## Wiederkehrende Kommandos
|
## Wiederkehrende Kommandos
|
||||||
|
|
||||||
|
|
@ -92,92 +83,96 @@ Produktion. Für Prod-Deploy IMMER `DEPLOY_TARGET=prod` explizit setzen.
|
||||||
```sh
|
```sh
|
||||||
cd app
|
cd app
|
||||||
npm run dev # Dev-Server localhost:5173
|
npm run dev # Dev-Server localhost:5173
|
||||||
npm run check # Type-Check (svelte-check)
|
npm run check # Type-Check (sollte 0 errors sein)
|
||||||
npm run test:unit # Vitest
|
npm run test:unit # Vitest — aktuell 29 Tests
|
||||||
npm run test:e2e # Playwright
|
npm run test:e2e # Playwright — aktuell 3 Tests
|
||||||
npm run build # Prod-Build nach app/build/
|
npm run build # Prod-Build nach app/build/
|
||||||
```
|
```
|
||||||
|
|
||||||
### Publish-Pipeline
|
### Deploy nach `svelte.joerg-lohrer.de`
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cd publish
|
cd app && npm run build && cd ..
|
||||||
deno task check # pre-flight (Bunker, Relays, Blossom)
|
./scripts/deploy-svelte.sh
|
||||||
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
|
Das Script:
|
||||||
|
- liest `SVELTE_FTP_*` aus `.env.local`
|
||||||
|
- uploaded `app/build/*` per FTPS (TLS 1.2-Cap wegen All-Inkl-Bug)
|
||||||
|
- checkt `HTTP/2 200` am Ende
|
||||||
|
|
||||||
```sh
|
### Manuelles Publishen eines Posts (bis Publish-Pipeline fertig ist)
|
||||||
DEPLOY_TARGET=staging ./scripts/deploy-svelte.sh # Pre-Prod
|
|
||||||
DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh # Prod (joerg-lohrer.de)
|
Siehe `docs/HANDOFF.md` Abschnitt „Manuelles Publishen". Kurz:
|
||||||
```
|
- Body aus Markdown-Frontmatter extrahieren (awk-Pattern dort)
|
||||||
|
- Bilder zu Blossom: `nak blossom upload --server https://blossom.edufeed.org --sec "$BUNKER_URL" <bild>`
|
||||||
|
- Event bauen mit `nak event -k 30023 -d <slug> -t title=... ...`
|
||||||
|
- Push zu allen Relays
|
||||||
|
|
||||||
### Nostr-Status checken
|
### Nostr-Status checken
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Alle publizierten kind:30023-Events des Autors (inkl. l-Tag + a-Tags)
|
# Alle publizierten kind:30023-Events des Autors
|
||||||
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])}'
|
nak req -k 30023 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.damus.io 2>/dev/null | jq -c '{d: (.tags[] | select(.[0]=="d") | .[1]), title: (.tags[] | select(.[0]=="title") | .[1])}'
|
||||||
|
|
||||||
|
# kind:10002 (Relay-Liste)
|
||||||
|
nak req -k 10002 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.damus.io
|
||||||
|
|
||||||
|
# kind:10063 (Blossom-Liste)
|
||||||
|
nak req -k 10063 -a 4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41 wss://relay.damus.io
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech-Stack-Eigenheiten, die man kennen muss
|
## Tech-Stack-Eigenheiten, die man kennen muss
|
||||||
|
|
||||||
1. **Svelte 5 Runes:** `$props()`-Werte via `$derived()` in lokale Variablen.
|
1. **Svelte 5 Runes:** `$props()`-Werte müssen via `$derived()` in lokale
|
||||||
`$effect(() => { … event.id })` statt `onMount`, wenn bei Prop-Änderung
|
Variablen abgeleitet werden — sonst `state_referenced_locally`-Warning.
|
||||||
neu geladen werden muss (siehe `[...slug]/+page.svelte`).
|
|
||||||
|
|
||||||
2. **applesauce-relay v5.x API:** RxJS-basiert. `pool.request(relays, filter)`
|
2. **applesauce-relay v5.x API:** RxJS-basiert. `pool.request(relays, filter)`
|
||||||
liefert `Observable<NostrEvent>`. Die Loader in `app/src/lib/nostr/loaders.ts`
|
liefert `Observable<NostrEvent>`. Die Loader in `app/src/lib/nostr/loaders.ts`
|
||||||
nutzen `toArray() + lastValueFrom + timeout + catchError`-Pattern.
|
nutzen `toArray() + lastValueFrom + timeout + catchError`-Pattern.
|
||||||
|
**Nicht** das Tupel-Pattern `msg[0] === 'EVENT'` — das gehört in
|
||||||
|
alte nostr-tools-Beispiele, nicht hierher.
|
||||||
|
|
||||||
3. **DOMPurify braucht DOM:** Early-Fail-Guard für Node-Aufrufe im
|
3. **DOMPurify braucht DOM:** im `renderMarkdown`-Helper gibt es einen
|
||||||
`renderMarkdown`-Helper. SSR ist ohnehin aus (`ssr = false` im Layout).
|
Early-Fail-Guard für Node-Aufrufe (SSR ist ohnehin aus).
|
||||||
|
|
||||||
4. **All-Inkl-FTPS-Bug:** Data-Connection bricht bei TLS 1.3 ab.
|
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
|
`--tls-max 1.2` im curl-Call. Sobald SSH auf All-Inkl verfügbar ist
|
||||||
(Premium-Tarif angefragt), Umstellung auf rsync möglich.
|
(Premium-Tarif angefragt), wird das Deploy-Script auf rsync umgestellt.
|
||||||
|
|
||||||
5. **Amber-Bunker-Session:** bei neuer Bunker-URL müssen globale
|
5. **Amber-Bunker-Session:** bei neuer Bunker-URL müssen globale
|
||||||
Permissions in Amber auf „Allow + Always" für `get_public_key` und
|
Permissions in Amber zurückgesetzt werden. Sonst hängt `nak event`
|
||||||
`sign_event` gesetzt werden.
|
auf die Signatur-Response.
|
||||||
|
|
||||||
6. **Forgejo→GitHub Push-Mirror:** `git push` geht nach Forgejo, die
|
## Was nicht in Scope ist (laut Plan/Specs)
|
||||||
Action läuft auf GitHub (nachdem Forgejo gespiegelt hat). Push → Mirror →
|
|
||||||
Action braucht typisch 1–2 Minuten.
|
|
||||||
|
|
||||||
7. **svelte-i18n + activeLocale:** `$t('key')` in Templates, `get(t)('key')`
|
- Impressum-Inhalt (rechtliche Texte)
|
||||||
in imperativem Script-Code. `activeLocale` ist der projekteigene Store
|
- Meta-Stubs pro Post (kommt via Publish-Pipeline Phase 3)
|
||||||
(persistiert via `localStorage`), `locale` aus svelte-i18n wird
|
- Menü-Navigation (einfach nachrüstbar, aber nicht priorisiert)
|
||||||
automatisch synchronisiert.
|
- Eigener Relay (ideologischer Evolutionspfad, nicht Phase 1)
|
||||||
|
- Eigener Blossom-Server (dito)
|
||||||
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
|
## Wie mit Jörg arbeiten
|
||||||
|
|
||||||
- **Kurze Antworten**, konkrete Optionen, keine Grundlagen-Erklärungen.
|
- **Kurze Antworten**, konkrete Optionen, nicht lang umherreden.
|
||||||
- Bei mehreren Wegen: 2–3 Varianten mit Empfehlung nennen, nicht alles
|
- Bei mehreren Wegen: 2–3 Varianten mit Empfehlung nennen, nicht alles
|
||||||
aufzählen.
|
aufzählen.
|
||||||
- Spec-Updates auf `main` committen, dort läuft alle Arbeit.
|
- Commit-Nachrichten: imperativ, auf Deutsch, mit Kontext im Body.
|
||||||
- Nach Feature-Commits: Build + Deploy, damit Jörg live sehen kann.
|
Co-Author: `Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>`.
|
||||||
UI-Feedback fängt Layout-Fragen ab, die Tests nicht entdecken.
|
- Vor dem Dispatchen von Subagents: kritische API-Details der Libraries
|
||||||
- Vor Subagent-Dispatch: kritische API-Details verifizieren
|
manuell verifizieren (Plan-Annahmen können alte Versionsstände
|
||||||
(Plan-Annahmen können veraltet sein).
|
widerspiegeln). Beispiel: applesauce-relay API war nicht so wie im Plan
|
||||||
|
beschrieben — Subagent mit aktueller API briefen statt blind vertrauen.
|
||||||
|
- Nach jedem Feature-Commit: Build + Deploy, damit Jörg live sehen kann.
|
||||||
|
Das ist in diesem Workflow wichtig, weil UI-Feedback oft Layout-Fragen
|
||||||
|
aufwirft, die kein Test entdeckt.
|
||||||
|
|
||||||
## Credentials / Secrets
|
## Credentials / Secrets
|
||||||
|
|
||||||
Alle in `.env.local` (gitignored):
|
Alle in `.env.local` (gitignored). Variablen:
|
||||||
- `BUNKER_URL` — Amber-NIP-46-Pairing für Signaturen
|
- `BUNKER_URL` — Amber-NIP-46-Pairing für Signaturen
|
||||||
- `CLIENT_SECRET_HEX` — identisch mit GitHub-Secret (stabile App-ID in Amber)
|
- `SPA_FTP_HOST/USER/PASS/REMOTE_PATH` — FTPS nach spa.joerg-lohrer.de
|
||||||
- `AUTHOR_PUBKEY_HEX`, `BOOTSTRAP_RELAY`
|
- `SVELTE_FTP_HOST/USER/PASS/REMOTE_PATH` — FTPS nach svelte.joerg-lohrer.de
|
||||||
- `SVELTE_FTP_*`, `STAGING_FTP_*` — FTPS-Credentials pro Deploy-Target
|
|
||||||
|
|
||||||
Falls neue Bunker-URL nötig (Amber-Session kaputt):
|
Falls neue Bunker-URL nötig (Amber-Session kaputt):
|
||||||
- In Amber neue Bunker-URL generieren
|
- In Amber neue Bunker-URL generieren
|
||||||
|
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
name: Publish Nostr Events
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
paths: ['content/posts/**']
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
force_all:
|
|
||||||
description: 'Publish all posts (--force-all)'
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: denoland/setup-deno@v2
|
|
||||||
with:
|
|
||||||
deno-version: v2.x
|
|
||||||
|
|
||||||
- name: Pre-Flight Check
|
|
||||||
working-directory: ./publish
|
|
||||||
env:
|
|
||||||
BUNKER_URL: ${{ secrets.BUNKER_URL }}
|
|
||||||
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
|
|
||||||
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
|
|
||||||
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
|
|
||||||
run: |
|
|
||||||
deno run --allow-env --allow-read --allow-net src/cli.ts check
|
|
||||||
|
|
||||||
- name: Publish
|
|
||||||
working-directory: ./publish
|
|
||||||
env:
|
|
||||||
BUNKER_URL: ${{ secrets.BUNKER_URL }}
|
|
||||||
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
|
|
||||||
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
|
|
||||||
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
|
|
||||||
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
|
|
||||||
run: |
|
|
||||||
if [ "${{ github.event.inputs.force_all }}" = "true" ]; then
|
|
||||||
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish --force-all
|
|
||||||
else
|
|
||||||
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish
|
|
||||||
fi
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: publish-log
|
|
||||||
path: ./publish/logs/publish-*.json
|
|
||||||
retention-days: 30
|
|
||||||
105
CLAUDE.md
|
|
@ -1,105 +0,0 @@
|
||||||
# CLAUDE.md — Einstieg für Claude-Sessions
|
|
||||||
|
|
||||||
Dieser Einstieg ist für Claude-Code-Sessions gedacht. Für den inhaltlichen
|
|
||||||
Projektstand siehe [`docs/STATUS.md`](docs/STATUS.md) und
|
|
||||||
[`docs/HANDOFF.md`](docs/HANDOFF.md).
|
|
||||||
|
|
||||||
## Was dieses Repo ist
|
|
||||||
|
|
||||||
Die persönliche Webseite [`joerg-lohrer.de`](https://joerg-lohrer.de/) als
|
|
||||||
SvelteKit-SPA, die Blog-Posts live aus Nostr-Events (NIP-23, `kind:30023`)
|
|
||||||
auf 5 Public-Relays rendert. Seit 2026-04-21 mehrsprachig (DE/EN).
|
|
||||||
|
|
||||||
## Einstiegsreihenfolge
|
|
||||||
|
|
||||||
1. Diese Datei (Agent-Konventionen, Fallstricke).
|
|
||||||
2. [`docs/STATUS.md`](docs/STATUS.md) — wo steht alles gerade.
|
|
||||||
3. [`docs/HANDOFF.md`](docs/HANDOFF.md) — Alltags-Workflow, Stolperfallen.
|
|
||||||
4. Für konkrete Aufgaben: Spec unter `docs/superpowers/specs/`, Plan unter
|
|
||||||
`docs/superpowers/plans/`.
|
|
||||||
|
|
||||||
## Sprache und Ton
|
|
||||||
|
|
||||||
- **Antworten und Commit-Messages auf Deutsch.**
|
|
||||||
- Code-Identifier auf Englisch.
|
|
||||||
- Kurz, konkret, kein Grundlagen-Tutorial. Jörg ist technisch versiert.
|
|
||||||
- Bei mehreren Wegen: 2–3 Varianten mit Empfehlung, nicht alles aufzählen.
|
|
||||||
|
|
||||||
## Commit-Konvention
|
|
||||||
|
|
||||||
- Conventional-Commit-Präfixe: `feat`, `fix`, `chore`, `docs`, `test`.
|
|
||||||
- Imperativ, Deutsch, Body erklärt das *Warum*.
|
|
||||||
- Co-Author immer ergänzen:
|
|
||||||
```
|
|
||||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Kritische Fallstricke
|
|
||||||
|
|
||||||
### 1. Deploy-Target
|
|
||||||
|
|
||||||
`scripts/deploy-svelte.sh` hat `DEPLOY_TARGET=svelte` als Default —
|
|
||||||
das zielt auf `svelte.joerg-lohrer.de`, NICHT auf die Produktion.
|
|
||||||
|
|
||||||
Für Live-Deploy auf `joerg-lohrer.de`:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
**Immer explizit setzen.** Der stumme Default-Fehler ist nur sichtbar,
|
|
||||||
wenn man die Live-Seite kontrolliert. Reproduzierbar als Memory-Entry
|
|
||||||
im Claude-Memory-System.
|
|
||||||
|
|
||||||
### 2. zsh-Globbing mit eckigen Klammern
|
|
||||||
|
|
||||||
SvelteKit-Routen wie `app/src/routes/[...slug]/+page.svelte` enthalten
|
|
||||||
eckige Klammern, die zsh als Glob-Pattern interpretiert. Pfade IMMER in
|
|
||||||
einfachen Anführungszeichen:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git add 'app/src/routes/[...slug]/+page.svelte'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Forgejo → GitHub Push-Mirror
|
|
||||||
|
|
||||||
`git push` landet zuerst auf Forgejo (`forgejo.joerglohrer.synology.me`).
|
|
||||||
Der Forgejo-Mirror synct dann zu GitHub (typisch 30–90 s). Die GitHub-
|
|
||||||
Action (Publish-Pipeline) läuft erst nach dem Mirror. Wer direkt nach
|
|
||||||
`git push` `gh run list` aufruft, sieht evtl. noch keinen neuen Run.
|
|
||||||
|
|
||||||
### 4. Deno-Path-Konventionen
|
|
||||||
|
|
||||||
Publish-Pipeline läuft aus `publish/` (CWD), daher sind Pfade relativ
|
|
||||||
mit `../content/posts/...`. Git-Diff liefert aber repo-root-relative
|
|
||||||
Pfade (`content/posts/...`). `changedPostDirs` normalisiert beides —
|
|
||||||
wenn `posts=0` obwohl Änderungen vorliegen, ist das der Hotspot.
|
|
||||||
|
|
||||||
### 5. Publish-Pipeline erwartet `content/posts/<lang>/<slug>/`
|
|
||||||
|
|
||||||
Die Zwei-Ebenen-Struktur ist Teil der Traversierung. Wer einen Post
|
|
||||||
versehentlich in `content/posts/<slug>/` (ohne Sprach-Ordner) anlegt,
|
|
||||||
wird von der Pipeline ignoriert.
|
|
||||||
|
|
||||||
## Hauptarbeitsbereiche im Repo
|
|
||||||
|
|
||||||
| Pfad | Inhalt |
|
|
||||||
|---|---|
|
|
||||||
| `content/posts/<lang>/<slug>/index.md` | Markdown-Posts pro Sprache |
|
|
||||||
| `app/src/lib/i18n/` | UI-Lokalisierung (svelte-i18n, activeLocale-Store) |
|
|
||||||
| `app/src/lib/nostr/` | Relay-Loader, Translations-Resolving |
|
|
||||||
| `app/src/lib/components/` | Svelte-5-Runes-Komponenten |
|
|
||||||
| `app/src/routes/` | SvelteKit-Routen (Layout, Home, Archiv, Post, Impressum) |
|
|
||||||
| `publish/src/` | Deno-Publish-Pipeline (Deno-Tasks in `publish/deno.jsonc`) |
|
|
||||||
| `publish/tests/` | Deno-Tests für die Pipeline |
|
|
||||||
| `docs/superpowers/specs/` | Produktdesigns, Konventionen |
|
|
||||||
| `docs/superpowers/plans/` | Implementierungspläne (alle erledigt) |
|
|
||||||
| `scripts/deploy-svelte.sh` | FTPS-Deploy |
|
|
||||||
|
|
||||||
## Quick-Links
|
|
||||||
|
|
||||||
- [Produktspezifikation SPA](docs/superpowers/specs/2026-04-15-nostr-page-design.md)
|
|
||||||
- [Produktspezifikation Publish-Pipeline](docs/superpowers/specs/2026-04-15-publish-pipeline-design.md)
|
|
||||||
- [Bild-Metadaten-Konvention](docs/superpowers/specs/2026-04-16-image-metadata-convention.md)
|
|
||||||
- [Multilingual-Design](docs/superpowers/specs/2026-04-21-multilingual-posts-design.md)
|
|
||||||
- [Repo-Workflow-Skill](.claude/skills/joerglohrerde-workflow.md) (ausführlicher, mit Kommandos)
|
|
||||||
104
README.md
|
|
@ -1,112 +1,46 @@
|
||||||
# joerg-lohrer.de
|
# joerg-lohrer.de
|
||||||
|
|
||||||
Persönliche Webseite. Nach einer Transition von einer Hugo-basierten,
|
Persönliche Webseite. In Transition von einer Hugo-basierten, statischen Seite
|
||||||
statischen Seite läuft `joerg-lohrer.de` jetzt als SvelteKit-SPA, die
|
hin zu einer SvelteKit-SPA, die Blog-Posts live aus signierten Nostr-Events
|
||||||
Blog-Posts live aus signierten Nostr-Events (NIP-23, `kind:30023`) rendert.
|
(NIP-23, `kind:30023`) rendert.
|
||||||
|
|
||||||
## Aktueller Stand
|
## 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://joerg-lohrer.de/`** — Hugo-Seite, läuft noch.
|
||||||
- **`https://staging.joerg-lohrer.de/`** — Staging (gleicher Build, ein Schritt vor Prod).
|
- **`https://spa.joerg-lohrer.de/`** — Vanilla-HTML-Mini-Spike (Proof of Concept).
|
||||||
- **`https://svelte.joerg-lohrer.de/`** — Entwicklungs-Deploy-Target der Pipeline.
|
- **`https://svelte.joerg-lohrer.de/`** — produktive SvelteKit-SPA (Ziel).
|
||||||
- **`https://spa.joerg-lohrer.de/`** — Vanilla-HTML-Mini-Spike (Proof of Concept, historisch).
|
|
||||||
|
|
||||||
Detailliert in [`docs/STATUS.md`](docs/STATUS.md).
|
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
|
## Navigation
|
||||||
|
|
||||||
- 📍 **Stand und Live-URLs:** [`docs/STATUS.md`](docs/STATUS.md)
|
- 📍 **Stand und Live-URLs:** [`docs/STATUS.md`](docs/STATUS.md)
|
||||||
- 🔜 **Wie es weitergeht:** [`docs/HANDOFF.md`](docs/HANDOFF.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)
|
- 📐 **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)
|
- 📐 **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)
|
- 🛠 **SvelteKit-SPA-Plan:** [`docs/superpowers/plans/2026-04-15-spa-sveltekit.md`](docs/superpowers/plans/2026-04-15-spa-sveltekit.md) (35 Tasks, abgeschlossen)
|
||||||
- 📐 **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)
|
- 🤖 **Claude-Workflow-Skill:** [`.claude/skills/joerglohrerde-workflow.md`](.claude/skills/joerglohrerde-workflow.md)
|
||||||
|
|
||||||
## Branches
|
## Branches
|
||||||
|
|
||||||
- **`main`** — kanonisch. Seit Cutover (2026-04-18) Produktions-Quelle.
|
- **`main`** — kanonisch (Content, Specs, Pläne, Deploy-Scripts, Skill).
|
||||||
- **`spa`** — historischer SvelteKit-Arbeitszweig, inzwischen gemerged.
|
- **`spa`** — aktueller Arbeitszweig mit allen SvelteKit-Commits. Wird beim
|
||||||
|
Cutover nach `main` gemerged.
|
||||||
- **`hugo-archive`** — eingefrorener Hugo-Zustand als Orphan-Branch.
|
- **`hugo-archive`** — eingefrorener Hugo-Zustand als Orphan-Branch.
|
||||||
Rollback-Option über `git checkout hugo-archive && hugo build`.
|
Rollback über `git checkout hugo-archive && hugo build`.
|
||||||
|
|
||||||
## Repo-Struktur
|
## Repo-Struktur
|
||||||
|
|
||||||
```
|
```
|
||||||
content/posts/<lang>/<slug>/ Markdown-Posts pro Sprache (26× de, 1× en)
|
content/posts/ Markdown-Posts (Quelle für Nostr-Events)
|
||||||
content/impressum.md Statisches Impressum (wird von SPA geladen)
|
app/ SvelteKit-SPA (Ziel-Implementation)
|
||||||
app/ SvelteKit-SPA (Laufzeit-Renderer)
|
preview/spa-mini/ Vanilla-HTML-Mini-Spike (Referenz)
|
||||||
src/lib/i18n/ UI-Lokalisierung (svelte-i18n + Messages)
|
scripts/deploy-svelte.sh FTPS-Deploy nach svelte.joerg-lohrer.de
|
||||||
src/lib/nostr/ Relay-Loader, Translations-Resolving
|
static/ Site-Assets (Favicons, Profilbild)
|
||||||
publish/ Deno-Publish-Pipeline (Blossom + Nostr)
|
docs/ Specs, Pläne, Status, Handoff
|
||||||
preview/spa-mini/ Vanilla-HTML-Mini-Spike (historische Referenz)
|
.claude/ Claude-Code-Sessions (transparenz) + Skills
|
||||||
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
|
## Lizenz
|
||||||
|
|
||||||
Inhalte: [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/deed.de)
|
Siehe [LICENSE](LICENSE).
|
||||||
(Namensnennung erwünscht, aber rechtlich nicht erforderlich), sofern nicht
|
|
||||||
anders vermerkt. Drittinhalte sind beim jeweiligen Bild mit Autor:innen und
|
|
||||||
Lizenz gekennzeichnet.
|
|
||||||
|
|
||||||
Code: siehe [LICENSE](LICENSE).
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
# Öffentliche Site-URL für Canonical-Link und og:url-Meta-Tags.
|
|
||||||
# Zur Build-Zeit fest; gilt domain-übergreifend (svelte./staging./haupt-
|
|
||||||
# domain). Für jeden Deploy-Zweck kann eine andere URL gesetzt werden.
|
|
||||||
#
|
|
||||||
# Beispiele:
|
|
||||||
# PUBLIC_SITE_URL=https://svelte.joerg-lohrer.de
|
|
||||||
# PUBLIC_SITE_URL=https://staging.joerg-lohrer.de
|
|
||||||
# PUBLIC_SITE_URL=https://joerg-lohrer.de
|
|
||||||
PUBLIC_SITE_URL=https://joerg-lohrer.de
|
|
||||||
|
|
@ -37,7 +37,6 @@
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"marked": "^18.0.0",
|
"marked": "^18.0.0",
|
||||||
"nostr-tools": "^2.23.3",
|
"nostr-tools": "^2.23.3",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2"
|
||||||
"svelte-i18n": "^4.0.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,9 @@
|
||||||
<meta name="description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
<meta name="description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
||||||
<meta property="og:title" content="Jörg Lohrer – Blog" />
|
<meta property="og:title" content="Jörg Lohrer – Blog" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="__SITE_URL__/" />
|
<meta property="og:url" content="https://svelte.joerg-lohrer.de/" />
|
||||||
<meta property="og:description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
<meta property="og:description" content="Jörg Lohrer – Blog (Nostr-basiert)" />
|
||||||
<link rel="canonical" href="__SITE_URL__/" />
|
|
||||||
<meta name="robots" content="index, follow" />
|
<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>
|
<title>Jörg Lohrer</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
// CC-Zero-Badge: kombination aus CC-Heart + Zero-Logo, monochrom via
|
|
||||||
// currentColor. Icons aus dem offiziellen CC-Press-Kit
|
|
||||||
// (creativecommons.org/mission/branding/). Inline hier, weil statische
|
|
||||||
// svg-imports mit ?raw in vite problematisch sind.
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span class="cc-badge" aria-hidden="true">
|
|
||||||
<!-- CC-Heart (vereinfachtes herz aus dem offiziellen logo) -->
|
|
||||||
<svg viewBox="0 0 46296 40689" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M23204.91 7530.98c2944.63,-3188.84 6384.04,-4639.01 10366.38,-4077.21 4110.34,579.88 7609.97,3518.41 8854.17,7479.01 957.39,3047.58 559.96,6460.83 -722.09,9573.35 -1993.98,4840.97 -7886.31,11722.09 -18555.24,16532.85 -10668.92,-4810.76 -16561.25,-11691.88 -18555.24,-16532.85 -1282.05,-3112.52 -1679.47,-6525.77 -722.09,-9573.35 1244.19,-3960.6 4743.83,-6899.13 8854.17,-7479.01 3982.46,-561.82 7421.94,888.46 10366.64,4077.48 5.4,5.84 56.52,61.37 56.53,61.36 0.04,0.04 51.9,-56.36 56.79,-61.63zm-56.79 -4522.44c-6431.69,-5048.01 -16512.25,-3730.83 -21147.65,3855.94 -1539.08,2519.03 -2117.14,5447.75 -1981.3,8355.45 235.64,5043.59 2412.75,9452.27 5610.61,13256.78 4306.02,5122.9 10531.26,9148.59 17382.21,12152.72 9.53,4.18 88.63,38.56 136.13,59.69 41.66,-17.53 114.6,-50.41 137.01,-60.3 6815.65,-3004.07 13075.56,-7030.12 17381.33,-12152.12 3198.08,-3804.33 5374.97,-8213.2 5610.61,-13256.78 135.85,-2907.7 -442.2,-5836.43 -1981.3,-8355.45 -4635.4,-7586.77 -14715.95,-8903.95 -21147.65,-3855.94z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M22983.64 21630.19l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.85,-2300.18 -2239.67,-3843.76 -87.17,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.75,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.13,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.43,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12zm13802.46 0l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.86,-2300.18 -2239.67,-3843.76 -87.18,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.74,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.14,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.42,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<!-- CC-Zero (kreis + 0 aus dem cc-0-logo) -->
|
|
||||||
<svg viewBox="-0.5 0.5 64 64" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M31.5,14.08c-10.565,0-13.222,9.969-13.222,18.42c0,8.452,2.656,18.42,13.222,18.42c10.564,0,13.221-9.968,13.221-18.42C44.721,24.049,42.064,14.08,31.5,14.08z M31.5,21.026c0.429,0,0.82,0.066,1.188,0.157c0.761,0.656,1.133,1.561,0.403,2.823l-7.036,12.93c-0.216-1.636-0.247-3.24-0.247-4.437C25.808,28.777,26.066,21.026,31.5,21.026z M36.766,26.987c0.373,1.984,0.426,4.056,0.426,5.513c0,3.723-0.258,11.475-5.69,11.475c-0.428,0-0.822-0.045-1.188-0.136c-0.07-0.021-0.134-0.043-0.202-0.067c-0.112-0.032-0.23-0.068-0.336-0.11c-1.21-0.515-1.972-1.446-0.874-3.093L36.766,26.987z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M31.433,0.5c-8.877,0-16.359,3.09-22.454,9.3c-3.087,3.087-5.443,6.607-7.082,10.532C0.297,24.219-0.5,28.271-0.5,32.5c0,4.268,0.797,8.32,2.397,12.168c1.6,3.85,3.921,7.312,6.969,10.396c3.085,3.049,6.549,5.399,10.398,7.037c3.886,1.602,7.939,2.398,12.169,2.398c4.229,0,8.34-0.826,12.303-2.465c3.962-1.639,7.496-3.994,10.621-7.081c3.011-2.933,5.289-6.297,6.812-10.106C62.73,41,63.5,36.883,63.5,32.5c0-4.343-0.77-8.454-2.33-12.303c-1.562-3.885-3.848-7.32-6.857-10.33C48.025,3.619,40.385,0.5,31.433,0.5z M31.567,6.259c7.238,0,13.412,2.566,18.554,7.709c2.477,2.477,4.375,5.31,5.67,8.471c1.296,3.162,1.949,6.518,1.949,10.061c0,7.354-2.516,13.454-7.506,18.33c-2.592,2.516-5.502,4.447-8.74,5.781c-3.2,1.334-6.498,1.994-9.927,1.994c-3.468,0-6.788-0.653-9.949-1.948c-3.163-1.334-6.001-3.238-8.516-5.716c-2.515-2.514-4.455-5.353-5.826-8.516c-1.333-3.199-2.017-6.498-2.017-9.927c0-3.467,0.684-6.787,2.017-9.949c1.371-3.2,3.312-6.074,5.826-8.628C18.092,8.818,24.252,6.259,31.567,6.259z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.cc-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.15em;
|
|
||||||
color: var(--accent);
|
|
||||||
vertical-align: -0.2em;
|
|
||||||
}
|
|
||||||
.cc-badge svg {
|
|
||||||
width: 1.1em;
|
|
||||||
height: 1.1em;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import type { NostrEvent, TranslationInfo } from '$lib/nostr/loaders';
|
|
||||||
import { loadTranslations } from '$lib/nostr/loaders';
|
|
||||||
import { activeLocale } from '$lib/i18n';
|
|
||||||
import type { SupportedLocale } from '$lib/i18n/activeLocale';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
event: NostrEvent;
|
|
||||||
}
|
|
||||||
let { event }: Props = $props();
|
|
||||||
|
|
||||||
let translations: TranslationInfo[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const currentId = event.id;
|
|
||||||
loading = true;
|
|
||||||
translations = [];
|
|
||||||
loadTranslations(event)
|
|
||||||
.then((infos) => {
|
|
||||||
if (event.id !== currentId) return;
|
|
||||||
translations = infos;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (event.id === currentId) loading = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function currentLang(): string {
|
|
||||||
return event.tags.find((tag) => tag[0] === 'l')?.[1] ?? 'de';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Option {
|
|
||||||
code: string;
|
|
||||||
href: string | null; // null = aktueller post, kein klick-ziel
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = $derived.by<Option[]>(() => {
|
|
||||||
const self: Option = { code: currentLang(), href: null };
|
|
||||||
const others: Option[] = translations.map((t) => ({
|
|
||||||
code: t.lang,
|
|
||||||
href: `/${t.slug}/`
|
|
||||||
}));
|
|
||||||
// aktuelle sprache zuerst, dann rest sortiert nach code
|
|
||||||
return [self, ...others.sort((a, b) => a.code.localeCompare(b.code))];
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectOther(code: string, href: string) {
|
|
||||||
activeLocale.set(code as SupportedLocale);
|
|
||||||
// hartes location-setzen, damit svelte-kit-router den post-load triggert
|
|
||||||
window.location.href = href;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if !loading && translations.length > 0}
|
|
||||||
<p class="lang-switch" role="group" aria-label="Article language">
|
|
||||||
<span class="icon" aria-hidden="true">📖</span>
|
|
||||||
{#each options as opt, i}
|
|
||||||
{#if opt.href === null}
|
|
||||||
<span class="btn active" aria-current="true">{opt.code.toUpperCase()}</span>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
onclick={() => selectOther(opt.code, opt.href!)}
|
|
||||||
>{opt.code.toUpperCase()}</button>
|
|
||||||
{/if}
|
|
||||||
{#if i < options.length - 1}<span class="sep" aria-hidden="true">|</span>{/if}
|
|
||||||
{/each}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.lang-switch {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.35rem;
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: var(--muted);
|
|
||||||
margin: 0.25rem 0 1rem;
|
|
||||||
}
|
|
||||||
.icon {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.btn:hover:not(.active) {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.btn.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
.sep {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { t, activeLocale, SUPPORTED_LOCALES } from '$lib/i18n';
|
|
||||||
import type { SupportedLocale } from '$lib/i18n/activeLocale';
|
|
||||||
|
|
||||||
let current = $state<SupportedLocale>('de');
|
|
||||||
activeLocale.subscribe((v) => (current = v));
|
|
||||||
|
|
||||||
function select(lang: SupportedLocale) {
|
|
||||||
activeLocale.set(lang);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="switcher" role="group" aria-label={$t('lang.switch_aria')}>
|
|
||||||
{#each SUPPORTED_LOCALES as code}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn"
|
|
||||||
class:active={current === code}
|
|
||||||
aria-pressed={current === code}
|
|
||||||
onclick={() => select(code)}
|
|
||||||
>{code.toUpperCase()}</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.switcher {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
border-radius: 3px;
|
|
||||||
padding: 1px 7px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.btn:hover {
|
|
||||||
color: var(--fg);
|
|
||||||
}
|
|
||||||
.btn.active {
|
|
||||||
color: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -6,8 +6,6 @@
|
||||||
import ReplyList from './ReplyList.svelte';
|
import ReplyList from './ReplyList.svelte';
|
||||||
import ReplyComposer from './ReplyComposer.svelte';
|
import ReplyComposer from './ReplyComposer.svelte';
|
||||||
import ExternalClientLinks from './ExternalClientLinks.svelte';
|
import ExternalClientLinks from './ExternalClientLinks.svelte';
|
||||||
import LanguageAvailability from './LanguageAvailability.svelte';
|
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
|
|
@ -22,20 +20,18 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const dtag = $derived(tagValue(event, 'd'));
|
const dtag = $derived(tagValue(event, 'd'));
|
||||||
let currentLocale = $state('de');
|
const title = $derived(tagValue(event, 'title') || '(ohne Titel)');
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const title = $derived(tagValue(event, 'title') || $t('post.untitled'));
|
|
||||||
const summary = $derived(tagValue(event, 'summary'));
|
const summary = $derived(tagValue(event, 'summary'));
|
||||||
const image = $derived(tagValue(event, 'image'));
|
const image = $derived(tagValue(event, 'image'));
|
||||||
const publishedAt = $derived(
|
const publishedAt = $derived(
|
||||||
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
|
parseInt(tagValue(event, 'published_at') || `${event.created_at}`, 10)
|
||||||
);
|
);
|
||||||
const date = $derived(
|
const date = $derived(
|
||||||
new Date(publishedAt * 1000).toLocaleDateString(
|
new Date(publishedAt * 1000).toLocaleDateString('de-DE', {
|
||||||
currentLocale === 'en' ? 'en-US' : 'de-DE',
|
year: 'numeric',
|
||||||
{ year: 'numeric', month: 'long', day: 'numeric' }
|
month: 'long',
|
||||||
)
|
day: 'numeric'
|
||||||
|
})
|
||||||
);
|
);
|
||||||
const tags = $derived(tagsAll(event, 't'));
|
const tags = $derived(tagsAll(event, 't'));
|
||||||
const bodyHtml = $derived(renderMarkdown(event.content));
|
const bodyHtml = $derived(renderMarkdown(event.content));
|
||||||
|
|
@ -54,7 +50,7 @@
|
||||||
|
|
||||||
<h1 class="post-title">{title}</h1>
|
<h1 class="post-title">{title}</h1>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{$t('post.published_on', { values: { date } })}
|
Veröffentlicht am {date}
|
||||||
{#if tags.length > 0}
|
{#if tags.length > 0}
|
||||||
<div class="tags">
|
<div class="tags">
|
||||||
{#each tags as t}
|
{#each tags as t}
|
||||||
|
|
@ -64,8 +60,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LanguageAvailability {event} />
|
|
||||||
|
|
||||||
{#if image}
|
{#if image}
|
||||||
<p class="cover"><img src={image} alt="Cover-Bild" /></p>
|
<p class="cover"><img src={image} alt="Cover-Bild" /></p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
// Inline-SVGs als kleine social-icon-leiste. bewusst keine zusätzlichen
|
|
||||||
// image-requests, kein icon-font. hover-color via currentColor + fill=currentColor.
|
|
||||||
|
|
||||||
const AUTHOR_PUBKEY_HEX =
|
|
||||||
'4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
|
|
||||||
|
|
||||||
type Entry = { label: string; href: string; icon: 'nostr' | 'mastodon' | 'linkedin' | 'bluesky' | 'mail' | 'orcid' };
|
|
||||||
|
|
||||||
const entries: Entry[] = [
|
|
||||||
{ label: 'Nostr', href: `https://edufeed.org/p/${AUTHOR_PUBKEY_HEX}`, icon: 'nostr' },
|
|
||||||
{ label: 'Mastodon', href: 'https://reliverse.social/@joerglohrer', icon: 'mastodon' },
|
|
||||||
{ label: 'Bluesky', href: 'https://bsky.app/profile/joerglohrer.bsky.social', icon: 'bluesky' },
|
|
||||||
{ label: 'LinkedIn', href: 'https://www.linkedin.com/in/joerglohrer', icon: 'linkedin' },
|
|
||||||
{ label: 'ORCID', href: 'https://orcid.org/0000-0002-9282-0406', icon: 'orcid' },
|
|
||||||
{ label: 'E-Mail', href: 'mailto:webmaster@joerg-lohrer.de', icon: 'mail' }
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav class="social" aria-label="Soziale Profile und Kontakt">
|
|
||||||
{#each entries as e (e.href)}
|
|
||||||
<a
|
|
||||||
href={e.href}
|
|
||||||
target={e.icon === 'mail' ? undefined : '_blank'}
|
|
||||||
rel={e.icon === 'mail' ? undefined : 'me noopener'}
|
|
||||||
aria-label={e.label}
|
|
||||||
title={e.label}
|
|
||||||
>
|
|
||||||
{#if e.icon === 'nostr'}
|
|
||||||
<!-- Nostr-Logo (Outline) von satscoffee/nostr_icons, CC0 -->
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 875 875"
|
|
||||||
aria-hidden="true"
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="40"
|
|
||||||
stroke-miterlimit="10"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="m684.72,485.57c.22,12.59-11.93,51.47-38.67,81.3-26.74,29.83-56.02,20.85-58.42,20.16s-3.09-4.46-7.89-3.77-9.6,6.17-18.86,7.2-17.49,1.71-26.06-1.37c-4.46.69-5.14.71-7.2,2.24s-17.83,10.79-21.6,11.47c0,7.2-1.37,44.57,0,55.89s3.77,25.71,7.54,36c3.77,10.29,2.74,10.63,7.54,9.94s13.37.34,15.77,4.11c2.4,3.77,1.37,6.51,5.49,8.23s60.69,17.14,99.43,19.2c26.74.69,42.86,2.74,52.12,19.54,1.37,7.89,7.54,13.03,11.31,14.06s8.23,2.06,12,5.83,1.03,8.23,5.49,11.66c4.46,3.43,14.74,8.57,25.37,13.71,10.63,5.14,15.09,13.37,15.77,16.11s1.71,10.97,1.71,10.97c0,0-8.91,0-10.97-2.06s-2.74-5.83-2.74-5.83c0,0-6.17,1.03-7.54,3.43s.69,2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8,4.11,8.23,5.83,8.23,10.63,10.97s8.23,5.83,8.23,5.83l-7.2,4.46s-4.46,2.06-14.74-.69-11.66-4.46-12.69-10.63,0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77,4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97,0-19.2,3.43-19.89,1.71-26.74-14.06-55.89-19.89-64.12c-13.03,1.03-50.74-.69-50.74-.69,0,0-2.4-.69-17.49,5.83s-36.48,13.76-46.77,19.93-14.4,9.7-16.12,13.13c.12,3-1.23,7.72-2.79,9.06s-12.48,2.42-12.48,2.42c0,0-5.85,5.86-8.25,9.97-6.86,9.6-55.2,125.14-66.52,149.83-13.54,32.57-9.77,27.43-37.71,27.43s-8.06.3-8.06.3c0,0-12.34,5.88-16.8,5.88s-18.86-2.4-26.4,0-16.46,9.26-23.31,10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12,0,0,1.74-6.51,7.91-10.88,8.23-5.83,25.37-16.11,34.63-21.26s17.49-7.89,23.31-9.26,18.51-6.17,30.51-9.94,19.54-8.23,29.83-31.54c10.29-23.31,50.4-111.43,51.43-116.23.63-2.96,3.73-6.48,4.8-15.09.66-5.35-2.49-13.04,1.71-22.63,10.97-25.03,21.6-20.23,26.4-20.23s17.14.34,26.4-1.37,15.43-2.74,24.69-7.89,11.31-8.91,11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2,1.03,8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17,13.03-1.71-18.86-5.14,7.2-2.74-16.11-4.8,8.23-3.43-14.4-5.83,4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46,3.43-4.46,3.43l1.37,12-12.2-6.27-7-11.9s2.36,4.01-9.62,7.53c-20.55,0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89,3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26,25.71-22.97,25.03-16.46,46.29-17.14c21.26-.69,32.91,2.74,46.29,8.23s38.74,13.71,43.89,17.49c11.31-9.94,28.46-19.89,34.29-19.89,1.03-2.4,6.19-12.33,17.96-17.6,35.31-15.81,108.13-34,131.53-35.54,31.2-2.06,7.89-1.37,39.09,2.06,31.2,3.43,54.17,7.54,69.6,12.69,12.58,4.19,25.03,9.6,34.29,2.06,4.33-1.81,11.81-1.34,17.83-5.14,30.69-25.09,34.72-32.35,43.63-41.95s20.14-24.91,22.54-45.14,4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03,2.06-12,5.14-22.29,6.86-30.51s9.94-14.74,19.89-16.46c9.94-1.71,17.83,1.37,22.29,4.8,4.46,3.43,11.65,6.28,13.37,10.29.34,1.71-1.37,6.51,8.23,8.23,9.6,1.71,16.05,4.16,16.05,4.16,0,0,15.64,4.29,3.11,7.73-12.69,2.06-20.52-.71-24.29,1.69s-7.21,10.08-9.61,11.1-7.2.34-12,4.11-9.6,6.86-12.69,14.4-5.49,15.77-3.43,26.74,8.57,31.54,14.4,43.2c5.83,11.66,20.23,40.8,24.34,47.66s15.77,29.49,16.8,53.83,1.03,44.23,0,54.86-10.84,51.65-35.53,85.94c-8.16,14.14-23.21,31.9-24.67,35.03-1.45,3.13-3.02,4.88-1.61,7.65,4.62,9.05,12.87,22.13,14.71,29.22,2.29,6.64,6.99,16.13,7.22,28.72Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'mastodon'}
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M21.58 13.9c-.3 1.53-2.67 3.22-5.4 3.54-1.42.17-2.82.33-4.32.26-2.45-.11-4.39-.58-4.39-.58 0 .24.01.47.05.68.32 2.4 2.39 2.55 4.34 2.61 1.97.07 3.74-.49 3.74-.49l.08 1.78s-1.39.75-3.87.88c-1.37.08-3.07-.03-5.05-.56C2.46 20.86 1.72 16.2 1.61 11.47 1.57 10.07 1.59 8.76 1.59 7.66c0-4.85 3.18-6.27 3.18-6.27 1.6-.73 4.35-1.04 7.21-1.07h.07c2.86.02 5.61.33 7.22 1.07 0 0 3.18 1.42 3.18 6.27 0 0 .04 3.59-.45 6.08-.03.16-.18.16-.42.16zm-3.01-5.53c0-1.18-.3-2.11-.9-2.81-.62-.7-1.43-1.05-2.44-1.05-1.17 0-2.06.45-2.65 1.35l-.58.97-.58-.97c-.59-.9-1.48-1.35-2.65-1.35-1.01 0-1.82.36-2.44 1.05-.6.7-.9 1.63-.9 2.81v5.78h2.3V8.54c0-1.18.5-1.78 1.5-1.78 1.1 0 1.65.71 1.65 2.13v3.09h2.28V8.88c0-1.41.55-2.13 1.65-2.13 1 0 1.5.6 1.5 1.78v5.61h2.3V8.37z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'bluesky'}
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M5.2 3.5c2.4 1.8 5 5.4 5.9 7.4.9-2 3.5-5.6 5.9-7.4 1.8-1.3 4.7-2.3 4.7 1 0 .7-.4 5.6-.6 6.4-.8 2.8-3.6 3.5-6.1 3.1 4.4.7 5.5 3.2 3.1 5.7-4.6 4.8-6.6-1.2-7.1-2.7-.1-.3-.1-.4-.1-.3 0-.1-.1 0-.1.3-.5 1.5-2.5 7.5-7.1 2.7-2.5-2.5-1.3-5 3.1-5.7-2.5.4-5.3-.3-6.1-3.1C-.1 10.1-.5 5.2-.5 4.5c0-3.3 2.9-2.3 4.7-1l1 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'linkedin'}
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14zM8.34 18.34v-8.4H5.67v8.4h2.67zM7 8.67a1.55 1.55 0 1 0 0-3.1 1.55 1.55 0 0 0 0 3.1zm11.34 9.67v-4.6c0-2.46-1.31-3.6-3.06-3.6-1.41 0-2.04.78-2.39 1.32v-1.13h-2.67c.04.75 0 8.01 0 8.01h2.67v-4.47c0-.24.02-.48.09-.65.19-.48.63-.97 1.37-.97.97 0 1.36.74 1.36 1.82v4.27h2.63z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'orcid'}
|
|
||||||
<!-- offizielles ORCID-symbol stark vereinfacht, monochrom via currentColor -->
|
|
||||||
<svg viewBox="0 0 256 256" aria-hidden="true" width="20" height="20">
|
|
||||||
<circle cx="128" cy="128" r="128" fill="currentColor" opacity="0.15" />
|
|
||||||
<circle cx="128" cy="128" r="118" fill="none" stroke="currentColor" stroke-width="10" />
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M86 76a8 8 0 1 1-16 0 8 8 0 0 1 16 0zM72 96h14v96H72V96zm34 0h38c28 0 50 21 50 48s-22 48-50 48h-38V96zm14 14v68h22c22 0 36-14 36-34s-14-34-36-34h-22z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else if e.icon === 'mail'}
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm0 2v.01L12 13l8-6.99V6H4zm0 2.7V18h16V8.7l-8 6.99L4 8.7z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.social {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: 0.6rem;
|
|
||||||
}
|
|
||||||
.social a {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 34px;
|
|
||||||
height: 34px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--code-bg);
|
|
||||||
color: var(--muted);
|
|
||||||
transition:
|
|
||||||
color 140ms,
|
|
||||||
background 140ms,
|
|
||||||
transform 140ms;
|
|
||||||
}
|
|
||||||
.social a:hover,
|
|
||||||
.social a:focus-visible {
|
|
||||||
color: var(--accent);
|
|
||||||
background: color-mix(in srgb, var(--accent) 15%, var(--code-bg));
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
.social svg {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { detectInitialLocale } from './activeLocale';
|
|
||||||
|
|
||||||
describe('detectInitialLocale', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.localStorage?.clear?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('nimmt wert aus localStorage, wenn vorhanden und gültig', () => {
|
|
||||||
const storage = new Map<string, string>([['locale', 'en']]);
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: (k) => storage.get(k) ?? null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'de-DE',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fällt auf navigator.language zurück, wenn storage leer', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'en-US',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalisiert navigator.language (de-AT → de)', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'de-AT',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('de');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fällt auf ersten supported eintrag, wenn navigator unbekannt', () => {
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: () => null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'fr-FR',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('de');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert ungültige werte im storage', () => {
|
|
||||||
const storage = new Map<string, string>([['locale', 'fr']]);
|
|
||||||
expect(detectInitialLocale({
|
|
||||||
storage: {
|
|
||||||
getItem: (k) => storage.get(k) ?? null,
|
|
||||||
setItem: () => {}
|
|
||||||
},
|
|
||||||
navigatorLanguage: 'en-US',
|
|
||||||
supported: ['de', 'en']
|
|
||||||
})).toBe('en');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { writable, type Writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export type SupportedLocale = 'de' | 'en';
|
|
||||||
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = ['de', 'en'] as const;
|
|
||||||
const STORAGE_KEY = 'locale';
|
|
||||||
|
|
||||||
interface Storage {
|
|
||||||
getItem: (key: string) => string | null;
|
|
||||||
setItem: (key: string, value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetectArgs {
|
|
||||||
storage: Storage;
|
|
||||||
navigatorLanguage: string | undefined;
|
|
||||||
supported: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectInitialLocale(args: DetectArgs): SupportedLocale {
|
|
||||||
const stored = args.storage.getItem(STORAGE_KEY);
|
|
||||||
if (stored && (args.supported as readonly string[]).includes(stored)) {
|
|
||||||
return stored as SupportedLocale;
|
|
||||||
}
|
|
||||||
const nav = (args.navigatorLanguage ?? '').slice(0, 2).toLowerCase();
|
|
||||||
if ((args.supported as readonly string[]).includes(nav)) {
|
|
||||||
return nav as SupportedLocale;
|
|
||||||
}
|
|
||||||
return args.supported[0] as SupportedLocale;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createActiveLocale(): Writable<SupportedLocale> & { bootstrap: () => void } {
|
|
||||||
const store = writable<SupportedLocale>('de');
|
|
||||||
let bootstrapped = false;
|
|
||||||
|
|
||||||
function bootstrap() {
|
|
||||||
if (bootstrapped) return;
|
|
||||||
bootstrapped = true;
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const initial = detectInitialLocale({
|
|
||||||
storage: window.localStorage,
|
|
||||||
navigatorLanguage: window.navigator.language,
|
|
||||||
supported: SUPPORTED_LOCALES
|
|
||||||
});
|
|
||||||
store.set(initial);
|
|
||||||
store.subscribe((v) => {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(STORAGE_KEY, v);
|
|
||||||
} catch {
|
|
||||||
// private-mode / quota — ignorieren
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscribe: store.subscribe,
|
|
||||||
set: store.set,
|
|
||||||
update: store.update,
|
|
||||||
bootstrap
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const activeLocale = createActiveLocale();
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import { addMessages, init, locale, _ } from 'svelte-i18n';
|
|
||||||
import de from './messages/de.json';
|
|
||||||
import en from './messages/en.json';
|
|
||||||
import { activeLocale, SUPPORTED_LOCALES } from './activeLocale';
|
|
||||||
|
|
||||||
let initialized = false;
|
|
||||||
|
|
||||||
export function initI18n(): void {
|
|
||||||
if (initialized) return;
|
|
||||||
initialized = true;
|
|
||||||
addMessages('de', de);
|
|
||||||
addMessages('en', en);
|
|
||||||
init({
|
|
||||||
fallbackLocale: 'de',
|
|
||||||
initialLocale: 'de'
|
|
||||||
});
|
|
||||||
activeLocale.bootstrap();
|
|
||||||
activeLocale.subscribe((l) => {
|
|
||||||
locale.set(l);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export { _ as t, locale, activeLocale, SUPPORTED_LOCALES };
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"nav": {
|
|
||||||
"home": "Home",
|
|
||||||
"archive": "Archiv",
|
|
||||||
"imprint": "Impressum",
|
|
||||||
"brand_aria": "Zur Startseite"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"greeting": "Hi 🖖 Willkommen auf meinem Blog 🤗",
|
|
||||||
"latest": "Neueste Beiträge",
|
|
||||||
"more_archive": "Alle Beiträge im Archiv →",
|
|
||||||
"empty": "Keine Posts gefunden auf den abgefragten Relays."
|
|
||||||
},
|
|
||||||
"archive": {
|
|
||||||
"title": "Archiv",
|
|
||||||
"subtitle": "Alle Beiträge, nach Jahr gruppiert.",
|
|
||||||
"doc_title": "Archiv – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"back_to_overview": "← Zurück zur Übersicht",
|
|
||||||
"untitled": "(ohne Titel)",
|
|
||||||
"published_on": "Veröffentlicht am {date}",
|
|
||||||
"not_found": "Post \"{slug}\" nicht gefunden.",
|
|
||||||
"unknown_error": "Unbekannter Fehler"
|
|
||||||
},
|
|
||||||
"imprint": {
|
|
||||||
"doc_title": "Impressum – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"lang": {
|
|
||||||
"switch_aria": "Sprache wechseln"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"nav": {
|
|
||||||
"home": "Home",
|
|
||||||
"archive": "Archive",
|
|
||||||
"imprint": "Imprint",
|
|
||||||
"brand_aria": "Go to homepage"
|
|
||||||
},
|
|
||||||
"home": {
|
|
||||||
"greeting": "Hi 🖖 Welcome to my blog 🤗",
|
|
||||||
"latest": "Latest posts",
|
|
||||||
"more_archive": "All posts in the archive →",
|
|
||||||
"empty": "No posts found on the queried relays."
|
|
||||||
},
|
|
||||||
"archive": {
|
|
||||||
"title": "Archive",
|
|
||||||
"subtitle": "All posts, grouped by year.",
|
|
||||||
"doc_title": "Archive – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"back_to_overview": "← Back to overview",
|
|
||||||
"untitled": "(untitled)",
|
|
||||||
"published_on": "Published on {date}",
|
|
||||||
"not_found": "Post \"{slug}\" not found.",
|
|
||||||
"unknown_error": "Unknown error"
|
|
||||||
},
|
|
||||||
"imprint": {
|
|
||||||
"doc_title": "Imprint – Jörg Lohrer"
|
|
||||||
},
|
|
||||||
"lang": {
|
|
||||||
"switch_aria": "Switch language"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { resolveTranslationsFromRefs } from './loaders';
|
|
||||||
import type { NostrEvent } from './loaders';
|
|
||||||
import type { TranslationRef } from './translations';
|
|
||||||
|
|
||||||
function ev(tags: string[][]): NostrEvent {
|
|
||||||
return {
|
|
||||||
id: 'x',
|
|
||||||
pubkey: 'p',
|
|
||||||
created_at: 0,
|
|
||||||
kind: 30023,
|
|
||||||
tags,
|
|
||||||
content: '',
|
|
||||||
sig: 's'
|
|
||||||
} as unknown as NostrEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('resolveTranslationsFromRefs', () => {
|
|
||||||
it('liefert lang/slug/title für jeden aufgelösten ref', async () => {
|
|
||||||
const refs: TranslationRef[] = [
|
|
||||||
{ kind: 30023, pubkey: 'p1', dtag: 'hello' }
|
|
||||||
];
|
|
||||||
const fetcher = async () => [
|
|
||||||
ev([
|
|
||||||
['d', 'hello'],
|
|
||||||
['title', 'Hello World'],
|
|
||||||
['L', 'ISO-639-1'],
|
|
||||||
['l', 'en', 'ISO-639-1']
|
|
||||||
])
|
|
||||||
];
|
|
||||||
const result = await resolveTranslationsFromRefs(refs, fetcher);
|
|
||||||
expect(result).toEqual([
|
|
||||||
{ lang: 'en', slug: 'hello', title: 'Hello World' }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert refs, zu denen kein event gefunden wird', async () => {
|
|
||||||
const refs: TranslationRef[] = [
|
|
||||||
{ kind: 30023, pubkey: 'p1', dtag: 'hello' },
|
|
||||||
{ kind: 30023, pubkey: 'p1', dtag: 'missing' }
|
|
||||||
];
|
|
||||||
const fetcher = async (r: TranslationRef) =>
|
|
||||||
r.dtag === 'hello'
|
|
||||||
? [ev([
|
|
||||||
['d', 'hello'],
|
|
||||||
['title', 'Hi'],
|
|
||||||
['l', 'en', 'ISO-639-1']
|
|
||||||
])]
|
|
||||||
: [];
|
|
||||||
const result = await resolveTranslationsFromRefs(refs, fetcher);
|
|
||||||
expect(result).toEqual([{ lang: 'en', slug: 'hello', title: 'Hi' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert events ohne l-tag (sprache unklar)', async () => {
|
|
||||||
const refs: TranslationRef[] = [
|
|
||||||
{ kind: 30023, pubkey: 'p', dtag: 'x' }
|
|
||||||
];
|
|
||||||
const fetcher = async () => [
|
|
||||||
ev([
|
|
||||||
['d', 'x'],
|
|
||||||
['title', 'kein lang-tag']
|
|
||||||
])
|
|
||||||
];
|
|
||||||
const result = await resolveTranslationsFromRefs(refs, fetcher);
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leere ref-liste → leere ergebnis-liste', async () => {
|
|
||||||
const fetcher = async () => {
|
|
||||||
throw new Error('should not be called');
|
|
||||||
};
|
|
||||||
expect(await resolveTranslationsFromRefs([], fetcher)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -6,7 +6,6 @@ import type { Filter as ApplesauceFilter } from 'applesauce-core/helpers/filter'
|
||||||
import { pool } from './pool';
|
import { pool } from './pool';
|
||||||
import { readRelays } from '$lib/stores/readRelays';
|
import { readRelays } from '$lib/stores/readRelays';
|
||||||
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
|
import { AUTHOR_PUBKEY_HEX, RELAY_HARD_TIMEOUT_MS } from './config';
|
||||||
import type { TranslationRef } from './translations';
|
|
||||||
|
|
||||||
/** Re-export als sprechenden Alias */
|
/** Re-export als sprechenden Alias */
|
||||||
export type { NostrEvent };
|
export type { NostrEvent };
|
||||||
|
|
@ -190,55 +189,3 @@ export async function loadReactions(dtag: string): Promise<ReactionSummary[]> {
|
||||||
.map(([content, count]) => ({ content, count }))
|
.map(([content, count]) => ({ content, count }))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranslationInfo {
|
|
||||||
lang: string;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure Variante für Tests — erhält die Events via Fetcher statt Relays.
|
|
||||||
*/
|
|
||||||
export async function resolveTranslationsFromRefs(
|
|
||||||
refs: TranslationRef[],
|
|
||||||
fetcher: (ref: TranslationRef) => Promise<NostrEvent[]>
|
|
||||||
): Promise<TranslationInfo[]> {
|
|
||||||
if (refs.length === 0) return [];
|
|
||||||
const results = await Promise.all(refs.map(fetcher));
|
|
||||||
const infos: TranslationInfo[] = [];
|
|
||||||
for (let i = 0; i < refs.length; i++) {
|
|
||||||
const evs = results[i];
|
|
||||||
if (evs.length === 0) continue;
|
|
||||||
const latest = evs.reduce((best, cur) =>
|
|
||||||
cur.created_at > best.created_at ? cur : best
|
|
||||||
);
|
|
||||||
const lang = latest.tags.find((t) => t[0] === 'l')?.[1];
|
|
||||||
if (!lang) continue;
|
|
||||||
const slug = latest.tags.find((t) => t[0] === 'd')?.[1] ?? refs[i].dtag;
|
|
||||||
const title = latest.tags.find((t) => t[0] === 'title')?.[1] ?? '';
|
|
||||||
infos.push({ lang, slug, title });
|
|
||||||
}
|
|
||||||
return infos;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loader: findet die anderssprachigen Varianten eines Posts.
|
|
||||||
* Liefert leere Liste, wenn keine a-Tags mit marker "translation" vorhanden.
|
|
||||||
*/
|
|
||||||
export async function loadTranslations(
|
|
||||||
event: NostrEvent
|
|
||||||
): Promise<TranslationInfo[]> {
|
|
||||||
const { parseTranslationRefs } = await import('./translations');
|
|
||||||
const refs = parseTranslationRefs(event);
|
|
||||||
if (refs.length === 0) return [];
|
|
||||||
const relays = get(readRelays);
|
|
||||||
return resolveTranslationsFromRefs(refs, (ref) =>
|
|
||||||
collectEvents(relays, {
|
|
||||||
kinds: [ref.kind],
|
|
||||||
authors: [ref.pubkey],
|
|
||||||
'#d': [ref.dtag],
|
|
||||||
limit: 1
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { parseTranslationRefs } from './translations';
|
|
||||||
import type { NostrEvent } from './loaders';
|
|
||||||
|
|
||||||
function ev(tags: string[][]): NostrEvent {
|
|
||||||
return {
|
|
||||||
id: 'x',
|
|
||||||
pubkey: 'p',
|
|
||||||
created_at: 0,
|
|
||||||
kind: 30023,
|
|
||||||
tags,
|
|
||||||
content: '',
|
|
||||||
sig: 's'
|
|
||||||
} as unknown as NostrEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('parseTranslationRefs', () => {
|
|
||||||
it('extrahiert a-tags mit marker "translation"', () => {
|
|
||||||
const e = ev([
|
|
||||||
['d', 'x'],
|
|
||||||
['a', '30023:abc:other-slug', '', 'translation'],
|
|
||||||
['a', '30023:abc:third-slug', '', 'translation']
|
|
||||||
]);
|
|
||||||
expect(parseTranslationRefs(e)).toEqual([
|
|
||||||
{ kind: 30023, pubkey: 'abc', dtag: 'other-slug' },
|
|
||||||
{ kind: 30023, pubkey: 'abc', dtag: 'third-slug' }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert a-tags ohne marker "translation"', () => {
|
|
||||||
const e = ev([
|
|
||||||
['a', '30023:abc:root-thread', '', 'root'],
|
|
||||||
['a', '30023:abc:x', '', 'reply']
|
|
||||||
]);
|
|
||||||
expect(parseTranslationRefs(e)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignoriert a-tags mit malformed coordinate', () => {
|
|
||||||
const e = ev([
|
|
||||||
['a', 'not-a-coord', '', 'translation'],
|
|
||||||
['a', '30023:abc:ok', '', 'translation']
|
|
||||||
]);
|
|
||||||
expect(parseTranslationRefs(e)).toEqual([
|
|
||||||
{ kind: 30023, pubkey: 'abc', dtag: 'ok' }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leeres tag-array → leere liste', () => {
|
|
||||||
expect(parseTranslationRefs(ev([]))).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import type { NostrEvent } from './loaders';
|
|
||||||
|
|
||||||
export interface TranslationRef {
|
|
||||||
kind: number;
|
|
||||||
pubkey: string;
|
|
||||||
dtag: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COORD_RE = /^(\d+):([0-9a-f]+):([a-z0-9][a-z0-9-]*)$/;
|
|
||||||
|
|
||||||
export function parseTranslationRefs(event: NostrEvent): TranslationRef[] {
|
|
||||||
const refs: TranslationRef[] = [];
|
|
||||||
for (const tag of event.tags) {
|
|
||||||
if (tag[0] !== 'a') continue;
|
|
||||||
if (tag[3] !== 'translation') continue;
|
|
||||||
const coord = tag[1];
|
|
||||||
if (typeof coord !== 'string') continue;
|
|
||||||
const m = coord.match(COORD_RE);
|
|
||||||
if (!m) continue;
|
|
||||||
refs.push({
|
|
||||||
kind: parseInt(m[1], 10),
|
|
||||||
pubkey: m[2],
|
|
||||||
dtag: m[3]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return refs;
|
|
||||||
}
|
|
||||||
|
|
@ -1,168 +1,32 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { page } from '$app/state';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import { bootstrapReadRelays } from '$lib/stores/readRelays';
|
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();
|
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(() => {
|
onMount(() => {
|
||||||
bootstrapReadRelays();
|
bootstrapReadRelays();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- favicon-Tags liegen in src/app.html — hier nichts nötig. -->
|
<svelte:head>
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
<header class="site-header">
|
</svelte:head>
|
||||||
<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>
|
<main>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</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>
|
<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 {
|
main {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
min-height: calc(100vh - 200px);
|
|
||||||
}
|
}
|
||||||
@media (min-width: 640px) {
|
@media (min-width: 640px) {
|
||||||
main {
|
main {
|
||||||
padding: 1.5rem;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,9 @@
|
||||||
import { loadPostList } from '$lib/nostr/loaders';
|
import { loadPostList } from '$lib/nostr/loaders';
|
||||||
import { getProfile } from '$lib/nostr/profileCache';
|
import { getProfile } from '$lib/nostr/profileCache';
|
||||||
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
||||||
|
import ProfileCard from '$lib/components/ProfileCard.svelte';
|
||||||
import PostCard from '$lib/components/PostCard.svelte';
|
import PostCard from '$lib/components/PostCard.svelte';
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.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 profile: Profile | null = $state(null);
|
||||||
let posts: NostrEvent[] = $state([]);
|
let posts: NostrEvent[] = $state([]);
|
||||||
|
|
@ -27,156 +20,33 @@
|
||||||
posts = list;
|
posts = list;
|
||||||
loading = false;
|
loading = false;
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
error = get(t)('home.empty');
|
error = 'Keine Posts gefunden auf den abgefragten Relays.';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loading = false;
|
loading = false;
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const p = profile;
|
const name = profile?.display_name ?? profile?.name ?? 'Jörg Lohrer';
|
||||||
const name = (p && (p.display_name ?? p.name)) ?? 'Jörg Lohrer';
|
|
||||||
document.title = `${name} – Blog`;
|
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>
|
</script>
|
||||||
|
|
||||||
<section class="hero">
|
<ProfileCard {profile} />
|
||||||
<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">
|
<h1 class="list-title">Beiträge</h1>
|
||||||
<h2 class="section-title">{$t('home.latest')}</h2>
|
|
||||||
<LoadingOrError {loading} {error} />
|
<LoadingOrError {loading} {error} />
|
||||||
{#each latest as post (post.id)}
|
|
||||||
<PostCard event={post} />
|
{#each posts as post (post.id)}
|
||||||
{/each}
|
<PostCard event={post} />
|
||||||
{#if hasMore}
|
{/each}
|
||||||
<div class="more">
|
|
||||||
<a href="/archiv/" class="more-link">{$t('home.more_archive')}</a>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hero {
|
.list-title {
|
||||||
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;
|
margin: 0 0 1rem;
|
||||||
font-size: 1.25rem;
|
font-size: 1.4rem;
|
||||||
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import type { NostrEvent } from '$lib/nostr/loaders';
|
import type { NostrEvent } from '$lib/nostr/loaders';
|
||||||
import { loadPost } from '$lib/nostr/loaders';
|
import { loadPost } from '$lib/nostr/loaders';
|
||||||
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
import { AUTHOR_PUBKEY_HEX } from '$lib/nostr/config';
|
||||||
import { buildHablaLink } from '$lib/nostr/naddr';
|
import { buildHablaLink } from '$lib/nostr/naddr';
|
||||||
import PostView from '$lib/components/PostView.svelte';
|
import PostView from '$lib/components/PostView.svelte';
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const dtag = $derived(data.dtag);
|
const dtag = $derived(data.dtag);
|
||||||
|
|
@ -23,31 +22,23 @@
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
$effect(() => {
|
onMount(async () => {
|
||||||
const currentDtag = dtag;
|
try {
|
||||||
post = null;
|
const p = await loadPost(dtag);
|
||||||
loading = true;
|
loading = false;
|
||||||
error = null;
|
if (!p) {
|
||||||
loadPost(currentDtag)
|
error = `Post "${dtag}" nicht gefunden.`;
|
||||||
.then((p) => {
|
} else {
|
||||||
if (currentDtag !== dtag) return;
|
post = p;
|
||||||
if (!p) {
|
}
|
||||||
error = get(t)('post.not_found', { values: { slug: currentDtag } });
|
} catch (e) {
|
||||||
} else {
|
loading = false;
|
||||||
post = p;
|
error = e instanceof Error ? e.message : 'Unbekannter Fehler';
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
if (currentDtag !== dtag) return;
|
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (currentDtag === dtag) loading = false;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="breadcrumb"><a href="/">{$t('post.back_to_overview')}</a></nav>
|
<nav class="breadcrumb"><a href="/">← Zurück zur Übersicht</a></nav>
|
||||||
|
|
||||||
<LoadingOrError {loading} {error} {hablaLink} />
|
<LoadingOrError {loading} {error} {hablaLink} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import type { NostrEvent } from '$lib/nostr/loaders';
|
|
||||||
import { loadPostList } from '$lib/nostr/loaders';
|
|
||||||
import PostCard from '$lib/components/PostCard.svelte';
|
|
||||||
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
|
|
||||||
import { t, activeLocale } from '$lib/i18n';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
|
|
||||||
let posts: NostrEvent[] = $state([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error: string | null = $state(null);
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
try {
|
|
||||||
posts = await loadPostList();
|
|
||||||
loading = false;
|
|
||||||
if (posts.length === 0) {
|
|
||||||
error = get(t)('home.empty');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
loading = false;
|
|
||||||
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentLocale = $state('de');
|
|
||||||
activeLocale.subscribe((v) => (currentLocale = v));
|
|
||||||
|
|
||||||
const filtered = $derived.by(() =>
|
|
||||||
posts.filter((p) => {
|
|
||||||
const l = p.tags.find((tag) => tag[0] === 'l')?.[1];
|
|
||||||
return (l ?? 'de') === currentLocale;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Posts nach Jahr gruppieren (neueste zuerst)
|
|
||||||
type YearGroup = { year: number; posts: NostrEvent[] };
|
|
||||||
const groupsByYear = $derived.by<YearGroup[]>(() => {
|
|
||||||
const byYear = new Map<number, NostrEvent[]>();
|
|
||||||
for (const p of filtered) {
|
|
||||||
const ts = Number(p.tags.find((t) => t[0] === 'published_at')?.[1] ?? p.created_at);
|
|
||||||
const year = new Date(ts * 1000).getUTCFullYear();
|
|
||||||
if (!byYear.has(year)) byYear.set(year, []);
|
|
||||||
byYear.get(year)!.push(p);
|
|
||||||
}
|
|
||||||
return [...byYear.entries()]
|
|
||||||
.map(([year, p]) => ({ year, posts: p }))
|
|
||||||
.sort((a, b) => b.year - a.year);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('archive.doc_title')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<h1 class="title">{$t('archive.title')}</h1>
|
|
||||||
<p class="meta">{$t('archive.subtitle')}</p>
|
|
||||||
|
|
||||||
<LoadingOrError {loading} {error} />
|
|
||||||
|
|
||||||
{#each groupsByYear as group (group.year)}
|
|
||||||
<section class="year-group">
|
|
||||||
<h2 class="year">{group.year}</h2>
|
|
||||||
{#each group.posts as post (post.id)}
|
|
||||||
<PostCard event={post} />
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.title {
|
|
||||||
margin: 0 0 0.3rem;
|
|
||||||
font-size: 1.8rem;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
color: var(--muted);
|
|
||||||
margin: 0 0 2rem;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
.year-group {
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
.year {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
padding-bottom: 0.3rem;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<script lang="ts">
|
|
||||||
import { renderMarkdown } from '$lib/render/markdown';
|
|
||||||
import impressumRaw from '../../../../content/impressum.md?raw';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
|
|
||||||
// Frontmatter abtrennen, nur Body rendern.
|
|
||||||
// Toleriert trailing spaces auf den ---/--- trenner-zeilen.
|
|
||||||
const match = impressumRaw.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n([\s\S]*)$/);
|
|
||||||
const body = match ? match[1] : impressumRaw;
|
|
||||||
const html = renderMarkdown(body);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('imprint.doc_title')}</title>
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<article class="impressum">
|
|
||||||
{@html html}
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.impressum :global(h1) {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.impressum :global(h2) {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin: 2rem 0 0.6rem;
|
|
||||||
}
|
|
||||||
.impressum :global(h3) {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
margin: 1.4rem 0 0.4rem;
|
|
||||||
}
|
|
||||||
.impressum :global(p) {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
.impressum :global(a) {
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -4,13 +4,6 @@ RewriteEngine On
|
||||||
RewriteCond %{HTTPS} !=on
|
RewriteCond %{HTTPS} !=on
|
||||||
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
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.
|
# Existierende Datei oder Verzeichnis? Direkt ausliefern.
|
||||||
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
RewriteCond %{REQUEST_FILENAME} -d
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
{
|
|
||||||
"names": {
|
|
||||||
"joerglohrer": "4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41"
|
|
||||||
},
|
|
||||||
"relays": {
|
|
||||||
"4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41": [
|
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://nos.lol",
|
|
||||||
"wss://relay.primal.net",
|
|
||||||
"wss://relay.tchncs.de",
|
|
||||||
"wss://relay.edufeed.org"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 844 B |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
|
@ -1,22 +1,8 @@
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
test('Home zeigt Hero (Name + Avatar) und neueste Beiträge', async ({ page }) => {
|
test('Home zeigt Profil und mindestens einen Post', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
// Hero: Name als h1
|
await expect(page.getByRole('heading', { level: 1, name: /Beiträge/ })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
await expect(page.locator('.profile .name')).toBeVisible({ timeout: 15_000 });
|
||||||
// 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 });
|
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();
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { defineConfig } from 'vitest/config';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()],
|
plugins: [sveltekit()],
|
||||||
test: {
|
test: {
|
||||||
include: ['tests/unit/**/*.{test,spec}.{js,ts}', 'src/**/*.{test,spec}.{js,ts}'],
|
include: ['tests/unit/**/*.{test,spec}.{js,ts}'],
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true
|
globals: true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Rich
|
||||||
Mein Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte ich keinen Einfluss habe. Deshalb kann ich für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
|
Mein Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte ich keinen Einfluss habe. Deshalb kann ich für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
|
||||||
|
|
||||||
### Urheberrecht
|
### Urheberrecht
|
||||||
Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Soweit nicht anders angegeben, stelle ich eigene Inhalte und Werke unter der Creative-Commons-Lizenz [CC0 1.0 Universal (Public Domain Dedication)](https://creativecommons.org/publicdomain/zero/1.0/deed.de) zur Verfügung — sie dürfen ohne Rückfrage für jeden Zweck, auch kommerziell, kopiert, bearbeitet, verbreitet und weiterverwendet werden. Eine Namensnennung ist rechtlich nicht erforderlich, aber ich freue mich natürlich, wenn Du mich als Quelle nennst. Wo eine abweichende Lizenz gilt, ist sie beim jeweiligen Inhalt vermerkt. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Solltest Du trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitte ich um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Inhalte umgehend entfernen.
|
Die durch den Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen jedoch nicht der schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind sowohl für den privaten, als auch für den kommerziellen Gebrauch unter Namensnennung und der Creative Commons Lizenz [CC BY-SA](https://creativecommons.org/licenses/by-sa/4.0/deed.de) gestattet. Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Solltest Du trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten ich um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Inhalte umgehend entfernen.
|
||||||
|
|
||||||
### Datenschutz
|
### Datenschutz
|
||||||
Die Nutzung meiner Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf meiner Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder eMail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Deine ausdrückliche Zustimmung nicht an Dritte weitergegeben. Ich weise darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich. Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten durch Dritte zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit ausdrücklich widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-Mails, vor.
|
Die Nutzung meiner Webseite ist in der Regel ohne Angabe personenbezogener Daten möglich. Soweit auf meiner Seiten personenbezogene Daten (beispielsweise Name, Anschrift oder eMail-Adressen) erhoben werden, erfolgt dies, soweit möglich, stets auf freiwilliger Basis. Diese Daten werden ohne Deine ausdrückliche Zustimmung nicht an Dritte weitergegeben. Ich weise darauf hin, dass die Datenübertragung im Internet (z.B. bei der Kommunikation per E-Mail) Sicherheitslücken aufweisen kann. Ein lückenloser Schutz der Daten vor dem Zugriff durch Dritte ist nicht möglich. Der Nutzung von im Rahmen der Impressumspflicht veröffentlichten Kontaktdaten durch Dritte zur Übersendung von nicht ausdrücklich angeforderter Werbung und Informationsmaterialien wird hiermit ausdrücklich widersprochen. Die Betreiber der Seiten behalten sich ausdrücklich rechtliche Schritte im Falle der unverlangten Zusendung von Werbeinformationen, etwa durch Spam-Mails, vor.
|
||||||
|
|
@ -11,16 +11,6 @@ author: Jörg Lohrer
|
||||||
slug: "premium-freemium-mium-mium-mium"
|
slug: "premium-freemium-mium-mium-mium"
|
||||||
lang: de
|
lang: de
|
||||||
dir: ltr
|
dir: ltr
|
||||||
images:
|
|
||||||
- file: my-very-hungry-caterpillar.jpg
|
|
||||||
role: cover
|
|
||||||
alt: "Kleine Raupe aus Papier gefaltet, Anspielung auf das Kinderbuch 'Die kleine Raupe Nimmersatt'"
|
|
||||||
license: "https://creativecommons.org/licenses/by-nc-sa/3.0/"
|
|
||||||
authors:
|
|
||||||
- name: "Relly Annett-Baker"
|
|
||||||
source_url: "https://www.flickr.com/photos/fizzkitten/4454153264/"
|
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 187 KiB |
|
|
@ -8,13 +8,13 @@ date: "2013-05-29"
|
||||||
slug: "erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive"
|
slug: "erlebnispadagogik-im-handbuch-jugend-evangelische-perspektive"
|
||||||
lang: de
|
lang: de
|
||||||
dir: ltr
|
dir: ltr
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Erlebnispädagogik im Handbuch Jugend – Evangelische Perspektiven
|
# Erlebnispädagogik im Handbuch Jugend – Evangelische Perspektiven
|
||||||
|
|
||||||
Das [**Handbuch Jugend – Evangelische Perspektiven**](http://ci-muenster.de/bookshop/artikel/buecher/Bildung-im-Kindes-und-Jugendalter/A40097_Handbuch_Jugend_2013.php), welches 2013 erschienen ist, hat auf Seite 343-345 folgenden Artikel von mir zur Erlebnispädagogik abgedruckt.
|
Das
|
||||||
|
[](http://ci-muenster.de/bookshop/artikel/buecher/Bildung-im-Kindes-und-Jugendalter/A40097_Handbuch_Jugend_2013.php)
|
||||||
|
**Handbuch Jugend – Evangelische Perspektiven**, welches 2013 erschienen ist, hat auf Seite 343-345 folgenden Artikel von mir zur Erlebnispädagogik abgedruckt.
|
||||||
[CC BY](http://creativecommons.org/licenses/by/2.0/de/) Jörg Lohrer
|
[CC BY](http://creativecommons.org/licenses/by/2.0/de/) Jörg Lohrer
|
||||||
|
|
||||||
## Erlebnispädagogik
|
## Erlebnispädagogik
|
||||||
|
|
@ -44,10 +44,14 @@ Die Wirkungsforschung erlebnispädagogischer Lernarrangements untersucht deren S
|
||||||
Im Kontext evangelischer Jugendbildung stellt sich bei der Verwendung erlebnispädagogischer Methoden immer auch die Frage nach einer Verknüpfung von christlichen Inhalten mit der handlungsorientierten Pädagogik. Die Assoziation von Natur und Schöpfung ist beispielsweise ebenso naheliegend wie die Reflexion von religiösen Erfahrungen in erlebnispädagogischen Lernsettings. Inwiefern sich dabei Unverfügbares inszenieren lässt, bleibt dabei ebenso eine Herausforderung, wie bei der oben beschriebenen Wirkungsforschung. In den vergangenen Jahren haben sich einige Konzepte zur Erprobung erlebnispädagogischer Methoden im christlichen Kontext entwickelt, die zunehmend auch theologisch und religionsdidaktisch reflektiert werden. Eine überregionale Organisation dieser Einzelinitiativen auf Bundesebene wäre der nächste Schritt hin zu einem evangelischen Profil erlebnispädagogischer Jugendbildungsarbeit.
|
Im Kontext evangelischer Jugendbildung stellt sich bei der Verwendung erlebnispädagogischer Methoden immer auch die Frage nach einer Verknüpfung von christlichen Inhalten mit der handlungsorientierten Pädagogik. Die Assoziation von Natur und Schöpfung ist beispielsweise ebenso naheliegend wie die Reflexion von religiösen Erfahrungen in erlebnispädagogischen Lernsettings. Inwiefern sich dabei Unverfügbares inszenieren lässt, bleibt dabei ebenso eine Herausforderung, wie bei der oben beschriebenen Wirkungsforschung. In den vergangenen Jahren haben sich einige Konzepte zur Erprobung erlebnispädagogischer Methoden im christlichen Kontext entwickelt, die zunehmend auch theologisch und religionsdidaktisch reflektiert werden. Eine überregionale Organisation dieser Einzelinitiativen auf Bundesebene wäre der nächste Schritt hin zu einem evangelischen Profil erlebnispädagogischer Jugendbildungsarbeit.
|
||||||
|
|
||||||
### Literatur
|
### Literatur
|
||||||
- Großer, Achim/Oberländer, Rainer/Lohrer, Jörg/Wiedmayer, Jörg (2005/2011): Sinn gesucht – Gott erfahren. Erlebnispädagogik im christlichen Kontext (Band 1 (2005)/Band 2 (2011)). Stuttgart: Buch und Musik.
|

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

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

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

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

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

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

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

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

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

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

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

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

|
||||||
|
|
||||||
|
Fertig!
|
||||||
|
|
||||||
|
Diese Idee inklusive der Schablone steht unter [CC0-Lizenz](https://creativecommons.org/publicdomain/zero/1.0/deed.de). Du darfst das Werk kopieren, verändern, verbreiten und aufführen, sogar zu kommerziellen Zwecken, ohne um weitere Erlaubnis bitten zu müssen.
|
||||||
|
#### Weitere Quellen
|
||||||
|
* How to Make a Paper Cut-Out Luther Rose [YouTube](https://www.youtube.com/watch?v=b5FCaNZPU98) | [PDF](http://www.kellyklages.com/lutherrose.pdf)
|
||||||
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 3.8 MiB After Width: | Height: | Size: 3.8 MiB |
|
|
@ -11,21 +11,6 @@ slug: "pflanzenschild-qr-code"
|
||||||
author: Jörg Lohrer
|
author: Jörg Lohrer
|
||||||
lang: de
|
lang: de
|
||||||
dir: ltr
|
dir: ltr
|
||||||
images:
|
|
||||||
- file: cura-plugin-change-filment-at-z.png
|
|
||||||
role: cover
|
|
||||||
alt: "Screenshot des Cura-Slicers mit aktiviertem 'Change Filament at Z'-Plugin — Konfiguration eines Filamentwechsels in bestimmten Layern"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: qr-code-pflanzenschild.jpg
|
|
||||||
alt: "Dreieckiges 3D-gedrucktes Pflanzenschild mit aufgedrucktem zweifarbigem QR-Code, steckt in einem Pflanztopf"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 173 KiB |
|
Before Width: | Height: | Size: 446 KiB After Width: | Height: | Size: 446 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 236 KiB After Width: | Height: | Size: 236 KiB |
|
|
@ -15,50 +15,6 @@ toc: true
|
||||||
toc_label: "Inhaltsverzeichnis"
|
toc_label: "Inhaltsverzeichnis"
|
||||||
toc_icon: "vr-cardboard"
|
toc_icon: "vr-cardboard"
|
||||||
toc_sticky: "true"
|
toc_sticky: "true"
|
||||||
images:
|
|
||||||
- file: 04-aframe.jpg
|
|
||||||
role: cover
|
|
||||||
alt: "Screenshot einer A-Frame-WebVR-Szene: 3D-Objekte in einem Browser-Viewport, erstellt mit A-Frame-Framework"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
source_url: "https://codepen.io/joerglohrer/full/dyXQqWG"
|
|
||||||
|
|
||||||
- file: 01-immersion-wikipedia.jpg
|
|
||||||
alt: "Screenshot des Wikipedia-Artikels 'Immersive learning' mit Einstiegsdefinition"
|
|
||||||
license: UNKNOWN
|
|
||||||
authors: UNKNOWN
|
|
||||||
source_url: "https://en.wikipedia.org/wiki/Immersive_learning"
|
|
||||||
|
|
||||||
- file: 02-mittelalterliche-kirche.jpg
|
|
||||||
alt: "Screenshot eines 3D-Modells einer mittelalterlichen Kirche (Calatrava la Nueva, Spanien) auf Sketchfab, erstellt aus 76 Laser-Scans und 4100 Fotos"
|
|
||||||
license: "https://creativecommons.org/licenses/by-nc/4.0/"
|
|
||||||
authors: UNKNOWN
|
|
||||||
source_url: "https://sketchfab.com/3d-models/medieval-church-calatrava-la-nueva-spain-171a047c08bc4dd588cca5ac744e8065"
|
|
||||||
|
|
||||||
- file: 03-avatare-erstellen.jpg
|
|
||||||
alt: "Screenshot der Avatar-Erstellung im Ready Player Me Web-Interface"
|
|
||||||
license: UNKNOWN
|
|
||||||
authors: UNKNOWN
|
|
||||||
|
|
||||||
- file: 05-pupillendistanz.jpg
|
|
||||||
alt: "Screenshot der iOS-App 'EyeMeasure' bei der Messung des Pupillenabstands mittels iPhone-Kamera"
|
|
||||||
license: UNKNOWN
|
|
||||||
authors: UNKNOWN
|
|
||||||
|
|
||||||
- file: 06-vr-adapter-3ddruck.jpg
|
|
||||||
alt: "3D-gedruckter Adapter zur Befestigung einer VIVE Deluxe Audio Strap an der Oculus Quest 2, frisch aus dem 3D-Drucker"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: 07-vive-straps-3ddruck.jpg
|
|
||||||
alt: "3D-gedruckte Halterungen der VIVE Deluxe Audio Strap, montiert an der Oculus Quest 2"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 410 KiB |
|
|
@ -11,51 +11,6 @@ author: Jörg Lohrer
|
||||||
slug: "wordpress-werkstatt"
|
slug: "wordpress-werkstatt"
|
||||||
lang: de
|
lang: de
|
||||||
dir: ltr
|
dir: ltr
|
||||||
images:
|
|
||||||
- file: 04-termine-neu.png
|
|
||||||
role: cover
|
|
||||||
alt: "Screenshot der WordPress-Beitragsübersicht mit eingefügtem Shortcode [relilab_termine], der eine Terminliste als Block rendert"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: 01-json-import.png
|
|
||||||
alt: "Screenshot der ACF-Plugin-Oberfläche beim Import einer JSON-Datei mit Feldgruppen-Definitionen"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: 02-terminfelder.png
|
|
||||||
alt: "Screenshot eines WordPress-Beitrags mit zwei neuen ACF-Terminfeldern 'Startet am' und 'Endet am' als Datum-/Zeit-Picker"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: 03-kategorien.png
|
|
||||||
alt: "Screenshot der WordPress-Kategorieverwaltung mit neu angelegter Kategorie 'Termine' samt Unterkategorien"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: 05-php-storm.png
|
|
||||||
alt: "Screenshot der PhpStorm-IDE mit geöffneter PHP-Datei zum add_shortcode()-Aufruf"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: 06-termine-listen.png
|
|
||||||
alt: "Screenshot des PHP-Codes für die Funktion 'termineAusgeben' mit get_posts()-Abfrage und Shortcode-Registrierung"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: 07-external-library.png
|
|
||||||
alt: "Screenshot der PhpStorm-Konfiguration zur Einbindung von WordPress als External Library für Auto-Complete"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
|
@ -15,15 +15,6 @@ toc: true
|
||||||
toc_label: "Inhaltsverzeichnis"
|
toc_label: "Inhaltsverzeichnis"
|
||||||
toc_icon: "futbol"
|
toc_icon: "futbol"
|
||||||
toc_sticky: "true"
|
toc_sticky: "true"
|
||||||
images:
|
|
||||||
- file: bibelfussball1.png
|
|
||||||
role: cover
|
|
||||||
alt: "Tafel-Skizze eines Fußballfeldes mit Mittellinie, Strafräumen und zwei Toren — Magnetknopf markiert die aktuelle Ballposition"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
|
@ -11,33 +11,6 @@ author: Jörg Lohrer
|
||||||
slug: "moodle-iomad-linux"
|
slug: "moodle-iomad-linux"
|
||||||
lang: de
|
lang: de
|
||||||
dir: ltr
|
dir: ltr
|
||||||
images:
|
|
||||||
- file: title-gif.gif
|
|
||||||
role: cover
|
|
||||||
alt: "Animiertes Titelbild des Artikels zur Moodle-Server-Installation mit Iomad unter Ubuntu"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: 01-netzwerkbruecke.png
|
|
||||||
alt: "Screenshot der VirtualBox-Netzwerkeinstellungen mit aktivierter Netzwerkbrücke für die Ubuntu-VM"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: 02-hosts-eintragen.png
|
|
||||||
alt: "Terminal-Screenshot mit geöffneter /etc/hosts-Datei im nano-Editor, neuer Eintrag 'moodle.local' wird hinzugefügt"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
|
|
||||||
- file: "03-config generieren.png"
|
|
||||||
alt: "Screenshot des Moodle-Installationsassistenten beim automatischen Generieren der config.php"
|
|
||||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
|
||||||
authors:
|
|
||||||
- name: "Jörg Lohrer"
|
|
||||||
# a:
|
|
||||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 761 KiB After Width: | Height: | Size: 761 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 221 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 606 KiB After Width: | Height: | Size: 606 KiB |
|
Before Width: | Height: | Size: 560 KiB After Width: | Height: | Size: 560 KiB |
|
Before Width: | Height: | Size: 672 KiB After Width: | Height: | Size: 672 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 813 KiB After Width: | Height: | Size: 813 KiB |
|
Before Width: | Height: | Size: 440 KiB After Width: | Height: | Size: 440 KiB |
|
Before Width: | Height: | Size: 823 KiB After Width: | Height: | Size: 823 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |