release: v0.12.0 (#520)

Forty-one commits since v0.11.0. See CHANGELOG.md for the full list and the upgrade notes.
This commit is contained in:
Ralksta
2026-08-23 23:18:14 +02:00
committed by GitHub
parent 2f546f440d
commit 10ec68f493
119 changed files with 6888 additions and 758 deletions

55
lib/i18n/index.ts Normal file
View File

@@ -0,0 +1,55 @@
/**
* Visitor-facing internationalisation.
*
* Deliberately not a framework: `settings.yaml: lang` picks one of the
* dictionaries in `lib/i18n/locales/`, and every visitor-facing string is read
* off that object. No message extraction, no runtime loader, no route prefixes
* — a self-hosted portfolio is served in exactly one language.
*
* This module is **client-safe** (no `fs`). Server components read the language
* from the config via `lib/i18n/server.ts`; client components take the resolved
* locale from `components/I18nProvider.tsx` and look the dictionary up here.
* Both dictionaries are small enough that shipping them together beats a
* dynamic import.
*/
import { en } from './locales/en';
import { de } from './locales/de';
/** Locales with a dictionary. Anything else falls back to English. */
export const SUPPORTED_LOCALES = ['en', 'de'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'en';
/** The shape every dictionary must implement — English is the reference. */
export type Dictionary = typeof en;
const DICTIONARIES: Record<Locale, Dictionary> = { en, de };
/**
* Map a `settings.yaml: lang` value onto a locale we have strings for.
*
* Region subtags are accepted (`de-AT` → `de`), unknown languages fall back to
* English. The raw value still reaches `<html lang>` — a French deployment gets
* `lang="fr"` for screen readers and an English UI, which beats claiming to be
* English.
*/
export function resolveLocale(lang?: string | null): Locale {
if (!lang) return DEFAULT_LOCALE;
const base = lang.trim().toLowerCase().split(/[-_]/)[0];
return (SUPPORTED_LOCALES as readonly string[]).includes(base)
? (base as Locale)
: DEFAULT_LOCALE;
}
/** Dictionary for a locale (already resolved). */
export function getDictionary(locale: Locale): Dictionary {
return DICTIONARIES[locale] ?? DICTIONARIES[DEFAULT_LOCALE];
}
/** Convenience: resolve a raw `lang` string and return its dictionary. */
export function dictionaryFor(lang?: string | null): Dictionary {
return getDictionary(resolveLocale(lang));
}

178
lib/i18n/locales/de.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* German dictionary.
*
* Typed as `Dictionary` (derived from `en.ts`), so a key added to English but
* forgotten here fails the type-check rather than falling through to English
* at runtime.
*/
import type { Dictionary } from '../index';
const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;
export const de: Dictionary = {
dateLocale: 'de-DE',
nav: {
home: 'Start',
about: 'Über mich',
map: 'Karte',
journal: 'Journal',
skipToContent: 'Zum Inhalt springen',
},
common: {
backTo: (label: string) => `Zurück zu ${label}`,
backToGallery: 'Zurück zur Galerie',
backToJournal: 'Zurück zum Journal',
home: 'Start',
photos: (n: number) => plural(n, 'Foto', 'Fotos'),
albums: (n: number) => plural(n, 'Album', 'Alben'),
collections: (n: number) => plural(n, 'Sammlung', 'Sammlungen'),
loadingGallery: 'Galerie wird geladen',
loadingPhotos: 'Fotos werden geladen',
},
home: {
enter: 'Eintreten',
},
error: {
notFoundTitle: 'Nicht gefunden',
notFoundText: 'Diese Seite existiert nicht, oder das Album ist nicht mehr veröffentlicht.',
errorTitle: 'Etwas ist schiefgelaufen',
errorText:
'Diese Seite konnte gerade nicht geladen werden. Das ist meist vorübergehend — bitte gleich noch einmal versuchen.',
siteErrorText:
'Diese Website konnte gerade nicht geladen werden. Das ist meist vorübergehend — bitte gleich noch einmal versuchen.',
tryAgain: 'Erneut versuchen',
reference: (digest: string) => `Referenz: ${digest}`,
},
theme: {
switchToLight: 'Zum hellen Modus wechseln',
switchToDark: 'Zum dunklen Modus wechseln',
scrollToTop: 'Nach oben',
},
password: {
subtitle: 'Diese Galerie ist passwortgeschützt.',
siteSubtitle: 'Diese Website ist passwortgeschützt.',
placeholder: 'Passwort eingeben',
submit: 'Öffnen',
verifying: 'Wird geprüft…',
incorrect: 'Falsches Passwort. Bitte erneut versuchen.',
failed: 'Passwort konnte nicht geprüft werden. Bitte später erneut versuchen.',
},
about: {
kicker: 'Über mich',
title: 'Über mich',
metaTitle: (name: string) => `Über mich — ${name}`,
portraitAlt: 'Porträt',
gear: 'Ausrüstung',
},
map: {
title: 'Karte',
kicker: 'Wo',
subtitle: 'Wo die Fotos entstanden sind',
loading: 'Karte wird geladen…',
loadFailed: (status: number) => `Kartendaten konnten nicht geladen werden (${status})`,
initFailed: 'Karte konnte nicht initialisiert werden',
},
subpage: {
collectionKicker: (index: string) => `${index} — Sammlung`,
sectionsNav: 'Abschnitte',
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
},
journal: {
title: 'Journal',
kicker: 'Geschichten & Essays',
subtitle: 'Fotoessays, visuelle Geschichten und Notizen von unterwegs.',
description: 'Fotoessays, Reisegeschichten und Journale hinter den Kulissen.',
entryDescription: 'Journal-Eintrag',
empty: 'Noch keine Journal-Einträge veröffentlicht.',
readStory: 'Geschichte lesen →',
minRead: (n: number) => `${n} Min. Lesezeit`,
draft: 'Entwurf',
by: (author: string) => `von ${author}`,
notFound: 'Nicht gefunden',
},
lightbox: {
viewer: 'Bildansicht',
close: 'Schließen',
closeTitle: 'Schließen (Esc)',
previous: 'Vorheriges Foto',
previousTitle: 'Vorheriges Foto (Pfeil links)',
next: 'Nächstes Foto',
nextTitle: 'Nächstes Foto (Pfeil rechts)',
toggleInfo: 'Fotoinfos ein-/ausblenden',
toggleInfoTitle: 'Fotoinfos ein-/ausblenden (i)',
hideInfo: 'Infos ausblenden',
info: 'Infos',
loading: 'Wird geladen...',
camera: 'Kamera',
lens: 'Objektiv',
focalLength: 'Brennweite',
aperture: 'Blende',
shutter: 'Belichtungszeit',
iso: 'ISO',
location: 'Ort',
noExif: 'Keine EXIF-Daten vorhanden',
shortcuts: 'Tastaturkürzel',
shortcutNavigate: 'Vorheriges / nächstes Foto',
shortcutInfo: 'Fotoinfos',
shortcutFullscreen: 'Vollbild',
shortcutExitFullscreen: 'Vollbild verlassen',
shortcutList: 'Diese Liste',
shortcutClose: 'Ansicht schließen',
},
proofing: {
addFavorite: 'Zur Auswahl hinzufügen',
removeFavorite: 'Aus Auswahl entfernen',
addToFavorites: 'Zur Auswahl hinzufügen',
removeFromFavorites: 'Aus Auswahl entfernen',
saved: 'Gemerkt',
favorite: 'Merken',
showAll: 'Alle anzeigen',
selected: (n: number) => `❤️ ${n} ausgewählt`,
shareExport: 'Teilen & Export',
modalTitle: (n: number) => `❤️ Auswahl (${n})`,
closeModal: 'Dialog schließen',
intro: (n: number) =>
`Du hast ${plural(n, 'Foto', 'Fotos')} ausgewählt. Wähle unten eine Export-Option, um deine Auswahl zu teilen:`,
copyLink: 'Teilbaren Link kopieren',
linkCopied: 'Link kopiert!',
copyList: 'Textliste kopieren (#1, #2...)',
listCopied: 'Liste kopiert!',
sendEmail: '✉️ E-Mail an den Fotografen',
clearSelection: 'Auswahl löschen',
confirmClear: 'Die gesamte Auswahl löschen?',
listEmpty: (albumName: string) => `${albumName}: Keine Fotos ausgewählt.`,
listSummary: (albumName: string, count: number, indices: string) =>
`${albumName} — Ausgewählte Fotos (${count}): ${indices}`,
mailSubject: (n: number) => `Fotoauswahl (${n} Fotos)`,
mailBody: (list: string, url: string) =>
`Hallo,\n\nhier ist meine Fotoauswahl:\n\n${list}\n\nLink zur Auswahl: ${url}\n\nViele Grüße,`,
},
legal: {
navLabel: 'Impressum',
title: 'Impressum',
subtitle: 'Angaben gemäß § 5 TMG',
address: 'Anschrift',
contact: 'Kontakt',
email: 'E-Mail',
phone: 'Telefon',
taxSection: 'Steuernummer',
taxId: 'Steuernummer',
vatId: 'Umsatzsteuer-ID',
extraInfo: 'Weitere Informationen',
source: 'Quelle: Erstellt mit dem Impressum-Generator von eRecht24.',
},
};

178
lib/i18n/locales/en.ts Normal file
View File

@@ -0,0 +1,178 @@
/**
* English dictionary — the reference locale.
*
* Every other locale implements the `Dictionary` type derived from this file,
* so adding a key here is a type error in `de.ts` until it is translated.
* Values are plain strings, or functions when a count or a name is interpolated
* (a locale is free to pluralise differently — German "Alben" is not "Albums").
*/
const plural = (n: number, one: string, other: string) => `${n} ${n === 1 ? one : other}`;
export const en = {
/** Passed to `toLocaleDateString` for visitor-facing dates. */
dateLocale: 'en-US',
nav: {
home: 'Home',
about: 'About',
map: 'Map',
journal: 'Journal',
skipToContent: 'Skip to content',
},
common: {
backTo: (label: string) => `Back to ${label}`,
backToGallery: 'Back to Gallery',
backToJournal: 'Back to Journal',
home: 'Home',
photos: (n: number) => plural(n, 'photo', 'photos'),
albums: (n: number) => plural(n, 'album', 'albums'),
collections: (n: number) => plural(n, 'collection', 'collections'),
loadingGallery: 'Loading gallery',
loadingPhotos: 'Loading photos',
},
home: {
enter: 'Enter',
},
error: {
notFoundTitle: 'Not found',
notFoundText: 'This page does not exist, or the album is no longer published.',
errorTitle: 'Something went wrong',
errorText:
'This page could not be loaded right now. It is usually temporary — try again in a moment.',
siteErrorText:
'This site could not be loaded right now. It is usually temporary — try again in a moment.',
tryAgain: 'Try again',
reference: (digest: string) => `Reference: ${digest}`,
},
theme: {
switchToLight: 'Switch to light mode',
switchToDark: 'Switch to dark mode',
scrollToTop: 'Scroll to top',
},
password: {
subtitle: 'This gallery is password-protected.',
siteSubtitle: 'This site is password-protected.',
placeholder: 'Enter password',
submit: 'Enter',
verifying: 'Verifying…',
incorrect: 'Incorrect password. Please try again.',
failed: 'Unable to verify password. Please try again later.',
},
about: {
kicker: 'About',
title: 'About',
metaTitle: (name: string) => `About — ${name}`,
portraitAlt: 'Portrait',
gear: 'Gear',
},
map: {
title: 'Map',
kicker: 'Where',
subtitle: 'Where the photos were taken',
loading: 'Loading map…',
loadFailed: (status: number) => `Failed to load map data (${status})`,
initFailed: 'Failed to initialize map',
},
subpage: {
collectionKicker: (index: string) => `${index} — Collection`,
sectionsNav: 'Sections',
coverAria: (albumName: string, count: string) => `${albumName}, ${count}`,
},
journal: {
title: 'Journal',
kicker: 'Stories & Essays',
subtitle: 'Photo essays, visual stories, and field notes.',
description: 'Photo essays, travel stories, and behind-the-scenes journals.',
entryDescription: 'Journal entry',
empty: 'No journal entries published yet.',
readStory: 'Read Story →',
minRead: (n: number) => `${n} min read`,
draft: 'Draft',
by: (author: string) => `by ${author}`,
notFound: 'Not Found',
},
lightbox: {
viewer: 'Image viewer',
close: 'Close',
closeTitle: 'Close (Esc)',
previous: 'Previous photo',
previousTitle: 'Previous photo (Left arrow)',
next: 'Next photo',
nextTitle: 'Next photo (Right arrow)',
toggleInfo: 'Toggle photo info',
toggleInfoTitle: 'Toggle photo info (i)',
hideInfo: 'Hide Info',
info: 'Info',
loading: 'Loading...',
camera: 'Camera',
lens: 'Lens',
focalLength: 'Focal Length',
aperture: 'Aperture',
shutter: 'Shutter',
iso: 'ISO',
location: 'Location',
noExif: 'No EXIF data available',
shortcuts: 'Keyboard shortcuts',
shortcutNavigate: 'Previous / next photo',
shortcutInfo: 'Photo info',
shortcutFullscreen: 'Fullscreen',
shortcutExitFullscreen: 'Leave fullscreen',
shortcutList: 'This list',
shortcutClose: 'Close the viewer',
},
proofing: {
addFavorite: 'Add favorite',
removeFavorite: 'Remove favorite',
addToFavorites: 'Add to favorites',
removeFromFavorites: 'Remove from favorites',
saved: 'Saved',
favorite: 'Favorite',
showAll: 'Show All',
selected: (n: number) => `❤️ ${n} Selected`,
shareExport: 'Share & Export',
modalTitle: (n: number) => `❤️ Selection (${n})`,
closeModal: 'Close modal',
intro: (n: number) =>
`You have selected ${plural(n, 'photo', 'photos')}. Choose an export option below to share your selection:`,
copyLink: 'Copy Shareable Link',
linkCopied: 'Link Copied!',
copyList: 'Copy Text List (#1, #2...)',
listCopied: 'List Copied!',
sendEmail: '✉️ Send Email to Photographer',
clearSelection: 'Clear Selection',
confirmClear: 'Clear all selected favorites?',
listEmpty: (albumName: string) => `${albumName}: No photos selected.`,
listSummary: (albumName: string, count: number, indices: string) =>
`${albumName} — Selected Photos (${count}): ${indices}`,
mailSubject: (n: number) => `Photo Selection (${n} items)`,
mailBody: (list: string, url: string) =>
`Hello,\n\nHere is my photo selection:\n\n${list}\n\nShare Link: ${url}\n\nBest regards,`,
},
legal: {
navLabel: 'Legal Notice',
title: 'Legal Notice',
subtitle: 'Information pursuant to § 5 TMG',
address: 'Address',
contact: 'Contact',
email: 'Email',
phone: 'Phone',
taxSection: 'Tax Information',
taxId: 'Tax number',
vatId: 'VAT ID',
extraInfo: 'Additional Information',
source: 'Source: Created with the Impressum generator by eRecht24.',
},
};

19
lib/i18n/server.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Server-side entry point for i18n. Separate from `lib/i18n/index.ts` because
* it reaches into `lib/config`, which pulls in `fs` — importing this from a
* client component breaks the build (same split as `lib/journal.ts` vs
* `lib/admin/journal-service.ts`).
*/
import { getConfigOrNull } from '@/lib/config';
import { resolveLocale, getDictionary, type Dictionary, type Locale } from './index';
/** Locale of the current deployment, resolved from `settings.yaml: lang`. */
export function getLocale(): Locale {
return resolveLocale(getConfigOrNull()?.lang);
}
/** Dictionary for the current deployment. */
export function getServerDictionary(): Dictionary {
return getDictionary(getLocale());
}