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

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

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

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

|
||||
|
|
@ -1,46 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Lutherkürbis - Reformation an Halloween"
|
||||
description: "Schablone und Bastelanleitung für einen Kürbis zur Reformation"
|
||||
image: kuerbis-titelbild.jpg
|
||||
cover:
|
||||
image: kuerbis-titelbild.jpg
|
||||
tags: [ "Lutherrose", "Reformation", "Halloween", "Luther" ]
|
||||
date: "2017-10-31"
|
||||
author: Jörg Lohrer
|
||||
slug: "lutherkuerbis"
|
||||
lang: de
|
||||
dir: ltr
|
||||
---
|
||||
|
||||
|
||||
# Lutherkürbis - Reformation an Halloween
|
||||
|
||||
Das Symbol der Lutherrose ist auch heute noch in vielen Wappen enthalten und wird als Dekoelement genutzt ([Quelle: epd/imago](https://www.t-online.de/leben/familie/id_65982142/lutherrose-entstehung-und-bedeutung.html))
|
||||
|
||||
Aus einer [Fotovorlage der Lutherrose](https://duckduckgo.com/?q=lutherrose&t=h_&iax=images&ia=images) wird mit einem [Online-Konverter eine Schablone als verlustfrei skalierbare Vektorgrafik](https://image.online-convert.com/convert-to-svg) erzeugt:
|
||||
|
||||
[](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)
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Jojos Schoko-Zimt-Schnecken"
|
||||
description: "Rezept und Backanleitung"
|
||||
author: Jörg Lohrer
|
||||
image: schneckennudeln-titel.jpg
|
||||
cover:
|
||||
image: schneckennudeln-titel.jpg
|
||||
tags: [ "Schneckennudel", "Hefeteig", "Schoko", "Zimt" ]
|
||||
date: "2023-02-26"
|
||||
slug: "jojos-schoko-zimt-schnecken"
|
||||
lang: de
|
||||
dir: ltr
|
||||
---
|
||||
|
||||
|
||||
# Schoko-Schnecken
|
||||
|
||||
## Hefeteig
|
||||
**200g Milch** handwarm
|
||||
**½ Pk Vanillezucker
|
||||
60g Zucker
|
||||
22g Hefe**
|
||||
verrühren
|
||||
**1 Ei Größe L** dazu
|
||||
in der Teigknetmaschine
|
||||
**120g Weizenmehl 405
|
||||
380g Dinkelmehl 630**
|
||||
dazu und wenn es ein fester Teig ist
|
||||
**5g Salz** zugeben
|
||||
und
|
||||
**60g Butter** kalt in Streifen schneiden und
|
||||
5-10 Minuten einketen (-> Fenstertest)
|
||||
danach Teig 30-60 Minuten ruhen/gehen lassen
|
||||
|
||||
## Füllung
|
||||
in der Zwischenzeit
|
||||
**10g brauner Zucker
|
||||
30g Rohrohrzucker
|
||||
50g weißer Zucker
|
||||
100g Butter
|
||||
½ Pk Vanillezucker
|
||||
2 Teelöffel Zimt
|
||||
5 Teelöffel Kaba**
|
||||
verkneten zu einer cremigen Masse
|
||||
|
||||
Hefeteig ausrollen und mit der Füllung bestreichen
|
||||
Nach Belieben noch **Raspel Schokolade** darauf verteilen
|
||||

|
||||
einrollen, in 16 Stücke schneiden und in Kuchenform setzen:
|
||||

|
||||
Mit Frischhaltefolie abdecken und weitere ca. 30 Minuten gehen lassen, dann mit Eimilch abstreichen:
|
||||

|
||||
Backofen auf 220°Celsius Ober-/Unterhitze vorheizen.
|
||||
In den Ofen und dabei auf 180° reduzieren.
|
||||
Nach 10 Minuten auf 160° reduzien:
|
||||

|
||||
weitere 25 Minuten backen oder bis eine Kerntemperatur von 92° erreicht ist. Fertig:
|
||||

|
||||
|
|
@ -1,54 +0,0 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Hefefreuden - Dampfnudeln & Minihefezopf"
|
||||
description: "Rezept und Backanleitung"
|
||||
author: Jörg Lohrer
|
||||
image: Hefefreuden.jpg
|
||||
cover:
|
||||
image: Hefefreuden.jpg
|
||||
tags: [ "Dampfnudel", "Hefeteig", "Hefezopf" ]
|
||||
date: "2023-04-07"
|
||||
slug: "dampfnudeln"
|
||||
lang: de
|
||||
dir: ltr
|
||||
---
|
||||
|
||||
|
||||
# Dampfnudeln & Minihefezopf
|
||||
|
||||
## Zutaten
|
||||
|
||||
- 400 ml Milch
|
||||
- 120g Zucker
|
||||
- 1 Pkg Hefe (42g)
|
||||
- 3 Eier (ca 150g Vollei)
|
||||
- 1000g Mehl (300g Weizen Type 405 & 700g Dinkel Type 630)
|
||||
- 10 g Salz
|
||||
- 120 g Butter
|
||||
|
||||
## Rezept
|
||||
- Milch, Zucker, Hefe handwarm mischen
|
||||
- Eier dazu und in der Knetmaschine 5 Minuten lang das Mehl unterkneten
|
||||
- Salz dazu und die in Streifen geschnittene Butter weitere 10 Minuten verkneten
|
||||
- Mindestens 30 Minuten gehen lassen
|
||||

|
||||
|
||||
### Dampfnudeln
|
||||
- 6 x 135g Stücke vom Teig abstechen, rundschleifen und auf gelochtes Dampfgarblech aufsetzen:
|
||||

|
||||
- weitere 30 Minuten gehen lassen
|
||||
- dann bei 100°Celsius für 30 Minuten dampfgaren
|
||||
- fertig
|
||||

|
||||
- mit Vanillesoße servieren 
|
||||
|
||||
|
||||
### Hefezopf
|
||||
- Die restlichen ca 900-1000g Teig in 3 gleiche Teile abwiegen und zu einem Zopf flechten
|
||||
- Mit Küchenhandtuch abgedeckt mindestens 30 Minuten gehen lassen
|
||||
- Mit Ei abstreichen und im auf 220° vorgeheizten Backofen direkt bei einschießen auf 180° reduzieren
|
||||
- Nach 10 Minuten Back-Temperatur auf 160° reduzieren
|
||||
- Entweder backen bis 93° Kerntemperatur erreicht ist oder nach ca weiteren 30 Minuten
|
||||

|
||||
|
||||
|
||||
|
|
@ -11,6 +11,16 @@ author: Jörg Lohrer
|
|||
slug: "premium-freemium-mium-mium-mium"
|
||||
lang: de
|
||||
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"
|
||||
lang: de
|
||||
dir: ltr
|
||||
# a:
|
||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
||||
---
|
||||
|
||||
# Erlebnispädagogik im Handbuch Jugend – Evangelische Perspektiven
|
||||
|
||||
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.
|
||||
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.
|
||||
[CC BY](http://creativecommons.org/licenses/by/2.0/de/) Jörg Lohrer
|
||||
|
||||
## Erlebnispädagogik
|
||||
|
|
@ -44,14 +44,10 @@ Die Wirkungsforschung erlebnispädagogischer Lernarrangements untersucht deren S
|
|||
Im Kontext evangelischer Jugendbildung stellt sich bei der Verwendung erlebnispädagogischer Methoden immer auch die Frage nach einer Verknüpfung von christlichen Inhalten mit der handlungsorientierten Pädagogik. Die Assoziation von Natur und Schöpfung ist beispielsweise ebenso naheliegend wie die Reflexion von religiösen Erfahrungen in erlebnispädagogischen Lernsettings. Inwiefern sich dabei Unverfügbares inszenieren lässt, bleibt dabei ebenso eine Herausforderung, wie bei der oben beschriebenen Wirkungsforschung. In den vergangenen Jahren haben sich einige Konzepte zur Erprobung erlebnispädagogischer Methoden im christlichen Kontext entwickelt, die zunehmend auch theologisch und religionsdidaktisch reflektiert werden. Eine überregionale Organisation dieser Einzelinitiativen auf Bundesebene wäre der nächste Schritt hin zu einem evangelischen Profil erlebnispädagogischer Jugendbildungsarbeit.
|
||||
|
||||
### 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.
|
||||

|
||||
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.
|
||||
- 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.
|
||||
- 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
|
||||
- Bundesverband Individual- und Erlebnispädagogik e.V. (BE): [https://www.bundesverband-erlebnispaedagogik.de/](https://www.bundesverband-erlebnispaedagogik.de/)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Telegram Bot für Octopi"
|
||||
description: "Schnittstelle zwischen Telegram und OctoPrint"
|
||||
image: octopi1.png
|
||||
cover:
|
||||
image: octopi1.png
|
||||
tags: [ "Telegram", "Octopi", "Raspberry", "3DDruck" ]
|
||||
date: "2017-10-23"
|
||||
author: Jörg Lohrer
|
||||
slug: "telegram-octopi"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: octopi1.png
|
||||
role: cover
|
||||
alt: "Screenshot der OctoPrint-Plugin-Verwaltung während der Installation des Telegram-Plugins — Fortschrittsanzeige läuft"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: octopi2.png
|
||||
alt: "Screenshot der Konfigurationsmaske des OctoPrint-Telegram-Plugins mit Eingabefeld für den Telegram-Bot-Token"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: octopi3.png
|
||||
alt: "Screenshot der OctoPrint-Telegram-Plugin-Oberfläche nach erfolgreichem Token-Eintrag — Benutzerliste wird angezeigt, Rechte fehlen noch"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: octopi4.png
|
||||
alt: "Screenshot der Benutzer-Rechte-Konfiguration mit gesetzten Häkchen bei 'Command' und 'Notify'"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
# a:
|
||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
||||
---
|
||||
|
||||
|
||||
|
||||
Das [OctoPrint-Telegram-Plugin](http://plugins.octoprint.org/plugins/telegram/) schafft eine Schnittstelle zwischen Telegram und OctoPrint.
|
||||
Hier die Anleitung auf Englisch: [https://github.com/fabianonline/OctoPrint-Telegram/blob/stable/README.md](https://github.com/fabianonline/OctoPrint-Telegram/blob/stable/README.md)
|
||||
|
||||
Das dauert eine Weile:
|
||||

|
||||
|
||||
|
||||
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,87 @@
|
|||
---
|
||||
layout: post
|
||||
title: "Lutherkürbis - Reformation an Halloween"
|
||||
description: "Schablone und Bastelanleitung für einen Kürbis zur Reformation"
|
||||
image: kuerbis-titelbild.jpg
|
||||
cover:
|
||||
image: kuerbis-titelbild.jpg
|
||||
tags: [ "Lutherrose", "Reformation", "Halloween", "Luther" ]
|
||||
date: "2017-10-31"
|
||||
author: Jörg Lohrer
|
||||
slug: "lutherkuerbis"
|
||||
lang: de
|
||||
dir: ltr
|
||||
images:
|
||||
- file: kuerbis-titelbild.jpg
|
||||
role: cover
|
||||
alt: "Fertig geschnitzter Kürbis mit dem Muster einer Lutherrose, innen beleuchtet — glüht warm in der Dunkelheit"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: lutherrose.png
|
||||
alt: "Schwarz-weiße Vektorgrafik der Lutherrose: Kreuz im Herzen, umgeben von fünfblättriger Rose in einem Ring — als Schnitzschablone aufbereitet"
|
||||
caption: "Vektorisierte Schablone, abgeleitet von einer Fotovorlage aus dem Web"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
modifications: "Vektorisierung per online-convert.com aus gemeinfreier Fotovorlage; Originalurheber der Fotovorlage unbekannt"
|
||||
|
||||
- file: kuerbis-aufschneiden.jpg
|
||||
alt: "Hände schneiden mit großem Messer den Deckel von einem orangenen Kürbis ab"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: kuerbis-entkernen.jpg
|
||||
alt: "Mit einem Löffel wird das Fruchtfleisch und die Kerne aus dem Inneren des aufgeschnittenen Kürbis herausgekratzt"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: schablone-aufbringen.jpg
|
||||
alt: "Papier-Schablone mit Lutherrosen-Motiv wird auf die Außenhaut des entkernten Kürbis geklebt"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
|
||||
- file: kuerbis-ausschneiden.jpg
|
||||
alt: "Mit einem Schnitzwerkzeug wird die Lutherrose entlang der Schablone aus der Kürbishaut herausgeschnitten"
|
||||
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
|
||||
authors:
|
||||
- name: "Jörg Lohrer"
|
||||
# a:
|
||||
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
|
||||
---
|
||||
|
||||
|
||||
# Lutherkürbis - Reformation an Halloween
|
||||
|
||||
Das Symbol der Lutherrose ist auch heute noch in vielen Wappen enthalten und wird als Dekoelement genutzt ([Quelle: epd/imago](https://www.t-online.de/leben/familie/id_65982142/lutherrose-entstehung-und-bedeutung.html))
|
||||
|
||||
Aus einer [Fotovorlage der Lutherrose](https://duckduckgo.com/?q=lutherrose&t=h_&iax=images&ia=images) wird mit einem [Online-Konverter eine Schablone als verlustfrei skalierbare Vektorgrafik](https://image.online-convert.com/convert-to-svg) erzeugt:
|
||||
|
||||
[](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,6 +11,21 @@ slug: "pflanzenschild-qr-code"
|
|||
author: Jörg Lohrer
|
||||
lang: de
|
||||
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,6 +15,50 @@ toc: true
|
|||
toc_label: "Inhaltsverzeichnis"
|
||||
toc_icon: "vr-cardboard"
|
||||
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,6 +11,51 @@ author: Jörg Lohrer
|
|||
slug: "wordpress-werkstatt"
|
||||
lang: de
|
||||
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,6 +15,15 @@ toc: true
|
|||
toc_label: "Inhaltsverzeichnis"
|
||||
toc_icon: "futbol"
|
||||
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,6 +11,33 @@ author: Jörg Lohrer
|
|||
slug: "moodle-iomad-linux"
|
||||
lang: de
|
||||
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 |