Compare commits

..

67 Commits
spa ... main

Author SHA1 Message Date
Jörg Lohrer d12ed3c40e docs: status/handoff/readme/claude.md auf multilingual-stand
- README: neue plan-referenzen (3x multilingual), repo-struktur auf
  content/posts/<lang>/<slug>/, svelte-i18n + translations im app-tree
- STATUS: event-count 27 (26 de + 1 en), kurzfassung um mehrsprachigkeit,
  multilingual-abschnitt in „erledigt" (pipeline, spa, i18n + bugfixes)
- HANDOFF: option D entfernt (erledigt), neuer abschnitt „wie man eine
  übersetzung anlegt", frontmatter-template um a:-platzhalter,
  deploy-target-stolperfalle verschärft, vr-post-pfad aktualisiert
- Multilingual-spec: status von „noch nicht implementiert" auf „umgesetzt"
  + anmerkung zum post-switcher (📖 DE | EN statt text-hinweis)
- CLAUDE.md neu: knapper einstieg für agent-sessions mit commit-konvention,
  deploy-falle, zsh-globbing, forgejo-mirror-timing
- workflow-skill generalüberholt: post-cutover-stand, multilingual,
  publish-pipeline, activeLocale, 73 pipeline-tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:31:16 +02:00
Jörg Lohrer 9040e5ac02 feat(app): sprach-switcher direkt im post (📖 DE | EN)
statt text-hinweis "auch verfügbar in: ..." zeigt der post jetzt einen
kompakten switcher (📖 aktiver-code | anderer-code). klick auf den
anderen code setzt die ui-sprache global und navigiert zur sprach-
variante — alles konsistent.

language names raus (unused): displayLanguage + tests entfernt, da die
darstellung nun nur noch sprachcodes (DE/EN) zeigt. auch i18n-keys
lang.de/lang.en und post.also_available_in aufgeräumt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:09:33 +02:00
Jörg Lohrer 238b2a0938 feat(app): impressum-seitentitel lokalisiert (inhalt bleibt DE) 2026-04-21 14:15:04 +02:00
Jörg Lohrer 259d7949dd feat(app): post-route + komponenten lokalisiert (titel, datum, hinweise) 2026-04-21 14:13:59 +02:00
Jörg Lohrer 3411af610e feat(app): archiv-seite lokalisiert + nach locale gefiltert 2026-04-21 14:08:42 +02:00
Jörg Lohrer d7510953d2 feat(app): startseite lokalisiert + liste nach aktivem locale gefiltert 2026-04-21 14:07:17 +02:00
Jörg Lohrer d256670b56 feat(app): layout-header lokalisiert + sprach-switcher eingebunden 2026-04-21 14:00:26 +02:00
Jörg Lohrer 617b3dfccc feat(app): LanguageSwitcher-komponente mit de/en-buttons
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:33:40 +02:00
Jörg Lohrer 22997138f9 feat(app): i18n-init registriert messages und syncs mit activeLocale
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:33:19 +02:00
Jörg Lohrer 8f513495e3 feat(app): activeLocale-store mit persistence + initial-detection 2026-04-21 13:32:34 +02:00
Jörg Lohrer f799223836 chore(app): svelte-i18n + ui-messages-files (de/en)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:30:39 +02:00
Jörg Lohrer 5bab73def7 docs: plan 3/3 für multilinguale SPA (svelte-i18n + listen-filter)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:29:38 +02:00
Jörg Lohrer 0fca9cbfa2 fix(app): post-route lädt reaktiv via $effect statt onMount
bei navigation zwischen slugs innerhalb der gleichen [...slug]-route
bleibt die komponente montiert — onMount feuert dann nicht mehr, und
der neue post lud erst nach manuellem reload. $effect auf dtag löst
das und rendert die neue view sofort.

race-condition-guard: currentDtag wird pro effect-lauf festgefroren;
stale responses werden verworfen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:17:41 +02:00
Jörg Lohrer bf7b52ab9b feat(content): erste englische übersetzung (bible-selfies) + bidirektionaler a-tag
- neue sprach-variante content/posts/en/bible-selfies/ verweist auf dt. original
- dt. bibel-selfies verweist zurück auf bible-selfies
- plan 2/3: npm test → npm run test:unit (existing script name)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:53:59 +02:00
Jörg Lohrer 4a06213d03 feat(app): LanguageAvailability-komponente in PostView eingebunden 2026-04-21 12:49:59 +02:00
Jörg Lohrer 7f48644dfc feat(app): loadTranslations liefert sprach-varianten eines posts 2026-04-21 12:43:52 +02:00
Jörg Lohrer 8f4125fcc9 feat(app): displayLanguage code→anzeigename 2026-04-21 12:41:29 +02:00
Jörg Lohrer ef20e13172 feat(app): parseTranslationRefs extrahiert a-tags mit marker translation 2026-04-21 12:37:08 +02:00
Jörg Lohrer c28a64ed49 docs: plan 2/3 für multilinguale SPA (a-tag-resolving + sprachhinweis)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 12:32:13 +02:00
Jörg Lohrer b9eb2c0bab test: re-trigger action nach contentRoot-pfad-fix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:18:51 +02:00
Jörg Lohrer ccbfc61a7c fix(publish): changedPostDirs gibt pfade mit original-contentRoot zurück 2026-04-21 10:18:20 +02:00
Jörg Lohrer 6055a8c1cc test: trigger action-smoketest nach contentRoot-fix
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 10:12:22 +02:00
Jörg Lohrer b89442bf5c fix(publish): changedPostDirs normalisiert ../-präfix im contentRoot 2026-04-21 10:10:01 +02:00
Jörg Lohrer 367af9df9f test: trigger github-action nach struktur-migration
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:59:14 +02:00
Jörg Lohrer 00c4efb69a chore: posts nach content/posts/de/ migriert, lang+a-tag-platzhalter ergänzt 2026-04-21 09:51:22 +02:00
Jörg Lohrer c93befa925 feat(publish): buildKind30023 übernimmt a-tags aus frontmatter 2026-04-21 09:22:12 +02:00
Jörg Lohrer 1b0872a93f feat(publish): validatePost prüft a-tag-format 2026-04-21 09:19:35 +02:00
Jörg Lohrer 4986eae592 feat(publish): Frontmatter unterstützt a-tag-liste 2026-04-21 09:16:26 +02:00
Jörg Lohrer 66ff33e34a docs: plan 1/3 für multilinguale posts + spec-korrektur (26 statt 8 posts)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:14:43 +02:00
Jörg Lohrer f977516552 test(publish): entferne toten index-mkdir im allPostDirs-fixture
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:14:16 +02:00
Jörg Lohrer 0c2e99dfeb feat(publish): allPostDirs traversiert sprach-ebene 2026-04-21 09:10:27 +02:00
Jörg Lohrer d3215fa760 feat(publish): filterPostDirs traversiert sprach-ebene 2026-04-21 09:06:43 +02:00
Jörg Lohrer 695f5e8e69 test: filterPostDirs für sprach-ebene (failing) 2026-04-21 09:05:53 +02:00
Jörg Lohrer 2b82994314 docs: design für multilinguale posts (content/posts/<lang>, l-/a-tags)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 08:48:29 +02:00
Jörg Lohrer 09fd7cb924 docs: status/handoff nach reimport aktualisiert
- Option A (repo/nostr-konflikt) + Option D (delete-subcommand) erledigt
- 26 statt 18 events, alltags-workflow explizit dokumentiert
- NIP-32 lang-tag als grundlage für spätere mehrsprachigkeit
- Slug-Hygiene-Stolperfalle ergänzt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 18:10:27 +02:00
Jörg Lohrer 7186c32067 feat: nostr-reimport von 8 client-posts + delete-subcommand + nip-32 lang-tag
8 neue Posts aus direkt-auf-Nostr-erstellten Events (Habla/Yakihonne) ins
Repo geholt, mit sauberen ASCII-slugs statt der kaputten d-tags (Umlaute,
Emojis, Doppelpunkte, Trailing-Dashes). Alte Events per NIP-09 geloescht.

Pipeline-Erweiterungen:
- neuer subcommand "delete" publisht NIP-09 kind:5 events via stabilem
  bunker-signer (nutzt CLIENT_SECRET_HEX-identitaet, keine re-pairings).
- frontmatter.lang + kind:30023 event tagt jetzt NIP-32 konform mit
  ["L","ISO-639-1"] + ["l","de","ISO-639-1"] (default: de).
- validate-post deno-task bekommt --allow-env (yaml-parser brauchts).

Vorbereitung fuer spaetere Mehrsprachigkeit: EN-Versionen koennen via
separate markdown-datei mit lang:en als eigenes event publiziert und
spaeter per a-tag-referenz zum DE-pendant verlinkt werden.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:50:37 +02:00
Jörg Lohrer 40785df346 docs: cutover dokumentiert, option A (repo/nostr-konflikt) priorisiert
- README: aktueller Live-Stand (SPA auf joerg-lohrer.de), CC0-Lizenz
- STATUS: Cutover 2026-04-18 erfasst, Staging/Prod beide auf joerglohrer26/
- HANDOFF: Option A als priorisierter nächster Schritt — 9 verwaiste
  Nostr-Events mit problematischen d-tags (Emojis, Doppelpunkte, Umlaute,
  leerer d-tag) brauchen Markdown-Reimport + saubere Slugs + NIP-09-Cleanup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:27:53 +02:00
Jörg Lohrer 54eb0b62cb feat: cc0-badge im footer + impressum auf cc0 umstellen
Footer zeigt jetzt CC0-Badge (Heart+Zero inline SVG, monochrom via
currentColor) statt "© Jörg Lohrer". Impressum entsprechend von
CC BY-SA auf CC0 umgestellt, mit freundlichem Hinweis, dass
Namensnennung erwünscht, aber nicht rechtlich erforderlich ist.

"Nostr-basiert" im Footer jetzt Link zum GitHub-Repo (Making-of).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:19:21 +02:00
Jörg Lohrer 10e455a078 spa: startseite + archiv + impressum + menü + assets für cutover
Startseite (+page.svelte) komplett überarbeitet:
  - Hero mit lokalem Profilbild (WebP aus static/, schneller als
    kind:0-roundtrip), Begrüßung "Hi Willkommen auf meinem Blog 🤗",
    About/Website aus kind:0
  - Social-Icons-Leiste (Nostr/Mastodon/Bluesky/LinkedIn/ORCID/Mail)
    als inline-SVG, monochrom via currentColor, hover färbt blau
  - Nostr-Icon von satscoffee/nostr_icons (outline, CC0), die anderen
    stilisiert als vereinfachte Brand-Icons
  - Neueste 5 Posts + Archiv-Link

Archiv-Route (/archiv/): alle Posts, nach Jahr gruppiert.

Impressum (/impressum/): static-page, rendert content/impressum.md
(via vite ?raw-import), bleibt aus nostr-feeds draußen. Frontmatter-
parser toleriert trailing-spaces auf --- zeilen.

Menü im Layout: sticky header mit brand + 3 links (Home, Archiv,
Impressum), aktiv-state via akzent-farbe. Footer mit © + Impressum
+ "Nostr-basiert"-hinweis.

Assets: profilbild und favicons aus dem hugo-static (repo-root) nach
app/static/ übernommen, favicon-links in app.html ergänzt.

NIP-05: .well-known/nostr.json in app/static angelegt mit CORS-header
via .htaccess, damit "joerglohrer@joerg-lohrer.de" nach cutover
verifizierbar bleibt.

E2E-Tests angepasst an neue hero/navigation-struktur, 29/29 unit + 4/4
e2e grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 15:35:05 +02:00
Jörg Lohrer 3f8d3e7592 docs: handoff/status aktualisiert — delete-event erledigt, design-todos offen
- nip-09-delete-event für duplikat 1744905463975 festgehalten
- open-todos ergänzt: kind:5-filter in spa, repo/nostr-konflikt-mgmt,
  delete-subcommand in pipeline
- option 3 (menü+impressum+startseite) als design-todo klargestellt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 14:30:37 +02:00
Jörg Lohrer 34c62cb944 spa/deploy: dynamische site-url via __SITE_URL__-platzhalter, staging + prod als deploy-targets
app.html nutzt __SITE_URL__ als platzhalter in og:url und canonical.
deploy-svelte.sh ersetzt ihn nach dem build pro ziel via sed:
  - svelte  → https://svelte.joerg-lohrer.de  (default, bisheriger SVELTE_FTP_-pfad)
  - staging → https://staging.joerg-lohrer.de (STAGING_FTP_-pfad, webroot joerglohrer26)
  - prod    → https://joerg-lohrer.de        (STAGING_FTP_-pfad, cutover-ziel)

env-auslese aus .env.local nicht mehr via `source` (bricht bei
sonderzeichen im passwort), sondern via awk pro schlüssel. build wird
jetzt vom deploy-skript angestoßen, damit immer gegen den frischen
html-stand gebaut wird.

app/.env.example dokumentiert PUBLIC_SITE_URL (derzeit ungenutzt, da
der platzhalter-ansatz zuverlässiger ist als runtime-env für prerender).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 10:01:08 +02:00
Jörg Lohrer 75ad8b87fa docs: alle 24 tasks der publish-pipeline abgeschlossen
spa → main gemergt, github-actions-workflow manuell verifiziert
(run #1: signer ok, outbox ok, blossom-liste ok, mode=diff posts=0).

der 24-task-plan aus docs/superpowers/plans/2026-04-16-publish-pipeline.md
ist offiziell durch. pipeline läuft sowohl lokal als auch in ci,
auto-trigger bei content-push ist konfiguriert (aber noch nicht real
ausgelöst worden — beiläufig mitzunehmen, wenn mal ein post editiert
wird).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 07:44:00 +02:00
Jörg Lohrer b2cbbb6390 docs: ci-setup-guide + status/handoff für ci-phase aktualisiert
docs/github-ci-setup.md dokumentiert:
  - forgejo → github push-mirror
  - die 4 github-repository-secrets (bunker-url, author-pubkey-hex,
    bootstrap-relay, client-secret-hex) — letzteres identisch mit
    .env.local für stabile amber-app-identität
  - wie man sie rotiert
  - migrations-pfad weg von github (woodpecker, cron)

status + handoff reflektieren: pipeline live, alle 18 posts publiziert,
91 bilder auf blossom, ci-setup steht, cutover als nächster schritt
möglich.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 07:27:48 +02:00
Jörg Lohrer 2f7f991bc6 publish(task 22): github-actions-workflow für auto-publish
.github/workflows/publish.yml triggert bei:
  - push auf main mit änderungen in content/posts/**
  - manuellem workflow_dispatch (optional mit force_all=true)

ablauf:
  1. deno 2.x setup
  2. pre-flight check (bunker, kind:10002, kind:10063)
  3. publish (diff-modus per default, force-all bei manuellem trigger)
  4. log-artefakt (publish-*.json, 30 tage retention)

benötigt 4 repo-secrets im github-ui:
  - BUNKER_URL
  - AUTHOR_PUBKEY_HEX
  - BOOTSTRAP_RELAY
  - CLIENT_SECRET_HEX (stabile client-identität für amber-permissions)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 06:52:08 +02:00
Jörg Lohrer 0c6fdd15c3 publish(task 21): date-string-coercion + force-all migration erfolgreich
validatePost akzeptiert jetzt auch string-dates im YYYY-MM-DD- oder
ISO-8601-format und coerced sie in ein Date-objekt in-place. vorher
schlug die validation für 13 von 18 altposts fehl, weil deren yaml
`date: "2023-02-26"` quoted war (hugo-konvention) und der yaml-parser
strings statt Date-instanzen liefert.

migration durchgelaufen (log in docs/publish-logs/): 18/18 success,
91 bilder auf beiden blossom-servern, 5 write-relays — bis auf
relay.damus.io, der bei 6 posts nicht auf OK antwortet (üblich bei
damus, rate-limiting). alle 7 multi-relay-posts haben weiter mindestens
4 acks (über MIN_RELAY_ACKS=2).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 06:48:27 +02:00
Jörg Lohrer db61149924 publish(tasks 19+20): signer-stabilisierung für wiederholte runs
probleme auf der realen amber-infrastruktur behoben:

1. ohne festen CLIENT_SECRET_HEX erzeugt applesauce bei jedem lauf einen
   neuen client-pubkey. amber bindet permissions pro client-pubkey, also
   sah jeder lauf wie eine neue unberechtigte app aus und bekam
   "no permission" als auto-antwort.
   → CLIENT_SECRET_HEX in config + cli, SimpleSigner.fromKey durchgereicht.

2. applesauce wirft bei "already connected"/"no permission" unhandled
   rejections, weil response-promises asynchron reagieren.
   → globaler unhandledrejection-handler, der diese benannten fehler
   schluckt; connect() im try/catch mit open+force als fallback.

3. timeout auf bunker connect auf 60s erhöht (amber-pairing kann
   menschliches tap dauern beim ersten mal).

einzel-post-publish live verifiziert:
- offenheit-das-wesentliche als kind:30023 publiziert
- alle 5 write-relays haben bestätigt
- bild auf beide blossom-server hochgeladen
- SPA rendert das bild von blossom.edufeed.org

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 06:42:28 +02:00
Jörg Lohrer 18d9dad56e publish(task 18): cli-entrypoint mit subcommand-dispatch
src/cli.ts dispatcht via @std/cli/parse-args:
  - publish [--force-all | --post <slug> | --dry-run]
  - check
  - validate-post <path>

cmdPublish orchestriert:
  1. config laden, signer initialisieren, outbox + blossom-server laden
  2. post-dirs resolven (diff/force-all/single per slug)
  3. dry-run → liste printen, exit 0
  4. für jeden post: processPost aufrufen, logger aktualisieren
  5. am ende: logs/publish-<iso>.json, exit-code je nach fehlern

resolvePostDirs schaltet zwischen den drei modi um und findet bei
--post <slug> den passenden ordner über allPostDirs + findBySlug.

smoke-tests aus dem plan (usage → exit 2, validate-post → ✓) gehen
durch. alle 57 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:45:54 +02:00
Jörg Lohrer 4d9af00a97 publish(task 17): validate-post-subcommand
validatePostFile(path) liest post-datei, parst frontmatter, ruft
validatePost aus core/validation. liefert { ok, slug?, error? }.
offline-befehl ohne netz-zugriff. 3 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:40:29 +02:00
Jörg Lohrer f31e586e85 publish(task 16): check-subcommand (pre-flight-validation)
runCheck(config) prüft vor jedem publish-run:
  - bunker-signer erreichbar und pubkey matcht AUTHOR_PUBKEY_HEX
  - kind:10002 outbox-liste enthält mindestens 1 write-relay
  - kind:10063 blossom-liste nicht leer, alle server antworten auf
    HEAD / (405 wird toleriert — blossom-server erlauben oft kein HEAD)

liefert { ok, issues }. printCheckResult() druckt ✓ oder ✗ + liste.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:39:51 +02:00
Jörg Lohrer 68ea912fad publish(task 15): processPost — kern-pipeline pro post (tdd)
processPost(args) orchestriert pro post:
  1. frontmatter parsen + validieren
  2. draft → skipped-draft
  3. bilder sammeln + sequentiell zu blossom hochladen (mapping
     dateiname → primary-url)
  4. body mit rewriteImageUrls anpassen, coverUrl via resolveCoverUrl
  5. kind:30023 event bauen via buildKind30023
  6. checkExisting → action = new|update
  7. signieren via nip-46
  8. publishToRelays, prüfen ob minRelayAcks erreicht

alle externen abhängigkeiten (readPostFile, collectImages, upload,
sign, publish, checkExisting) via PostDeps-interface eingezogen
— einfach mockbar. fehler aller art landen als { status: failed,
error: msg }. 6 tests grün.

follow-up (nicht teil von task 15): license-tag und imeta-tags aus
images[]-frontmatter sind noch nicht im event. kommt in eigenem
folge-task, basierend auf der metadata-convention-spec.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:37:05 +02:00
Jörg Lohrer db85061287 publish(task 14): structured json logger
createLogger(opts) sammelt postSuccess/postFailed/postSkippedDraft-
events, druckt menschenlesbare zeilen (✓/✗/-), liefert am ende ein
RunLog mit allen einträgen plus start/end-timestamps. writeJson()
schreibt die komplette summary als json für archivierung/ci-artifact.
2 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:35:41 +02:00
Jörg Lohrer b6196f1052 publish(task 13): git-diff change-detection für post-ordner
filterPostDirs(lines, contentRoot) extrahiert post-verzeichnisse aus
git-diff-ausgabe (index.md-matches + asset-changes), ignoriert
_drafts/. contentRoot ist parameter (blaupausen-tauglich für nicht-
hugo-layouts). changedPostDirs(from, to, contentRoot, runner?) ruft
"git diff --name-only A..B" via dependency-injected runner. Plus
allPostDirs() für den --force-all-modus. 4 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:33:56 +02:00
Jörg Lohrer 02a955c46f publish(task 12): blossom-upload mit multi-server, bud-01 auth
uploadBlob(args) berechnet sha256, baut kind:24242-auth-event via
signer, schickt es base64-kodiert im authorization-header an PUT
/upload aller servers parallel. erfolg: report mit ok/failed-listen
und primaryUrl (erster erfolgreicher server). wirft wenn alle ablehnen.
BlossomClient via dependency-injection für tests.
TS-casts für Uint8Array→BufferSource/BodyInit (deno-strict). 3 tests
grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:32:58 +02:00
Jörg Lohrer 8eebd29266 publish(task 11): image-collector (ignoriert hugo-derivate)
collectImages(postDir) scannt ordner nach png/jpg/jpeg/gif/webp/svg,
ignoriert hugo-resize-derivate (*_hu_<hex>.*), liest bytes und gibt
sortierte ImageFile[]-liste zurück. mimeFromExt() mapped extension
auf mime-type. deno.jsonc test-task um --allow-write erweitert (Deno.
makeTempDir+writeFile in tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:31:32 +02:00
Jörg Lohrer 05ba4e4ef9 publish(task 10): nip-46 bunker-signer-wrapper mit timeout
createBunkerSigner(bunkerUrl) nutzt NostrConnectSigner.fromBunkerURI
aus applesauce-signers 2.x (früher als Nip46Signer im plan bezeichnet;
klassenname hat sich geändert). subscription- und publishMethod werden
global am class-constructor an einen shared RelayPool gekoppelt.
getPublicKey und signEvent bekommen je 30s-timeout mit sauberem
clearTimeout via withTimeout-helper. signer.ts ist vollständig, aber
ohne eigene unit-tests — integration folgt über check-subcommand
(task 16).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:30:15 +02:00
Jörg Lohrer 0a858371bf publish(task 9): blossom-server-liste-loader (kind:10063)
parseBlossomServers(ev) extrahiert "server"-tag-urls, normalisiert
trailing-slash. loadBlossomServers(bootstrapRelay, pubkey) fragt
kind:10063 via applesauce-relay. 3 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:28:44 +02:00
Jörg Lohrer 1ec48ad1a9 publish(task 8): outbox-relay-loader (kind:10002 parser + fetcher)
parseOutbox(ev) interpretiert nip-65 r-tags: ohne marker → read+write,
"read"/"write"-marker → nur jeweiliges set. ignoriert fremde tag-namen.
loadOutbox(bootstrapRelay, pubkey) fragt kind:10002-event via
applesauce-relay 2.x und parst das ergebnis. 3 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:27:47 +02:00
Jörg Lohrer ebe73cbf46 publish(task 7): relay-pool-wrapper (publish + checkExisting)
publishToRelays(urls, ev, opts) publisht signiertes event parallel zu
allen relays, mit retries + exponential backoff + timeout pro versuch.
retour: { ok: string[], failed: string[] }. default-pool via
applesauce-relay 2.x (new RelayPool()); publishFn via dependency-
injection für tests. checkExisting(slug, pubkey, urls) fragt je relay
nach kind:30023 mit #d-filter ab — true wenn irgendeiner matcht.
timer-leaks vermieden per clearTimeout in publishOne + im mock-test.
3 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:27:12 +02:00
Jörg Lohrer e4518fbf69 publish(task 6): kind:30023 event-builder mit tag-mapping
buildKind30023(args) baut unsigniertes kind:30023-event aus frontmatter
+ rewritten-body + cover-url. erzeugt pflicht-tags (d, title,
published_at) und bedingt optionale (summary aus description, image
aus coverUrl, t-tags aus tags[], client aus clientTag). plus
additionalTags-parameter für spätere task 15: license-tag und
imeta-tags (mit blossom-sha256) werden dort nach dem upload angehängt.
4 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:25:10 +02:00
Jörg Lohrer a6c5bd26e7 publish(task 5): markdown bild-url-rewriter (mapping-basiert, =WxH-strip)
rewriteImageUrls(md, mapping) ersetzt alle ![alt](filename)- und
[![alt](filename)](link)-konstrukte per dateinamen-lookup durch
blossom-urls aus dem mapping. absolute urls bleiben unverändert,
unbekannte dateinamen bleiben stehen (kein strip). =WxH-größenhinweise
werden entfernt. URL-dekodierung für leerzeichen-dateinamen (z.b.
'03-config generieren.png' im moodle-post). resolveCoverUrl()-helper
für den cover-lookup. 7 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:24:03 +02:00
Jörg Lohrer bc2679aeba publish(task 4): slug- und post-validation
validateSlug prüft regex ^[a-z0-9][a-z0-9-]*$ (lowercase, digits,
hyphen; kein führender hyphen). validatePost checkt title, slug
und date (muss Date-instanz mit gültigem timestamp sein). 7 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:23:18 +02:00
Jörg Lohrer 178016f0f4 publish(task 3): frontmatter-parser mit yaml + body-split
parseFrontmatter(md) trennt yaml-frontmatter vom markdown-body via
regex, parst yaml mit @std/yaml. Frontmatter-interface enthält
nostr-publish-konvention: optionale images-liste mit file/role/alt/
license/authors plus Author-interface (name/url/orcid). 4 tests grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:22:41 +02:00
Jörg Lohrer 1e4359aab6 publish(task 2): config-loader mit env-validation
loadConfig() liest 3 pflicht-keys (BUNKER_URL, AUTHOR_PUBKEY_HEX,
BOOTSTRAP_RELAY) und 3 optionale mit defaults (CONTENT_ROOT,
CLIENT_TAG=leer, MIN_RELAY_ACKS=2). pubkey-hex-format validiert
(64 lowercase hex), MIN_RELAY_ACKS als positive integer. 6 tests, alle
grün.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:21:39 +02:00
Jörg Lohrer 6b6502a22c publish(task 1): deno-grundgerüst (deno.jsonc, .env.example, readme)
deno 2.x projekt mit jsr/npm-imports für @std, nostr-tools, applesauce-signers,
applesauce-relay und rxjs. env-handling: primär ../.env.local (projekt-lokal),
alternativ publish/.env für fremd-repos (template in .env.example). tasks für
publish, check, validate-post, test, fmt, lint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:20:19 +02:00
Jörg Lohrer 32fe856232 docs: status + handoff für fortsetzung morgen aktualisiert
- aktueller stand: content-migration fertig, pipeline-plan geschrieben
- nächster schritt klar benannt: task 1 aus publish-pipeline-plan
- env-vorarbeiten dokumentiert (alle keys in .env.local)
- offene UNKNOWN-liste für spätere recherche

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:17:57 +02:00
Jörg Lohrer 931ef9f03f docs: publish-pipeline-vorbereitung + bild-metadaten-konvention
- spec publish-pipeline aktualisiert: blossom-only (rsync-legacy-pfad raus)
- plan publish-pipeline (24 tasks in 12 phasen), blaupausen-tauglich:
  alle projekt-konstanten via env (BUNKER_URL, AUTHOR_PUBKEY_HEX,
  BOOTSTRAP_RELAY, CONTENT_ROOT, CLIENT_TAG, MIN_RELAY_ACKS)
- spec bild-metadaten-konvention (phase 1, yaml-basiert)
- wiki-entwurf deutsch + englisch: inline-markdown-konvention zur
  bildattribution mit mapping zu nip-92 imeta-tags. konvention für
  menschen, parser passt sich an; bei ambiguität eskalation zur
  redaktion statt stillem raten
- redaktions-checkliste für bild-durchgang (abgearbeitet, bleibt
  zum abgleich bei späteren migrationen)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:14:02 +02:00
Jörg Lohrer c023b59769 content: strukturierte bild-metadaten für alle 18 posts
pro post ein images:-block im frontmatter mit file, role, alt, license,
authors — als vorbereitung für publish-pipeline (imeta-tags nip-92).
91 bilder insgesamt. fünf UNKNOWN-einträge im vr-post zur späteren
recherche markiert (wikipedia-screenshot, sketchfab-fotograf,
ready-player-me, eyemeasure-app).

bei erlebnispadagogik-post: tote amazon-hotlinks entfernt, literatur-
referenzen in saubere textliste umgebaut.

redaktionell geprüft; CC0 für eigene fotos und screenshots,
CC BY-SA 3.0 DE für saemann-midjourney-collage, CC BY 4.0 für
dezentrale-oep-oer-gemeinschaftsbeitrag, CC BY-NC-SA 3.0 für
raupen-flickr-bild, CC BY-NC 4.0 für sketchfab-kirche.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 15:13:47 +02:00
212 changed files with 15286 additions and 641 deletions

View File

@ -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 12 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: 23 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

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

@ -0,0 +1,57 @@
name: Publish Nostr Events
on:
push:
branches: [main]
paths: ['content/posts/**']
workflow_dispatch:
inputs:
force_all:
description: 'Publish all posts (--force-all)'
type: boolean
default: false
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Pre-Flight Check
working-directory: ./publish
env:
BUNKER_URL: ${{ secrets.BUNKER_URL }}
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
run: |
deno run --allow-env --allow-read --allow-net src/cli.ts check
- name: Publish
working-directory: ./publish
env:
BUNKER_URL: ${{ secrets.BUNKER_URL }}
AUTHOR_PUBKEY_HEX: ${{ secrets.AUTHOR_PUBKEY_HEX }}
BOOTSTRAP_RELAY: ${{ secrets.BOOTSTRAP_RELAY }}
CLIENT_SECRET_HEX: ${{ secrets.CLIENT_SECRET_HEX }}
GITHUB_EVENT_BEFORE: ${{ github.event.before }}
run: |
if [ "${{ github.event.inputs.force_all }}" = "true" ]; then
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish --force-all
else
deno run --allow-env --allow-read --allow-write=./logs --allow-net --allow-run=git src/cli.ts publish
fi
- uses: actions/upload-artifact@v4
if: always()
with:
name: publish-log
path: ./publish/logs/publish-*.json
retention-days: 30

105
CLAUDE.md Normal file
View File

@ -0,0 +1,105 @@
# CLAUDE.md — Einstieg für Claude-Sessions
Dieser Einstieg ist für Claude-Code-Sessions gedacht. Für den inhaltlichen
Projektstand siehe [`docs/STATUS.md`](docs/STATUS.md) und
[`docs/HANDOFF.md`](docs/HANDOFF.md).
## Was dieses Repo ist
Die persönliche Webseite [`joerg-lohrer.de`](https://joerg-lohrer.de/) als
SvelteKit-SPA, die Blog-Posts live aus Nostr-Events (NIP-23, `kind:30023`)
auf 5 Public-Relays rendert. Seit 2026-04-21 mehrsprachig (DE/EN).
## Einstiegsreihenfolge
1. Diese Datei (Agent-Konventionen, Fallstricke).
2. [`docs/STATUS.md`](docs/STATUS.md) — wo steht alles gerade.
3. [`docs/HANDOFF.md`](docs/HANDOFF.md) — Alltags-Workflow, Stolperfallen.
4. Für konkrete Aufgaben: Spec unter `docs/superpowers/specs/`, Plan unter
`docs/superpowers/plans/`.
## Sprache und Ton
- **Antworten und Commit-Messages auf Deutsch.**
- Code-Identifier auf Englisch.
- Kurz, konkret, kein Grundlagen-Tutorial. Jörg ist technisch versiert.
- Bei mehreren Wegen: 23 Varianten mit Empfehlung, nicht alles aufzählen.
## Commit-Konvention
- Conventional-Commit-Präfixe: `feat`, `fix`, `chore`, `docs`, `test`.
- Imperativ, Deutsch, Body erklärt das *Warum*.
- Co-Author immer ergänzen:
```
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
```
## Kritische Fallstricke
### 1. Deploy-Target
`scripts/deploy-svelte.sh` hat `DEPLOY_TARGET=svelte` als Default —
das zielt auf `svelte.joerg-lohrer.de`, NICHT auf die Produktion.
Für Live-Deploy auf `joerg-lohrer.de`:
```sh
DEPLOY_TARGET=prod ./scripts/deploy-svelte.sh
```
**Immer explizit setzen.** Der stumme Default-Fehler ist nur sichtbar,
wenn man die Live-Seite kontrolliert. Reproduzierbar als Memory-Entry
im Claude-Memory-System.
### 2. zsh-Globbing mit eckigen Klammern
SvelteKit-Routen wie `app/src/routes/[...slug]/+page.svelte` enthalten
eckige Klammern, die zsh als Glob-Pattern interpretiert. Pfade IMMER in
einfachen Anführungszeichen:
```sh
git add 'app/src/routes/[...slug]/+page.svelte'
```
### 3. Forgejo → GitHub Push-Mirror
`git push` landet zuerst auf Forgejo (`forgejo.joerglohrer.synology.me`).
Der Forgejo-Mirror synct dann zu GitHub (typisch 3090 s). Die GitHub-
Action (Publish-Pipeline) läuft erst nach dem Mirror. Wer direkt nach
`git push` `gh run list` aufruft, sieht evtl. noch keinen neuen Run.
### 4. Deno-Path-Konventionen
Publish-Pipeline läuft aus `publish/` (CWD), daher sind Pfade relativ
mit `../content/posts/...`. Git-Diff liefert aber repo-root-relative
Pfade (`content/posts/...`). `changedPostDirs` normalisiert beides —
wenn `posts=0` obwohl Änderungen vorliegen, ist das der Hotspot.
### 5. Publish-Pipeline erwartet `content/posts/<lang>/<slug>/`
Die Zwei-Ebenen-Struktur ist Teil der Traversierung. Wer einen Post
versehentlich in `content/posts/<slug>/` (ohne Sprach-Ordner) anlegt,
wird von der Pipeline ignoriert.
## Hauptarbeitsbereiche im Repo
| Pfad | Inhalt |
|---|---|
| `content/posts/<lang>/<slug>/index.md` | Markdown-Posts pro Sprache |
| `app/src/lib/i18n/` | UI-Lokalisierung (svelte-i18n, activeLocale-Store) |
| `app/src/lib/nostr/` | Relay-Loader, Translations-Resolving |
| `app/src/lib/components/` | Svelte-5-Runes-Komponenten |
| `app/src/routes/` | SvelteKit-Routen (Layout, Home, Archiv, Post, Impressum) |
| `publish/src/` | Deno-Publish-Pipeline (Deno-Tasks in `publish/deno.jsonc`) |
| `publish/tests/` | Deno-Tests für die Pipeline |
| `docs/superpowers/specs/` | Produktdesigns, Konventionen |
| `docs/superpowers/plans/` | Implementierungspläne (alle erledigt) |
| `scripts/deploy-svelte.sh` | FTPS-Deploy |
## Quick-Links
- [Produktspezifikation SPA](docs/superpowers/specs/2026-04-15-nostr-page-design.md)
- [Produktspezifikation Publish-Pipeline](docs/superpowers/specs/2026-04-15-publish-pipeline-design.md)
- [Bild-Metadaten-Konvention](docs/superpowers/specs/2026-04-16-image-metadata-convention.md)
- [Multilingual-Design](docs/superpowers/specs/2026-04-21-multilingual-posts-design.md)
- [Repo-Workflow-Skill](.claude/skills/joerglohrerde-workflow.md) (ausführlicher, mit Kommandos)

104
README.md
View File

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

9
app/.env.example Normal file
View File

@ -0,0 +1,9 @@
# Öffentliche Site-URL für Canonical-Link und og:url-Meta-Tags.
# Zur Build-Zeit fest; gilt domain-übergreifend (svelte./staging./haupt-
# domain). Für jeden Deploy-Zweck kann eine andere URL gesetzt werden.
#
# Beispiele:
# PUBLIC_SITE_URL=https://svelte.joerg-lohrer.de
# PUBLIC_SITE_URL=https://staging.joerg-lohrer.de
# PUBLIC_SITE_URL=https://joerg-lohrer.de
PUBLIC_SITE_URL=https://joerg-lohrer.de

View File

@ -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"
}
}

View File

@ -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 {

View File

@ -0,0 +1,47 @@
<script lang="ts">
// CC-Zero-Badge: kombination aus CC-Heart + Zero-Logo, monochrom via
// currentColor. Icons aus dem offiziellen CC-Press-Kit
// (creativecommons.org/mission/branding/). Inline hier, weil statische
// svg-imports mit ?raw in vite problematisch sind.
</script>
<span class="cc-badge" aria-hidden="true">
<!-- CC-Heart (vereinfachtes herz aus dem offiziellen logo) -->
<svg viewBox="0 0 46296 40689" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M23204.91 7530.98c2944.63,-3188.84 6384.04,-4639.01 10366.38,-4077.21 4110.34,579.88 7609.97,3518.41 8854.17,7479.01 957.39,3047.58 559.96,6460.83 -722.09,9573.35 -1993.98,4840.97 -7886.31,11722.09 -18555.24,16532.85 -10668.92,-4810.76 -16561.25,-11691.88 -18555.24,-16532.85 -1282.05,-3112.52 -1679.47,-6525.77 -722.09,-9573.35 1244.19,-3960.6 4743.83,-6899.13 8854.17,-7479.01 3982.46,-561.82 7421.94,888.46 10366.64,4077.48 5.4,5.84 56.52,61.37 56.53,61.36 0.04,0.04 51.9,-56.36 56.79,-61.63zm-56.79 -4522.44c-6431.69,-5048.01 -16512.25,-3730.83 -21147.65,3855.94 -1539.08,2519.03 -2117.14,5447.75 -1981.3,8355.45 235.64,5043.59 2412.75,9452.27 5610.61,13256.78 4306.02,5122.9 10531.26,9148.59 17382.21,12152.72 9.53,4.18 88.63,38.56 136.13,59.69 41.66,-17.53 114.6,-50.41 137.01,-60.3 6815.65,-3004.07 13075.56,-7030.12 17381.33,-12152.12 3198.08,-3804.33 5374.97,-8213.2 5610.61,-13256.78 135.85,-2907.7 -442.2,-5836.43 -1981.3,-8355.45 -4635.4,-7586.77 -14715.95,-8903.95 -21147.65,-3855.94z"
/>
<path
fill="currentColor"
d="M22983.64 21630.19l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.85,-2300.18 -2239.67,-3843.76 -87.17,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.75,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.13,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.43,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12zm13802.46 0l-2928.01 -1451.38c-1017.73,1483.99 -1758.21,2488.33 -3897.08,1902.25 -1678.91,-460.05 -2175.86,-2300.18 -2239.67,-3843.76 -87.18,-2108.39 649.94,-4543.46 3168.15,-4413.24 1609.13,83.19 2294.74,1032.23 2661.15,1885.36l3196.99 -1638.9c-1574.75,-3004.31 -5265.14,-4026.05 -8393.32,-3188.81 -3328.66,890.9 -5014.61,3952.95 -4955.5,7255.23 60.42,3375.58 1680.8,6291.51 5161.55,7052.54 1697.16,371.06 3545.13,284.81 5116.74,-503.18 1216.27,-609.83 2567.56,-1786.86 3109,-3056.12z"
/>
</svg>
<!-- CC-Zero (kreis + 0 aus dem cc-0-logo) -->
<svg viewBox="-0.5 0.5 64 64" xmlns="http://www.w3.org/2000/svg">
<path
fill="currentColor"
d="M31.5,14.08c-10.565,0-13.222,9.969-13.222,18.42c0,8.452,2.656,18.42,13.222,18.42c10.564,0,13.221-9.968,13.221-18.42C44.721,24.049,42.064,14.08,31.5,14.08z M31.5,21.026c0.429,0,0.82,0.066,1.188,0.157c0.761,0.656,1.133,1.561,0.403,2.823l-7.036,12.93c-0.216-1.636-0.247-3.24-0.247-4.437C25.808,28.777,26.066,21.026,31.5,21.026z M36.766,26.987c0.373,1.984,0.426,4.056,0.426,5.513c0,3.723-0.258,11.475-5.69,11.475c-0.428,0-0.822-0.045-1.188-0.136c-0.07-0.021-0.134-0.043-0.202-0.067c-0.112-0.032-0.23-0.068-0.336-0.11c-1.21-0.515-1.972-1.446-0.874-3.093L36.766,26.987z"
/>
<path
fill="currentColor"
d="M31.433,0.5c-8.877,0-16.359,3.09-22.454,9.3c-3.087,3.087-5.443,6.607-7.082,10.532C0.297,24.219-0.5,28.271-0.5,32.5c0,4.268,0.797,8.32,2.397,12.168c1.6,3.85,3.921,7.312,6.969,10.396c3.085,3.049,6.549,5.399,10.398,7.037c3.886,1.602,7.939,2.398,12.169,2.398c4.229,0,8.34-0.826,12.303-2.465c3.962-1.639,7.496-3.994,10.621-7.081c3.011-2.933,5.289-6.297,6.812-10.106C62.73,41,63.5,36.883,63.5,32.5c0-4.343-0.77-8.454-2.33-12.303c-1.562-3.885-3.848-7.32-6.857-10.33C48.025,3.619,40.385,0.5,31.433,0.5z M31.567,6.259c7.238,0,13.412,2.566,18.554,7.709c2.477,2.477,4.375,5.31,5.67,8.471c1.296,3.162,1.949,6.518,1.949,10.061c0,7.354-2.516,13.454-7.506,18.33c-2.592,2.516-5.502,4.447-8.74,5.781c-3.2,1.334-6.498,1.994-9.927,1.994c-3.468,0-6.788-0.653-9.949-1.948c-3.163-1.334-6.001-3.238-8.516-5.716c-2.515-2.514-4.455-5.353-5.826-8.516c-1.333-3.199-2.017-6.498-2.017-9.927c0-3.467,0.684-6.787,2.017-9.949c1.371-3.2,3.312-6.074,5.826-8.628C18.092,8.818,24.252,6.259,31.567,6.259z"
/>
</svg>
</span>
<style>
.cc-badge {
display: inline-flex;
align-items: center;
gap: 0.15em;
color: var(--accent);
vertical-align: -0.2em;
}
.cc-badge svg {
width: 1.1em;
height: 1.1em;
display: block;
}
</style>

View File

@ -0,0 +1,107 @@
<script lang="ts">
import type { NostrEvent, TranslationInfo } from '$lib/nostr/loaders';
import { loadTranslations } from '$lib/nostr/loaders';
import { activeLocale } from '$lib/i18n';
import type { SupportedLocale } from '$lib/i18n/activeLocale';
interface Props {
event: NostrEvent;
}
let { event }: Props = $props();
let translations: TranslationInfo[] = $state([]);
let loading = $state(true);
$effect(() => {
const currentId = event.id;
loading = true;
translations = [];
loadTranslations(event)
.then((infos) => {
if (event.id !== currentId) return;
translations = infos;
})
.finally(() => {
if (event.id === currentId) loading = false;
});
});
function currentLang(): string {
return event.tags.find((tag) => tag[0] === 'l')?.[1] ?? 'de';
}
interface Option {
code: string;
href: string | null; // null = aktueller post, kein klick-ziel
}
const options = $derived.by<Option[]>(() => {
const self: Option = { code: currentLang(), href: null };
const others: Option[] = translations.map((t) => ({
code: t.lang,
href: `/${t.slug}/`
}));
// aktuelle sprache zuerst, dann rest sortiert nach code
return [self, ...others.sort((a, b) => a.code.localeCompare(b.code))];
});
function selectOther(code: string, href: string) {
activeLocale.set(code as SupportedLocale);
// hartes location-setzen, damit svelte-kit-router den post-load triggert
window.location.href = href;
}
</script>
{#if !loading && translations.length > 0}
<p class="lang-switch" role="group" aria-label="Article language">
<span class="icon" aria-hidden="true">📖</span>
{#each options as opt, i}
{#if opt.href === null}
<span class="btn active" aria-current="true">{opt.code.toUpperCase()}</span>
{:else}
<button
type="button"
class="btn"
onclick={() => selectOther(opt.code, opt.href!)}
>{opt.code.toUpperCase()}</button>
{/if}
{#if i < options.length - 1}<span class="sep" aria-hidden="true">|</span>{/if}
{/each}
</p>
{/if}
<style>
.lang-switch {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.88rem;
color: var(--muted);
margin: 0.25rem 0 1rem;
}
.icon {
font-size: 1rem;
line-height: 1;
}
.btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
border-radius: 3px;
padding: 1px 7px;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.btn:hover:not(.active) {
color: var(--fg);
}
.btn.active {
color: var(--accent);
border-color: var(--accent);
cursor: default;
}
.sep {
opacity: 0.4;
}
</style>

View File

@ -0,0 +1,48 @@
<script lang="ts">
import { t, activeLocale, SUPPORTED_LOCALES } from '$lib/i18n';
import type { SupportedLocale } from '$lib/i18n/activeLocale';
let current = $state<SupportedLocale>('de');
activeLocale.subscribe((v) => (current = v));
function select(lang: SupportedLocale) {
activeLocale.set(lang);
}
</script>
<div class="switcher" role="group" aria-label={$t('lang.switch_aria')}>
{#each SUPPORTED_LOCALES as code}
<button
type="button"
class="btn"
class:active={current === code}
aria-pressed={current === code}
onclick={() => select(code)}
>{code.toUpperCase()}</button>
{/each}
</div>
<style>
.switcher {
display: inline-flex;
gap: 0.25rem;
margin-left: 0.5rem;
}
.btn {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
border-radius: 3px;
padding: 1px 7px;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
}
.btn:hover {
color: var(--fg);
}
.btn.active {
color: var(--accent);
border-color: var(--accent);
}
</style>

View File

@ -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}

View File

@ -0,0 +1,118 @@
<script lang="ts">
// Inline-SVGs als kleine social-icon-leiste. bewusst keine zusätzlichen
// image-requests, kein icon-font. hover-color via currentColor + fill=currentColor.
const AUTHOR_PUBKEY_HEX =
'4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41';
type Entry = { label: string; href: string; icon: 'nostr' | 'mastodon' | 'linkedin' | 'bluesky' | 'mail' | 'orcid' };
const entries: Entry[] = [
{ label: 'Nostr', href: `https://edufeed.org/p/${AUTHOR_PUBKEY_HEX}`, icon: 'nostr' },
{ label: 'Mastodon', href: 'https://reliverse.social/@joerglohrer', icon: 'mastodon' },
{ label: 'Bluesky', href: 'https://bsky.app/profile/joerglohrer.bsky.social', icon: 'bluesky' },
{ label: 'LinkedIn', href: 'https://www.linkedin.com/in/joerglohrer', icon: 'linkedin' },
{ label: 'ORCID', href: 'https://orcid.org/0000-0002-9282-0406', icon: 'orcid' },
{ label: 'E-Mail', href: 'mailto:webmaster@joerg-lohrer.de', icon: 'mail' }
];
</script>
<nav class="social" aria-label="Soziale Profile und Kontakt">
{#each entries as e (e.href)}
<a
href={e.href}
target={e.icon === 'mail' ? undefined : '_blank'}
rel={e.icon === 'mail' ? undefined : 'me noopener'}
aria-label={e.label}
title={e.label}
>
{#if e.icon === 'nostr'}
<!-- Nostr-Logo (Outline) von satscoffee/nostr_icons, CC0 -->
<svg
viewBox="0 0 875 875"
aria-hidden="true"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="40"
stroke-miterlimit="10"
>
<path
d="m684.72,485.57c.22,12.59-11.93,51.47-38.67,81.3-26.74,29.83-56.02,20.85-58.42,20.16s-3.09-4.46-7.89-3.77-9.6,6.17-18.86,7.2-17.49,1.71-26.06-1.37c-4.46.69-5.14.71-7.2,2.24s-17.83,10.79-21.6,11.47c0,7.2-1.37,44.57,0,55.89s3.77,25.71,7.54,36c3.77,10.29,2.74,10.63,7.54,9.94s13.37.34,15.77,4.11c2.4,3.77,1.37,6.51,5.49,8.23s60.69,17.14,99.43,19.2c26.74.69,42.86,2.74,52.12,19.54,1.37,7.89,7.54,13.03,11.31,14.06s8.23,2.06,12,5.83,1.03,8.23,5.49,11.66c4.46,3.43,14.74,8.57,25.37,13.71,10.63,5.14,15.09,13.37,15.77,16.11s1.71,10.97,1.71,10.97c0,0-8.91,0-10.97-2.06s-2.74-5.83-2.74-5.83c0,0-6.17,1.03-7.54,3.43s.69,2.74-7.89.69-11.66-3.77-18.17-8.57c-6.51-4.8-16.46-17.14-25.03-16.8,4.11,8.23,5.83,8.23,10.63,10.97s8.23,5.83,8.23,5.83l-7.2,4.46s-4.46,2.06-14.74-.69-11.66-4.46-12.69-10.63,0-9.26-2.74-14.4-4.11-15.77-22.29-21.26c-18.17-5.49-66.52-21.26-100.12-24.69s-22.63-2.74-28.11-1.37-15.77,4.46-26.4-1.37c-10.63-5.83-16.8-13.71-17.49-20.23s-1.71-10.97,0-19.2,3.43-19.89,1.71-26.74-14.06-55.89-19.89-64.12c-13.03,1.03-50.74-.69-50.74-.69,0,0-2.4-.69-17.49,5.83s-36.48,13.76-46.77,19.93-14.4,9.7-16.12,13.13c.12,3-1.23,7.72-2.79,9.06s-12.48,2.42-12.48,2.42c0,0-5.85,5.86-8.25,9.97-6.86,9.6-55.2,125.14-66.52,149.83-13.54,32.57-9.77,27.43-37.71,27.43s-8.06.3-8.06.3c0,0-12.34,5.88-16.8,5.88s-18.86-2.4-26.4,0-16.46,9.26-23.31,10.29-4.95-1.34-8.38-3.74c-4-.21-14.27-.12-14.27-.12,0,0,1.74-6.51,7.91-10.88,8.23-5.83,25.37-16.11,34.63-21.26s17.49-7.89,23.31-9.26,18.51-6.17,30.51-9.94,19.54-8.23,29.83-31.54c10.29-23.31,50.4-111.43,51.43-116.23.63-2.96,3.73-6.48,4.8-15.09.66-5.35-2.49-13.04,1.71-22.63,10.97-25.03,21.6-20.23,26.4-20.23s17.14.34,26.4-1.37,15.43-2.74,24.69-7.89,11.31-8.91,11.31-8.91l-19.89-3.43s-18.51.69-25.03-4.46-15.43-15.77-15.43-15.77l-7.54-7.2,1.03,8.57s-5.14-8.91-6.51-10.29-8.57-6.51-11.31-11.31-7.54-25.03-7.54-25.03l-6.17,13.03-1.71-18.86-5.14,7.2-2.74-16.11-4.8,8.23-3.43-14.4-5.83,4.46-2.4-10.29-5.83-3.43s-14.06-9.26-16.46-9.6-4.46,3.43-4.46,3.43l1.37,12-12.2-6.27-7-11.9s2.36,4.01-9.62,7.53c-20.55,0-21.89-2.28-24.93-3.94-1.31-6.56-5.57-10.11-5.57-10.11h-20.57l-.34-6.86-7.89,3.09.69-10.29h-14.06l1.03-11.31h-8.91s3.09-9.26,25.71-22.97,25.03-16.46,46.29-17.14c21.26-.69,32.91,2.74,46.29,8.23s38.74,13.71,43.89,17.49c11.31-9.94,28.46-19.89,34.29-19.89,1.03-2.4,6.19-12.33,17.96-17.6,35.31-15.81,108.13-34,131.53-35.54,31.2-2.06,7.89-1.37,39.09,2.06,31.2,3.43,54.17,7.54,69.6,12.69,12.58,4.19,25.03,9.6,34.29,2.06,4.33-1.81,11.81-1.34,17.83-5.14,30.69-25.09,34.72-32.35,43.63-41.95s20.14-24.91,22.54-45.14,4.46-58.29-10.63-88.12-28.8-45.26-34.63-69.26c-5.83-24-8.23-61.03-6.17-73.03,2.06-12,5.14-22.29,6.86-30.51s9.94-14.74,19.89-16.46c9.94-1.71,17.83,1.37,22.29,4.8,4.46,3.43,11.65,6.28,13.37,10.29.34,1.71-1.37,6.51,8.23,8.23,9.6,1.71,16.05,4.16,16.05,4.16,0,0,15.64,4.29,3.11,7.73-12.69,2.06-20.52-.71-24.29,1.69s-7.21,10.08-9.61,11.1-7.2.34-12,4.11-9.6,6.86-12.69,14.4-5.49,15.77-3.43,26.74,8.57,31.54,14.4,43.2c5.83,11.66,20.23,40.8,24.34,47.66s15.77,29.49,16.8,53.83,1.03,44.23,0,54.86-10.84,51.65-35.53,85.94c-8.16,14.14-23.21,31.9-24.67,35.03-1.45,3.13-3.02,4.88-1.61,7.65,4.62,9.05,12.87,22.13,14.71,29.22,2.29,6.64,6.99,16.13,7.22,28.72Z"
/>
</svg>
{:else if e.icon === 'mastodon'}
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
<path
fill="currentColor"
d="M21.58 13.9c-.3 1.53-2.67 3.22-5.4 3.54-1.42.17-2.82.33-4.32.26-2.45-.11-4.39-.58-4.39-.58 0 .24.01.47.05.68.32 2.4 2.39 2.55 4.34 2.61 1.97.07 3.74-.49 3.74-.49l.08 1.78s-1.39.75-3.87.88c-1.37.08-3.07-.03-5.05-.56C2.46 20.86 1.72 16.2 1.61 11.47 1.57 10.07 1.59 8.76 1.59 7.66c0-4.85 3.18-6.27 3.18-6.27 1.6-.73 4.35-1.04 7.21-1.07h.07c2.86.02 5.61.33 7.22 1.07 0 0 3.18 1.42 3.18 6.27 0 0 .04 3.59-.45 6.08-.03.16-.18.16-.42.16zm-3.01-5.53c0-1.18-.3-2.11-.9-2.81-.62-.7-1.43-1.05-2.44-1.05-1.17 0-2.06.45-2.65 1.35l-.58.97-.58-.97c-.59-.9-1.48-1.35-2.65-1.35-1.01 0-1.82.36-2.44 1.05-.6.7-.9 1.63-.9 2.81v5.78h2.3V8.54c0-1.18.5-1.78 1.5-1.78 1.1 0 1.65.71 1.65 2.13v3.09h2.28V8.88c0-1.41.55-2.13 1.65-2.13 1 0 1.5.6 1.5 1.78v5.61h2.3V8.37z"
/>
</svg>
{:else if e.icon === 'bluesky'}
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
<path
fill="currentColor"
d="M5.2 3.5c2.4 1.8 5 5.4 5.9 7.4.9-2 3.5-5.6 5.9-7.4 1.8-1.3 4.7-2.3 4.7 1 0 .7-.4 5.6-.6 6.4-.8 2.8-3.6 3.5-6.1 3.1 4.4.7 5.5 3.2 3.1 5.7-4.6 4.8-6.6-1.2-7.1-2.7-.1-.3-.1-.4-.1-.3 0-.1-.1 0-.1.3-.5 1.5-2.5 7.5-7.1 2.7-2.5-2.5-1.3-5 3.1-5.7-2.5.4-5.3-.3-6.1-3.1C-.1 10.1-.5 5.2-.5 4.5c0-3.3 2.9-2.3 4.7-1l1 0z"
/>
</svg>
{:else if e.icon === 'linkedin'}
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
<path
fill="currentColor"
d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14zM8.34 18.34v-8.4H5.67v8.4h2.67zM7 8.67a1.55 1.55 0 1 0 0-3.1 1.55 1.55 0 0 0 0 3.1zm11.34 9.67v-4.6c0-2.46-1.31-3.6-3.06-3.6-1.41 0-2.04.78-2.39 1.32v-1.13h-2.67c.04.75 0 8.01 0 8.01h2.67v-4.47c0-.24.02-.48.09-.65.19-.48.63-.97 1.37-.97.97 0 1.36.74 1.36 1.82v4.27h2.63z"
/>
</svg>
{:else if e.icon === 'orcid'}
<!-- offizielles ORCID-symbol stark vereinfacht, monochrom via currentColor -->
<svg viewBox="0 0 256 256" aria-hidden="true" width="20" height="20">
<circle cx="128" cy="128" r="128" fill="currentColor" opacity="0.15" />
<circle cx="128" cy="128" r="118" fill="none" stroke="currentColor" stroke-width="10" />
<path
fill="currentColor"
d="M86 76a8 8 0 1 1-16 0 8 8 0 0 1 16 0zM72 96h14v96H72V96zm34 0h38c28 0 50 21 50 48s-22 48-50 48h-38V96zm14 14v68h22c22 0 36-14 36-34s-14-34-36-34h-22z"
/>
</svg>
{:else if e.icon === 'mail'}
<svg viewBox="0 0 24 24" aria-hidden="true" width="20" height="20">
<path
fill="currentColor"
d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm0 2v.01L12 13l8-6.99V6H4zm0 2.7V18h16V8.7l-8 6.99L4 8.7z"
/>
</svg>
{/if}
</a>
{/each}
</nav>
<style>
.social {
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
margin-top: 0.6rem;
}
.social a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--code-bg);
color: var(--muted);
transition:
color 140ms,
background 140ms,
transform 140ms;
}
.social a:hover,
.social a:focus-visible {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, var(--code-bg));
transform: translateY(-1px);
}
.social svg {
display: block;
}
</style>

View File

@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { detectInitialLocale } from './activeLocale';
describe('detectInitialLocale', () => {
beforeEach(() => {
globalThis.localStorage?.clear?.();
});
it('nimmt wert aus localStorage, wenn vorhanden und gültig', () => {
const storage = new Map<string, string>([['locale', 'en']]);
expect(detectInitialLocale({
storage: {
getItem: (k) => storage.get(k) ?? null,
setItem: () => {}
},
navigatorLanguage: 'de-DE',
supported: ['de', 'en']
})).toBe('en');
});
it('fällt auf navigator.language zurück, wenn storage leer', () => {
expect(detectInitialLocale({
storage: {
getItem: () => null,
setItem: () => {}
},
navigatorLanguage: 'en-US',
supported: ['de', 'en']
})).toBe('en');
});
it('normalisiert navigator.language (de-AT → de)', () => {
expect(detectInitialLocale({
storage: {
getItem: () => null,
setItem: () => {}
},
navigatorLanguage: 'de-AT',
supported: ['de', 'en']
})).toBe('de');
});
it('fällt auf ersten supported eintrag, wenn navigator unbekannt', () => {
expect(detectInitialLocale({
storage: {
getItem: () => null,
setItem: () => {}
},
navigatorLanguage: 'fr-FR',
supported: ['de', 'en']
})).toBe('de');
});
it('ignoriert ungültige werte im storage', () => {
const storage = new Map<string, string>([['locale', 'fr']]);
expect(detectInitialLocale({
storage: {
getItem: (k) => storage.get(k) ?? null,
setItem: () => {}
},
navigatorLanguage: 'en-US',
supported: ['de', 'en']
})).toBe('en');
});
});

View File

@ -0,0 +1,61 @@
import { writable, type Writable } from 'svelte/store';
export type SupportedLocale = 'de' | 'en';
export const SUPPORTED_LOCALES: readonly SupportedLocale[] = ['de', 'en'] as const;
const STORAGE_KEY = 'locale';
interface Storage {
getItem: (key: string) => string | null;
setItem: (key: string, value: string) => void;
}
export interface DetectArgs {
storage: Storage;
navigatorLanguage: string | undefined;
supported: readonly string[];
}
export function detectInitialLocale(args: DetectArgs): SupportedLocale {
const stored = args.storage.getItem(STORAGE_KEY);
if (stored && (args.supported as readonly string[]).includes(stored)) {
return stored as SupportedLocale;
}
const nav = (args.navigatorLanguage ?? '').slice(0, 2).toLowerCase();
if ((args.supported as readonly string[]).includes(nav)) {
return nav as SupportedLocale;
}
return args.supported[0] as SupportedLocale;
}
function createActiveLocale(): Writable<SupportedLocale> & { bootstrap: () => void } {
const store = writable<SupportedLocale>('de');
let bootstrapped = false;
function bootstrap() {
if (bootstrapped) return;
bootstrapped = true;
if (typeof window === 'undefined') return;
const initial = detectInitialLocale({
storage: window.localStorage,
navigatorLanguage: window.navigator.language,
supported: SUPPORTED_LOCALES
});
store.set(initial);
store.subscribe((v) => {
try {
window.localStorage.setItem(STORAGE_KEY, v);
} catch {
// private-mode / quota — ignorieren
}
});
}
return {
subscribe: store.subscribe,
set: store.set,
update: store.update,
bootstrap
};
}
export const activeLocale = createActiveLocale();

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

@ -0,0 +1,23 @@
import { addMessages, init, locale, _ } from 'svelte-i18n';
import de from './messages/de.json';
import en from './messages/en.json';
import { activeLocale, SUPPORTED_LOCALES } from './activeLocale';
let initialized = false;
export function initI18n(): void {
if (initialized) return;
initialized = true;
addMessages('de', de);
addMessages('en', en);
init({
fallbackLocale: 'de',
initialLocale: 'de'
});
activeLocale.bootstrap();
activeLocale.subscribe((l) => {
locale.set(l);
});
}
export { _ as t, locale, activeLocale, SUPPORTED_LOCALES };

View File

@ -0,0 +1,32 @@
{
"nav": {
"home": "Home",
"archive": "Archiv",
"imprint": "Impressum",
"brand_aria": "Zur Startseite"
},
"home": {
"greeting": "Hi 🖖 Willkommen auf meinem Blog 🤗",
"latest": "Neueste Beiträge",
"more_archive": "Alle Beiträge im Archiv →",
"empty": "Keine Posts gefunden auf den abgefragten Relays."
},
"archive": {
"title": "Archiv",
"subtitle": "Alle Beiträge, nach Jahr gruppiert.",
"doc_title": "Archiv Jörg Lohrer"
},
"post": {
"back_to_overview": "← Zurück zur Übersicht",
"untitled": "(ohne Titel)",
"published_on": "Veröffentlicht am {date}",
"not_found": "Post \"{slug}\" nicht gefunden.",
"unknown_error": "Unbekannter Fehler"
},
"imprint": {
"doc_title": "Impressum Jörg Lohrer"
},
"lang": {
"switch_aria": "Sprache wechseln"
}
}

View File

@ -0,0 +1,32 @@
{
"nav": {
"home": "Home",
"archive": "Archive",
"imprint": "Imprint",
"brand_aria": "Go to homepage"
},
"home": {
"greeting": "Hi 🖖 Welcome to my blog 🤗",
"latest": "Latest posts",
"more_archive": "All posts in the archive →",
"empty": "No posts found on the queried relays."
},
"archive": {
"title": "Archive",
"subtitle": "All posts, grouped by year.",
"doc_title": "Archive Jörg Lohrer"
},
"post": {
"back_to_overview": "← Back to overview",
"untitled": "(untitled)",
"published_on": "Published on {date}",
"not_found": "Post \"{slug}\" not found.",
"unknown_error": "Unknown error"
},
"imprint": {
"doc_title": "Imprint Jörg Lohrer"
},
"lang": {
"switch_aria": "Switch language"
}
}

View File

@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { resolveTranslationsFromRefs } from './loaders';
import type { NostrEvent } from './loaders';
import type { TranslationRef } from './translations';
function ev(tags: string[][]): NostrEvent {
return {
id: 'x',
pubkey: 'p',
created_at: 0,
kind: 30023,
tags,
content: '',
sig: 's'
} as unknown as NostrEvent;
}
describe('resolveTranslationsFromRefs', () => {
it('liefert lang/slug/title für jeden aufgelösten ref', async () => {
const refs: TranslationRef[] = [
{ kind: 30023, pubkey: 'p1', dtag: 'hello' }
];
const fetcher = async () => [
ev([
['d', 'hello'],
['title', 'Hello World'],
['L', 'ISO-639-1'],
['l', 'en', 'ISO-639-1']
])
];
const result = await resolveTranslationsFromRefs(refs, fetcher);
expect(result).toEqual([
{ lang: 'en', slug: 'hello', title: 'Hello World' }
]);
});
it('ignoriert refs, zu denen kein event gefunden wird', async () => {
const refs: TranslationRef[] = [
{ kind: 30023, pubkey: 'p1', dtag: 'hello' },
{ kind: 30023, pubkey: 'p1', dtag: 'missing' }
];
const fetcher = async (r: TranslationRef) =>
r.dtag === 'hello'
? [ev([
['d', 'hello'],
['title', 'Hi'],
['l', 'en', 'ISO-639-1']
])]
: [];
const result = await resolveTranslationsFromRefs(refs, fetcher);
expect(result).toEqual([{ lang: 'en', slug: 'hello', title: 'Hi' }]);
});
it('ignoriert events ohne l-tag (sprache unklar)', async () => {
const refs: TranslationRef[] = [
{ kind: 30023, pubkey: 'p', dtag: 'x' }
];
const fetcher = async () => [
ev([
['d', 'x'],
['title', 'kein lang-tag']
])
];
const result = await resolveTranslationsFromRefs(refs, fetcher);
expect(result).toEqual([]);
});
it('leere ref-liste → leere ergebnis-liste', async () => {
const fetcher = async () => {
throw new Error('should not be called');
};
expect(await resolveTranslationsFromRefs([], fetcher)).toEqual([]);
});
});

View File

@ -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
})
);
}

View File

@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { parseTranslationRefs } from './translations';
import type { NostrEvent } from './loaders';
function ev(tags: string[][]): NostrEvent {
return {
id: 'x',
pubkey: 'p',
created_at: 0,
kind: 30023,
tags,
content: '',
sig: 's'
} as unknown as NostrEvent;
}
describe('parseTranslationRefs', () => {
it('extrahiert a-tags mit marker "translation"', () => {
const e = ev([
['d', 'x'],
['a', '30023:abc:other-slug', '', 'translation'],
['a', '30023:abc:third-slug', '', 'translation']
]);
expect(parseTranslationRefs(e)).toEqual([
{ kind: 30023, pubkey: 'abc', dtag: 'other-slug' },
{ kind: 30023, pubkey: 'abc', dtag: 'third-slug' }
]);
});
it('ignoriert a-tags ohne marker "translation"', () => {
const e = ev([
['a', '30023:abc:root-thread', '', 'root'],
['a', '30023:abc:x', '', 'reply']
]);
expect(parseTranslationRefs(e)).toEqual([]);
});
it('ignoriert a-tags mit malformed coordinate', () => {
const e = ev([
['a', 'not-a-coord', '', 'translation'],
['a', '30023:abc:ok', '', 'translation']
]);
expect(parseTranslationRefs(e)).toEqual([
{ kind: 30023, pubkey: 'abc', dtag: 'ok' }
]);
});
it('leeres tag-array → leere liste', () => {
expect(parseTranslationRefs(ev([]))).toEqual([]);
});
});

View File

@ -0,0 +1,27 @@
import type { NostrEvent } from './loaders';
export interface TranslationRef {
kind: number;
pubkey: string;
dtag: string;
}
const COORD_RE = /^(\d+):([0-9a-f]+):([a-z0-9][a-z0-9-]*)$/;
export function parseTranslationRefs(event: NostrEvent): TranslationRef[] {
const refs: TranslationRef[] = [];
for (const tag of event.tags) {
if (tag[0] !== 'a') continue;
if (tag[3] !== 'translation') continue;
const coord = tag[1];
if (typeof coord !== 'string') continue;
const m = coord.match(COORD_RE);
if (!m) continue;
refs.push({
kind: parseInt(m[1], 10),
pubkey: m[2],
dtag: m[3]
});
}
return refs;
}

View File

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

View File

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

View File

@ -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} />

View File

@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { NostrEvent } from '$lib/nostr/loaders';
import { loadPostList } from '$lib/nostr/loaders';
import PostCard from '$lib/components/PostCard.svelte';
import LoadingOrError from '$lib/components/LoadingOrError.svelte';
import { t, activeLocale } from '$lib/i18n';
import { get } from 'svelte/store';
let posts: NostrEvent[] = $state([]);
let loading = $state(true);
let error: string | null = $state(null);
onMount(async () => {
try {
posts = await loadPostList();
loading = false;
if (posts.length === 0) {
error = get(t)('home.empty');
}
} catch (e) {
loading = false;
error = e instanceof Error ? e.message : get(t)('post.unknown_error');
}
});
let currentLocale = $state('de');
activeLocale.subscribe((v) => (currentLocale = v));
const filtered = $derived.by(() =>
posts.filter((p) => {
const l = p.tags.find((tag) => tag[0] === 'l')?.[1];
return (l ?? 'de') === currentLocale;
})
);
// Posts nach Jahr gruppieren (neueste zuerst)
type YearGroup = { year: number; posts: NostrEvent[] };
const groupsByYear = $derived.by<YearGroup[]>(() => {
const byYear = new Map<number, NostrEvent[]>();
for (const p of filtered) {
const ts = Number(p.tags.find((t) => t[0] === 'published_at')?.[1] ?? p.created_at);
const year = new Date(ts * 1000).getUTCFullYear();
if (!byYear.has(year)) byYear.set(year, []);
byYear.get(year)!.push(p);
}
return [...byYear.entries()]
.map(([year, p]) => ({ year, posts: p }))
.sort((a, b) => b.year - a.year);
});
</script>
<svelte:head>
<title>{$t('archive.doc_title')}</title>
</svelte:head>
<h1 class="title">{$t('archive.title')}</h1>
<p class="meta">{$t('archive.subtitle')}</p>
<LoadingOrError {loading} {error} />
{#each groupsByYear as group (group.year)}
<section class="year-group">
<h2 class="year">{group.year}</h2>
{#each group.posts as post (post.id)}
<PostCard event={post} />
{/each}
</section>
{/each}
<style>
.title {
margin: 0 0 0.3rem;
font-size: 1.8rem;
}
.meta {
color: var(--muted);
margin: 0 0 2rem;
font-size: 0.95rem;
}
.year-group {
margin-bottom: 2.5rem;
}
.year {
font-size: 1.2rem;
font-weight: 600;
margin: 0 0 1rem;
padding-bottom: 0.3rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
}
</style>

View File

@ -0,0 +1,41 @@
<script lang="ts">
import { renderMarkdown } from '$lib/render/markdown';
import impressumRaw from '../../../../content/impressum.md?raw';
import { t } from '$lib/i18n';
// Frontmatter abtrennen, nur Body rendern.
// Toleriert trailing spaces auf den ---/--- trenner-zeilen.
const match = impressumRaw.match(/^---[ \t]*\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n([\s\S]*)$/);
const body = match ? match[1] : impressumRaw;
const html = renderMarkdown(body);
</script>
<svelte:head>
<title>{$t('imprint.doc_title')}</title>
<meta name="robots" content="index, follow" />
</svelte:head>
<article class="impressum">
{@html html}
</article>
<style>
.impressum :global(h1) {
font-size: 1.8rem;
margin: 0 0 1rem;
}
.impressum :global(h2) {
font-size: 1.3rem;
margin: 2rem 0 0.6rem;
}
.impressum :global(h3) {
font-size: 1.05rem;
margin: 1.4rem 0 0.4rem;
}
.impressum :global(p) {
margin: 0 0 1rem;
}
.impressum :global(a) {
color: var(--accent);
}
</style>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

BIN
app/static/apple-touch-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
app/static/favicon-16x16.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

BIN
app/static/favicon-32x32.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

BIN
app/static/favicon.ico Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -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();
});

View File

@ -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
}

View File

@ -17,7 +17,7 @@ Die Inhalte unserer Seiten wurden mit größter Sorgfalt erstellt. Für die Rich
Mein Angebot enthält Links zu externen Webseiten Dritter, auf deren Inhalte ich keinen Einfluss habe. Deshalb kann ich für diese fremden Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht erkennbar. Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen werde ich derartige Links umgehend entfernen.
### 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.

View File

@ -1,32 +0,0 @@
---
layout: post
title: "Telegram Bot für Octopi"
description: "Schnittstelle zwischen Telegram und OctoPrint"
image: octopi1.png
cover:
image: octopi1.png
tags: [ "Telegram", "Octopi", "Raspberry", "3DDruck" ]
date: "2017-10-23"
author: Jörg Lohrer
slug: "telegram-octopi"
lang: de
dir: ltr
---
Das [OctoPrint-Telegram-Plugin](http://plugins.octoprint.org/plugins/telegram/) schafft eine Schnittstelle zwischen Telegram und OctoPrint.
Hier die Anleitung auf Englisch: [https://github.com/fabianonline/OctoPrint-Telegram/blob/stable/README.md](https://github.com/fabianonline/OctoPrint-Telegram/blob/stable/README.md)
Das dauert eine Weile:
![](octopi1.png)
Token eingeben:
![](octopi2.png)
Heisst aber nicht, dass jetzt alles gleich klappt:
![](octopi3.png)
Es müssen dem Benutzer noch die Rechte “Command” und “Notify” gegeben werden:
![](octopi4.png)

View File

@ -1,46 +0,0 @@
---
layout: post
title: "Lutherkürbis - Reformation an Halloween"
description: "Schablone und Bastelanleitung für einen Kürbis zur Reformation"
image: kuerbis-titelbild.jpg
cover:
image: kuerbis-titelbild.jpg
tags: [ "Lutherrose", "Reformation", "Halloween", "Luther" ]
date: "2017-10-31"
author: Jörg Lohrer
slug: "lutherkuerbis"
lang: de
dir: ltr
---
# Lutherkürbis - Reformation an Halloween
Das Symbol der Lutherrose ist auch heute noch in vielen Wappen enthalten und wird als Dekoelement genutzt ([Quelle: epd/imago](https://www.t-online.de/leben/familie/id_65982142/lutherrose-entstehung-und-bedeutung.html))
Aus einer [Fotovorlage der Lutherrose](https://duckduckgo.com/?q=lutherrose&t=h_&iax=images&ia=images) wird mit einem [Online-Konverter eine Schablone als verlustfrei skalierbare Vektorgrafik](https://image.online-convert.com/convert-to-svg) erzeugt:
[![](lutherrose.png)](https://material.rpi-virtuell.de/wp-content/uploads/2018/10/Lutherrose.pdf)
[Lutherrose PDF-Vorlage](https://material.rpi-virtuell.de/wp-content/uploads/2018/10/Lutherrose.pdf)
## Bastel-Anleitung
Einen Kürbis aufschneiden:
![](kuerbis-aufschneiden.jpg)
entkernen und aushöhlen:
![](kuerbis-entkernen.jpg)
Schablone aufbringen:
![](schablone-aufbringen.jpg)
Ausschneiden:
![](kuerbis-ausschneiden.jpg)
Mit Kerze oder elektrischem Licht ausstatten:
![](kuerbis-titelbild.jpg)
Fertig!
Diese Idee inklusive der Schablone steht unter [CC0-Lizenz](https://creativecommons.org/publicdomain/zero/1.0/deed.de). Du darfst das Werk kopieren, verändern, verbreiten und aufführen, sogar zu kommerziellen Zwecken, ohne um weitere Erlaubnis bitten zu müssen.
#### Weitere Quellen
* How to Make a Paper Cut-Out Luther Rose [YouTube](https://www.youtube.com/watch?v=b5FCaNZPU98) | [PDF](http://www.kellyklages.com/lutherrose.pdf)

View File

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

View File

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

View File

@ -11,6 +11,16 @@ author: Jörg Lohrer
slug: "premium-freemium-mium-mium-mium"
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>"
---

View File

@ -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://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3847400746&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)](http://ci-muenster.de/bookshop/artikel/buecher/Bildung-im-Kindes-und-Jugendalter/A40097_Handbuch_Jugend_2013.php)
**Handbuch Jugend Evangelische Perspektiven**, welches 2013 erschienen ist, hat auf Seite 343-345 folgenden Artikel von mir zur Erlebnispädagogik abgedruckt.
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
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3866870493&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
Großer, Achim/Oberländer, Rainer/Lohrer, Jörg/Wiedmayer, Jörg (2005/2011): Sinn gesucht Gott erfahren. Erlebnispädagogik im christlichen Kontext (Band 1 (2005)/Band 2 (2011)). Stuttgart: Buch und Musik.
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3497022934&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
Heckmair, Bernd/Michl, Werner (2008): Erleben und Lernen. Einführung in die Erlebnispädagogik. München: Reinhardt.
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3940562866&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
Reiners, Annette (2007): Praktische Erlebnispädagogik. Augsburg: ZIEL.
![](http://ws.assoc-amazon.de/widgets/q?_encoding=UTF8&ASIN=3936369348&Format=_SL160_&ID=AsinImage&MarketPlace=DE&ServiceVersion=20070822&WS=1&tag=httpwwwjoergl-21)
Pum, Viktoria/Pirner, Manfred L./Lohrer, Jörg (Hrsg.) (2011): Erlebnispädagogik im christlichen Kontext. Dokumentation einer Tagung der Evangelischen Akademie Bad Boll, 2. bis 4. März 2009. Bad Boll: Evangelische Akademie.
- 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/)

View File

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

View File

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

View File

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

View File

@ -0,0 +1,87 @@
---
layout: post
title: "Lutherkürbis - Reformation an Halloween"
description: "Schablone und Bastelanleitung für einen Kürbis zur Reformation"
image: kuerbis-titelbild.jpg
cover:
image: kuerbis-titelbild.jpg
tags: [ "Lutherrose", "Reformation", "Halloween", "Luther" ]
date: "2017-10-31"
author: Jörg Lohrer
slug: "lutherkuerbis"
lang: de
dir: ltr
images:
- file: kuerbis-titelbild.jpg
role: cover
alt: "Fertig geschnitzter Kürbis mit dem Muster einer Lutherrose, innen beleuchtet — glüht warm in der Dunkelheit"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: lutherrose.png
alt: "Schwarz-weiße Vektorgrafik der Lutherrose: Kreuz im Herzen, umgeben von fünfblättriger Rose in einem Ring — als Schnitzschablone aufbereitet"
caption: "Vektorisierte Schablone, abgeleitet von einer Fotovorlage aus dem Web"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
modifications: "Vektorisierung per online-convert.com aus gemeinfreier Fotovorlage; Originalurheber der Fotovorlage unbekannt"
- file: kuerbis-aufschneiden.jpg
alt: "Hände schneiden mit großem Messer den Deckel von einem orangenen Kürbis ab"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: kuerbis-entkernen.jpg
alt: "Mit einem Löffel wird das Fruchtfleisch und die Kerne aus dem Inneren des aufgeschnittenen Kürbis herausgekratzt"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: schablone-aufbringen.jpg
alt: "Papier-Schablone mit Lutherrosen-Motiv wird auf die Außenhaut des entkernten Kürbis geklebt"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
- file: kuerbis-ausschneiden.jpg
alt: "Mit einem Schnitzwerkzeug wird die Lutherrose entlang der Schablone aus der Kürbishaut herausgeschnitten"
license: "https://creativecommons.org/publicdomain/zero/1.0/deed.de"
authors:
- name: "Jörg Lohrer"
# a:
# - "30023:4fa5d1c413e2b45e10d40bf3562ab701a5331206e359c90baae0e99bfd6c6e41:<slug-der-anderssprachigen-variante>"
---
# Lutherkürbis - Reformation an Halloween
Das Symbol der Lutherrose ist auch heute noch in vielen Wappen enthalten und wird als Dekoelement genutzt ([Quelle: epd/imago](https://www.t-online.de/leben/familie/id_65982142/lutherrose-entstehung-und-bedeutung.html))
Aus einer [Fotovorlage der Lutherrose](https://duckduckgo.com/?q=lutherrose&t=h_&iax=images&ia=images) wird mit einem [Online-Konverter eine Schablone als verlustfrei skalierbare Vektorgrafik](https://image.online-convert.com/convert-to-svg) erzeugt:
[![](lutherrose.png)](https://material.rpi-virtuell.de/wp-content/uploads/2018/10/Lutherrose.pdf)
[Lutherrose PDF-Vorlage](https://material.rpi-virtuell.de/wp-content/uploads/2018/10/Lutherrose.pdf)
## Bastel-Anleitung
Einen Kürbis aufschneiden:
![](kuerbis-aufschneiden.jpg)
entkernen und aushöhlen:
![](kuerbis-entkernen.jpg)
Schablone aufbringen:
![](schablone-aufbringen.jpg)
Ausschneiden:
![](kuerbis-ausschneiden.jpg)
Mit Kerze oder elektrischem Licht ausstatten:
![](kuerbis-titelbild.jpg)
Fertig!
Diese Idee inklusive der Schablone steht unter [CC0-Lizenz](https://creativecommons.org/publicdomain/zero/1.0/deed.de). Du darfst das Werk kopieren, verändern, verbreiten und aufführen, sogar zu kommerziellen Zwecken, ohne um weitere Erlaubnis bitten zu müssen.
#### Weitere Quellen
* How to Make a Paper Cut-Out Luther Rose [YouTube](https://www.youtube.com/watch?v=b5FCaNZPU98) | [PDF](http://www.kellyklages.com/lutherrose.pdf)

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View File

@ -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>"
---

View File

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 446 KiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 236 KiB

View File

@ -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>"
---

View File

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View File

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 237 KiB

View File

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 201 KiB

View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 410 KiB

View File

@ -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>"
---

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -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>"
---

View File

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -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>"
---

View File

Before

Width:  |  Height:  |  Size: 761 KiB

After

Width:  |  Height:  |  Size: 761 KiB

View File

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 221 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 138 KiB

View File

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

Before

Width:  |  Height:  |  Size: 560 KiB

After

Width:  |  Height:  |  Size: 560 KiB

View File

Before

Width:  |  Height:  |  Size: 672 KiB

After

Width:  |  Height:  |  Size: 672 KiB

View File

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 110 KiB

View File

Before

Width:  |  Height:  |  Size: 440 KiB

After

Width:  |  Height:  |  Size: 440 KiB

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