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

View File

@@ -4,6 +4,7 @@
*/
import Link from 'next/link';
import { getServerDictionary } from '@/lib/i18n/server';
interface BackLinkProps {
href: string;
@@ -11,8 +12,9 @@ interface BackLinkProps {
}
export function BackLink({ href, label }: BackLinkProps) {
const t = getServerDictionary();
return (
<Link href={href} className="album-header__back" aria-label={`Back to ${label}`}>
<Link href={href} className="album-header__back" aria-label={t.common.backTo(label)}>
<svg
aria-hidden="true"
viewBox="0 0 24 24"

View File

@@ -5,10 +5,12 @@
import Link from 'next/link';
import { getConfig } from '@/lib/config';
import { getServerDictionary } from '@/lib/i18n/server';
export function Footer() {
const config = getConfig();
const { footer, legal } = config;
const t = getServerDictionary();
// Don't render if no footer config AND no legal config
if (
@@ -25,7 +27,7 @@ export function Footer() {
{footer?.name && <span className="footer__name">{footer.name}</span>}
{legal.enabled && (
<Link href="/impressum" className="footer__legal-link">
Impressum
{t.legal.navLabel}
</Link>
)}
</div>

View File

@@ -0,0 +1,29 @@
'use client';
/**
* Carries the resolved locale to client components.
*
* Only the locale string crosses the server/client boundary — dictionaries
* contain functions and would not serialise, so `useDictionary()` looks the
* object up on the client instead of receiving it as a prop.
*/
import { createContext, useContext, useMemo } from 'react';
import { DEFAULT_LOCALE, getDictionary, type Dictionary, type Locale } from '@/lib/i18n';
const LocaleContext = createContext<Locale>(DEFAULT_LOCALE);
export function I18nProvider({ locale, children }: { locale: Locale; children: React.ReactNode }) {
return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
}
/** Resolved locale — `DEFAULT_LOCALE` outside a provider. */
export function useLocale(): Locale {
return useContext(LocaleContext);
}
/** Dictionary for the current locale. */
export function useDictionary(): Dictionary {
const locale = useLocale();
return useMemo(() => getDictionary(locale), [locale]);
}

View File

@@ -175,6 +175,17 @@
}
.exifPanel {
/* Both floating panels paint their own dark surface, so their contents have
to stay light-on-dark. In dark mode the theme tokens already are, and each
preset tints them (warm #6b6359 in editorial, neutral #555 in monograph) —
so they are used as-is and dark mode is untouched. Only light mode, where
--text-primary is near-black and put dark text on that dark panel (#511),
swaps in fixed light values below. .exifCaption never had the bug because
it was hardcoded. */
--lb-panel-text: var(--text-primary);
--lb-panel-text-dim: var(--text-secondary);
--lb-panel-muted: var(--text-muted);
--lb-panel-border: var(--border-subtle);
position: fixed;
bottom: 60px;
right: 24px;
@@ -182,7 +193,7 @@
background: rgba(20, 20, 20, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-subtle);
border: 1px solid var(--lb-panel-border);
border-radius: var(--radius-lg);
padding: 20px;
min-width: 240px;
@@ -214,10 +225,14 @@
.exifRow {
display: flex;
/* `space-between` distributes the *free* space, so a value long enough to
leave none ran straight into its own label (#514). An explicit gap is the
floor; the panel has no max-width and grows to keep it. */
gap: 16px;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
border-bottom: 1px solid var(--lb-panel-border);
}
.exifRow:last-child {
@@ -226,15 +241,18 @@
.exifLabel {
font-size: 0.75rem;
color: var(--text-muted);
color: var(--lb-panel-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.exifValue {
font-size: 0.85rem;
color: var(--text-primary);
color: var(--lb-panel-text);
font-weight: 400;
/* Right-aligned so a value that does wrap stays flush with the ones above it,
rather than starting a ragged second column. */
text-align: right;
}
/* Marks the ISO row so a preset can keep its label — "200" alone reads
@@ -243,6 +261,105 @@
display: flex;
}
/* ── Keyboard shortcuts ─────────────────────────────
The panel has no trigger of its own — `?` or `h` opens it. It sits where a
trigger would have been, bottom left, clear of the EXIF panel opposite. */
.shortcutsPanel {
/* Both floating panels paint their own dark surface, so their contents have
to stay light-on-dark. In dark mode the theme tokens already are, and each
preset tints them (warm #6b6359 in editorial, neutral #555 in monograph) —
so they are used as-is and dark mode is untouched. Only light mode, where
--text-primary is near-black and put dark text on that dark panel (#511),
swaps in fixed light values below. .exifCaption never had the bug because
it was hardcoded. */
--lb-panel-text: var(--text-primary);
--lb-panel-text-dim: var(--text-secondary);
--lb-panel-muted: var(--text-muted);
--lb-panel-border: var(--border-subtle);
position: fixed;
bottom: 62px;
left: 24px;
z-index: 1001;
animation: exif-slide-up var(--transition-normal) ease forwards;
min-width: 240px;
padding: 16px 20px;
background: rgba(20, 20, 20, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--lb-panel-border);
border-radius: var(--radius-lg);
}
.shortcutsTitle {
margin: 0 0 10px;
font-family: var(--font-sans);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--lb-panel-muted);
}
.shortcutsList {
margin: 0;
}
/* Two columns, not space-between: a ragged left edge on the descriptions is
what makes a shortcut list hard to scan. */
.shortcutsRow {
display: flex;
align-items: baseline;
gap: 16px;
padding: 6px 0;
border-bottom: 1px solid var(--lb-panel-border);
}
.shortcutsRow:last-child {
border-bottom: none;
}
.shortcutsKeys {
display: flex;
flex: 0 0 66px;
gap: 4px;
}
.shortcutsLabel {
margin: 0;
font-size: 0.8rem;
color: var(--lb-panel-text);
}
.kbd {
min-width: 22px;
padding: 2px 6px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: var(--radius-sm);
font-family: var(--font-caption);
font-size: 0.75rem;
line-height: 1.4;
text-align: center;
color: var(--lb-panel-text-dim);
}
/* The panels stay dark while the page around them turns light, so their
contents cannot follow the theme here. Preset tinting is lost in light mode;
an unreadable panel is the worse trade (#511). */
:global([data-theme='light']) .exifPanel,
:global([data-theme='light']) .shortcutsPanel {
--lb-panel-text: rgba(255, 255, 255, 0.92);
--lb-panel-text-dim: rgba(255, 255, 255, 0.62);
--lb-panel-muted: rgba(255, 255, 255, 0.5);
--lb-panel-border: rgba(255, 255, 255, 0.12);
}
/* No keyboard, nothing to show. */
@media (hover: none) {
.shortcutsPanel {
display: none;
}
}
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 640px) {
.nav {
@@ -429,6 +546,24 @@
right: 176px;
}
/* The EXIF strip owns the bottom edge in this preset, so the panel opens from
the top instead. */
:global([data-preset='studio-modern']) .shortcutsPanel {
top: 58px;
bottom: auto;
left: 32px;
border-radius: 0;
background: rgba(10, 10, 10, 0.94);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:global([data-preset='studio-modern']) .shortcutsTitle,
:global([data-preset='studio-modern']) .kbd {
border-radius: 0;
font-family: var(--font-caption);
}
/* Film-edge strip replaces the popup panel */
:global([data-preset='studio-modern']) .exifPanel {
top: auto;

View File

@@ -6,6 +6,8 @@
* - Previous/Next navigation (arrows + swipe)
* - Close (Esc, click outside, X button)
* - EXIF metadata panel (fetched on demand)
* - Keyboard shortcut list (`?` or `h`), deliberately unadvertised
* - Real fullscreen (`f`), where the browser offers it
* - Preloads adjacent images
*/
@@ -19,6 +21,11 @@ import { useSwipe } from '@/hooks/useSwipe';
import styles from './Lightbox.module.css';
import { useProofing } from './ProofingContext';
import { IconHeart } from './Icons';
import { useDictionary } from './I18nProvider';
// From lib/config/schema directly: lib/config/index.ts pulls in `fs`, which a
// client component cannot import.
import { resolveWatermarkOpacity } from '@/lib/config/schema';
import { formatCamera } from '@/lib/exif';
export interface LightboxWatermark {
enabled?: boolean;
@@ -50,7 +57,11 @@ export function Lightbox({
watermark,
showExifToggle = true,
}: LightboxProps) {
const t = useDictionary();
const [showExif, setShowExif] = useState(false);
const [showShortcuts, setShowShortcuts] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [canFullscreen, setCanFullscreen] = useState(false);
const { exifData, exifLoading, fetchExif, clearExif } = useExif();
const [imageLoaded, setImageLoaded] = useState(false);
const overlayRef = useRef<HTMLDivElement>(null);
@@ -92,6 +103,61 @@ export function Lightbox({
}
}, [showExif, current, fetchExif]);
const toggleShortcuts = useCallback(() => {
setShowShortcuts((open) => !open);
}, []);
/*
* Real fullscreen. The overlay already covers the viewport, but the browser's
* own chrome sits above it — tab strip, URL bar, bookmarks — which is exactly
* the frame a photograph should not be shown in.
*
* `fullscreenEnabled` is the honest gate: iPhone Safari implements the API on
* `<video>` only, so the key and its shortcut row are simply absent there
* rather than failing silently.
*/
useEffect(() => {
setCanFullscreen(typeof document !== 'undefined' && !!document.fullscreenEnabled);
}, []);
const toggleFullscreen = useCallback(() => {
const overlay = overlayRef.current;
if (!overlay) return;
// Every rejection here is a decision the browser is entitled to make
// (denied permission, gesture requirement, already exiting) — the state
// then simply stays where it was.
if (document.fullscreenElement) {
void document.exitFullscreen().catch(() => {});
} else {
void overlay.requestFullscreen().catch(() => {});
}
}, []);
/*
* The state is derived from the event, never from the click: F11, the browser
* Esc and the window manager all leave fullscreen without asking us.
*/
useEffect(() => {
const syncFullscreen = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', syncFullscreen);
return () => document.removeEventListener('fullscreenchange', syncFullscreen);
}, []);
/*
* Leaving the lightbox by any route — X, backdrop click, Esc, a navigation —
* unmounts this component while the document is still fullscreen. Without
* this the visitor is left in a fullscreen *gallery page* with no obvious way
* back. Only ever exits a fullscreen this component asked for.
*/
useEffect(() => {
const overlay = overlayRef.current;
return () => {
if (overlay && document.fullscreenElement === overlay) {
void document.exitFullscreen().catch(() => {});
}
};
}, []);
// Preload adjacent images (skip videos — they stream on demand)
useEffect(() => {
const preload = (index: number) => {
@@ -119,7 +185,15 @@ export function Lightbox({
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
onClose();
// Innermost layer first: Esc dismisses the shortcut list, then leaves
// fullscreen, and only closes the viewer once nothing is stacked on
// top of it. Most browsers swallow this Esc to exit fullscreen
// themselves and never dispatch it — the middle branch is for the
// ones that do dispatch it, which would otherwise close the lightbox
// and leave the page behind it fullscreen.
if (showShortcuts) setShowShortcuts(false);
else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else onClose();
break;
case 'ArrowRight':
onNext();
@@ -131,6 +205,19 @@ export function Lightbox({
case 'I':
if (showExifToggle) handleExifToggle();
break;
// `?` is matched on the produced character, not the physical key:
// Shift+/ on a US layout, Shift+ß on a German one. `h` is the escape
// hatch for layouts where `?` is awkward — and the one key someone
// guesses without having been told.
case '?':
case 'h':
case 'H':
toggleShortcuts();
break;
case 'f':
case 'F':
if (canFullscreen) toggleFullscreen();
break;
}
};
@@ -141,7 +228,17 @@ export function Lightbox({
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [handleExifToggle, onClose, onNext, onPrev, showExifToggle]);
}, [
canFullscreen,
handleExifToggle,
onClose,
onNext,
onPrev,
showExifToggle,
showShortcuts,
toggleFullscreen,
toggleShortcuts,
]);
// Click on overlay background → close
const handleOverlayClick = useCallback(
@@ -153,6 +250,28 @@ export function Lightbox({
[onClose],
);
/*
* The advertised shortcuts, in the order they are worth learning. This is the
* list the `?` panel renders — a key added to the handler above belongs here
* too, or it stays as undiscoverable as the whole set was before.
*/
const shortcutRows = [
{ keys: ['←', '→'], label: t.lightbox.shortcutNavigate },
// Journal entries hide the EXIF toggle, so `i` does nothing there.
...(showExifToggle ? [{ keys: ['I'], label: t.lightbox.shortcutInfo }] : []),
// Absent where the browser has no element fullscreen to give (iPhone).
...(canFullscreen
? [
{
keys: ['F'],
label: isFullscreen ? t.lightbox.shortcutExitFullscreen : t.lightbox.shortcutFullscreen,
},
]
: []),
{ keys: ['?', 'H'], label: t.lightbox.shortcutList },
{ keys: ['Esc'], label: t.lightbox.shortcutClose },
];
const lightboxJsx = (
<div
className={styles.overlay}
@@ -162,15 +281,15 @@ export function Lightbox({
onTouchEnd={handleTouchEnd}
role="dialog"
aria-modal="true"
aria-label="Image viewer"
aria-label={t.lightbox.viewer}
>
{/* Close button */}
<button
ref={closeBtnRef}
className={styles.close}
onClick={onClose}
aria-label="Close"
title="Close (Esc)"
aria-label={t.lightbox.close}
title={t.lightbox.closeTitle}
>
<svg
aria-hidden="true"
@@ -190,8 +309,8 @@ export function Lightbox({
<button
className={`${styles.nav} ${styles.navPrev}`}
onClick={onPrev}
aria-label="Previous photo"
title="Previous photo (Left arrow)"
aria-label={t.lightbox.previous}
title={t.lightbox.previousTitle}
>
<svg
aria-hidden="true"
@@ -251,7 +370,7 @@ export function Lightbox({
? styles.watermark_center
: styles.watermark_bottom_right
}`}
style={{ opacity: (watermark.opacity ?? 50) / 100 }}
style={{ opacity: resolveWatermarkOpacity(watermark.opacity) }}
>
{watermark.text}
</div>
@@ -262,8 +381,8 @@ export function Lightbox({
<button
className={`${styles.nav} ${styles.navNext}`}
onClick={onNext}
aria-label="Next photo"
title="Next photo (Right arrow)"
aria-label={t.lightbox.next}
title={t.lightbox.nextTitle}
>
<svg
aria-hidden="true"
@@ -297,11 +416,11 @@ export function Lightbox({
fontWeight: isFav ? 600 : 400,
}}
onClick={() => proofing.toggleFavorite(current.id)}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
aria-label={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}
title={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}
>
<IconHeart size={14} fill={isFav ? 'currentColor' : 'none'} aria-hidden="true" />
{isFav ? 'Saved' : 'Favorite'}
{isFav ? t.proofing.saved : t.proofing.favorite}
</button>
)}
@@ -312,10 +431,10 @@ export function Lightbox({
onClick={handleExifToggle}
aria-expanded={showExif}
aria-controls="exif-panel"
aria-label="Toggle photo info"
title="Toggle photo info (i)"
aria-label={t.lightbox.toggleInfo}
title={t.lightbox.toggleInfoTitle}
>
{showExif ? 'Hide Info' : 'Info'}
{showExif ? t.lightbox.hideInfo : t.lightbox.info}
</button>
)}
@@ -324,52 +443,52 @@ export function Lightbox({
<div id="exif-panel" className={styles.exifPanel}>
{exifLoading ? (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Loading...</span>
<span className={styles.exifLabel}>{t.lightbox.loading}</span>
</div>
) : exifData ? (
<>
{exifData.description && <p className={styles.exifCaption}>{exifData.description}</p>}
{exifData.model && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Camera</span>
<span className={styles.exifLabel}>{t.lightbox.camera}</span>
<span className={styles.exifValue}>
{[exifData.make, exifData.model].filter(Boolean).join(' ')}
{formatCamera(exifData.make, exifData.model)}
</span>
</div>
)}
{exifData.lensModel && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Lens</span>
<span className={styles.exifLabel}>{t.lightbox.lens}</span>
<span className={styles.exifValue}>{exifData.lensModel}</span>
</div>
)}
{exifData.focalLength && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Focal Length</span>
<span className={styles.exifLabel}>{t.lightbox.focalLength}</span>
<span className={styles.exifValue}>{exifData.focalLength}mm</span>
</div>
)}
{exifData.fNumber && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Aperture</span>
<span className={styles.exifLabel}>{t.lightbox.aperture}</span>
<span className={styles.exifValue}>ƒ/{exifData.fNumber}</span>
</div>
)}
{exifData.exposureTime && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Shutter</span>
<span className={styles.exifLabel}>{t.lightbox.shutter}</span>
<span className={styles.exifValue}>{exifData.exposureTime}s</span>
</div>
)}
{exifData.iso && (
<div className={`${styles.exifRow} ${styles.exifRowIso}`}>
<span className={styles.exifLabel}>ISO</span>
<span className={styles.exifLabel}>{t.lightbox.iso}</span>
<span className={styles.exifValue}>{exifData.iso}</span>
</div>
)}
{(exifData.city || exifData.country) && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>Location</span>
<span className={styles.exifLabel}>{t.lightbox.location}</span>
<span className={styles.exifValue}>
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
</span>
@@ -378,11 +497,37 @@ export function Lightbox({
</>
) : (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>No EXIF data available</span>
<span className={styles.exifLabel}>{t.lightbox.noExif}</span>
</div>
)}
</div>
)}
{/*
The shortcuts have no control of their own and are not advertised: a
permanent button in the corner of a photograph costs every visitor
something, and the keys are worth nothing to the ones who would never
press them anyway. Whoever tries `?` or `h` finds them.
*/}
{showShortcuts && (
<div id="lightbox-shortcuts" className={styles.shortcutsPanel}>
<p className={styles.shortcutsTitle}>{t.lightbox.shortcuts}</p>
<dl className={styles.shortcutsList}>
{shortcutRows.map((row) => (
<div key={row.label} className={styles.shortcutsRow}>
<dt className={styles.shortcutsKeys}>
{row.keys.map((key) => (
<kbd key={key} className={styles.kbd}>
{key}
</kbd>
))}
</dt>
<dd className={styles.shortcutsLabel}>{row.label}</dd>
</div>
))}
</dl>
</div>
)}
</div>
);

View File

@@ -6,6 +6,7 @@
*/
import { useEffect, useRef, useState } from 'react';
import { useDictionary } from './I18nProvider';
/** Escapes special HTML characters to prevent XSS in Leaflet popup templates. */
function escapeHtml(str: string): string {
@@ -28,6 +29,7 @@ interface MapLocationPublic {
}
export function MapView() {
const t = useDictionary();
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<unknown>(null);
const [loading, setLoading] = useState(true);
@@ -52,7 +54,7 @@ export function MapView() {
/* ignore */
}
console.error('[Map] API error', res.status, detail);
setError(`Failed to load map data (${res.status})`);
setError(t.map.loadFailed(res.status));
setLoading(false);
return;
}
@@ -107,7 +109,7 @@ export function MapView() {
<div class="map-popup__body">
<h3 class="map-popup__city">${escapeHtml(loc.city)}</h3>
<p class="map-popup__country">${escapeHtml(loc.country)}</p>
<p class="map-popup__count">${loc.photoCount} photo${loc.photoCount === 1 ? '' : 's'}</p>
<p class="map-popup__count">${escapeHtml(t.common.photos(loc.photoCount))}</p>
<div class="map-popup__albums">${albumLinks}</div>
</div>
</div>
@@ -135,7 +137,7 @@ export function MapView() {
init().catch((err) => {
console.error('[Map] Init error:', err);
if (!cancelled) {
setError('Failed to initialize map');
setError(t.map.initFailed);
setLoading(false);
}
});
@@ -147,7 +149,7 @@ export function MapView() {
mapRef.current = null;
}
};
}, []);
}, [t]);
if (error) {
return (
@@ -194,7 +196,7 @@ export function MapView() {
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"></circle>
<path d="M12 2a10 10 0 0 1 10 10"></path>
</svg>
<p>Loading map…</p>
<p>{t.map.loading}</p>
<style>{`
@keyframes spin {
from { transform: rotate(0deg); }

View File

@@ -7,14 +7,16 @@
import { useState } from 'react';
import styles from './PasswordGate.module.css';
import { useDictionary } from './I18nProvider';
interface PasswordGateProps {
slug: string;
title: string;
type?: 'subpage' | 'album' | 'journal';
type?: 'subpage' | 'album' | 'journal' | 'site';
}
export default function PasswordGate({ slug, title, type = 'subpage' }: PasswordGateProps) {
const t = useDictionary();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@@ -34,11 +36,11 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
if (res.ok) {
window.location.reload();
} else {
setError('Incorrect password. Please try again.');
setError(t.password.incorrect);
setPassword('');
}
} catch {
setError('Unable to verify password. Please try again later.');
setError(t.password.failed);
} finally {
setLoading(false);
}
@@ -48,7 +50,9 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
<div className={styles.gate}>
<div className={styles.card}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.subtitle}>This gallery is password-protected.</p>
<p className={styles.subtitle}>
{type === 'site' ? t.password.siteSubtitle : t.password.subtitle}
</p>
<form onSubmit={handleSubmit} className={styles.form}>
<input
@@ -56,8 +60,8 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
aria-label="Enter password"
placeholder={t.password.placeholder}
aria-label={t.password.placeholder}
className={styles.input}
autoFocus
required
@@ -83,10 +87,10 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
<circle cx="12" cy="12" r="10" strokeOpacity="0.25"></circle>
<path d="M12 2a10 10 0 0 1 10 10"></path>
</svg>
Verifying…
{t.password.verifying}
</>
) : (
'Enter'
t.password.submit
)}
</button>
</form>

View File

@@ -2,6 +2,7 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { encodeProofBitmask, decodeProofBitmask } from '@/lib/proofing';
import { useDictionary } from './I18nProvider';
interface ProofingContextType {
favorites: Set<string>;
@@ -30,6 +31,7 @@ export function ProofingProvider({
albumName?: string;
allowMailto?: boolean;
}) {
const t = useDictionary();
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [isFilterActive, setIsFilterActive] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -126,8 +128,12 @@ export function ProofingProvider({
selectedIndices.push(index + 1);
}
});
if (selectedIndices.length === 0) return `${albumName}: No photos selected.`;
return `${albumName} — Selected Photos (${selectedIndices.length}): #${selectedIndices.join(', #')}`;
if (selectedIndices.length === 0) return t.proofing.listEmpty(albumName);
return t.proofing.listSummary(
albumName,
selectedIndices.length,
`#${selectedIndices.join(', #')}`,
);
};
return (

View File

@@ -3,8 +3,10 @@
import React, { useState } from 'react';
import { useProofing } from './ProofingContext';
import { IconCheck, IconCopy, IconLink } from './Icons';
import { useDictionary } from './I18nProvider';
export function ProofingModal() {
const t = useDictionary();
const proofing = useProofing();
const [copiedState, setCopiedState] = useState<'none' | 'link' | 'list'>('none');
@@ -36,10 +38,8 @@ export function ProofingModal() {
};
const handleMailto = () => {
const subject = encodeURIComponent(`Photo Selection (${favorites.size} items)`);
const body = encodeURIComponent(
`Hello,\n\nHere is my photo selection:\n\n${getFormattedList()}\n\nShare Link: ${getProofingUrl()}\n\nBest regards,`,
);
const subject = encodeURIComponent(t.proofing.mailSubject(favorites.size));
const body = encodeURIComponent(t.proofing.mailBody(getFormattedList(), getProofingUrl()));
window.location.href = `mailto:?subject=${subject}&body=${body}`;
};
@@ -82,12 +82,12 @@ export function ProofingModal() {
}}
>
<h3 style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }}>
❤️ Selection ({favorites.size})
{t.proofing.modalTitle(favorites.size)}
</h3>
<button
type="button"
onClick={() => setIsModalOpen(false)}
aria-label="Close modal"
aria-label={t.proofing.closeModal}
style={{
background: 'none',
border: 'none',
@@ -103,8 +103,7 @@ export function ProofingModal() {
</div>
<p style={{ fontSize: '0.9rem', opacity: 0.8, marginBottom: '1rem' }}>
You have selected {favorites.size} photo{favorites.size === 1 ? '' : 's'}. Choose an
export option below to share your selection:
{t.proofing.intro(favorites.size)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
@@ -127,11 +126,11 @@ export function ProofingModal() {
>
{copiedState === 'link' ? (
<>
<IconCheck size={15} aria-hidden="true" /> Link Copied!
<IconCheck size={15} aria-hidden="true" /> {t.proofing.linkCopied}
</>
) : (
<>
<IconLink size={15} aria-hidden="true" /> Copy Shareable Link
<IconLink size={15} aria-hidden="true" /> {t.proofing.copyLink}
</>
)}
</button>
@@ -155,11 +154,11 @@ export function ProofingModal() {
>
{copiedState === 'list' ? (
<>
<IconCheck size={15} aria-hidden="true" /> List Copied!
<IconCheck size={15} aria-hidden="true" /> {t.proofing.listCopied}
</>
) : (
<>
<IconCopy size={15} aria-hidden="true" /> Copy Text List (#1, #2...)
<IconCopy size={15} aria-hidden="true" /> {t.proofing.copyList}
</>
)}
</button>
@@ -182,14 +181,14 @@ export function ProofingModal() {
cursor: 'pointer',
}}
>
✉️ Send Email to Photographer
{t.proofing.sendEmail}
</button>
)}
<button
type="button"
onClick={() => {
if (confirm('Clear all selected favorites?')) {
if (confirm(t.proofing.confirmClear)) {
clearFavorites();
setIsModalOpen(false);
}
@@ -204,7 +203,7 @@ export function ProofingModal() {
opacity: 0.8,
}}
>
Clear Selection
{t.proofing.clearSelection}
</button>
</div>
</div>

View File

@@ -6,10 +6,12 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useDictionary } from './I18nProvider';
const SHOW_THRESHOLD = 300;
export function ScrollToTop() {
const t = useDictionary();
const [visible, setVisible] = useState(false);
const frame = useRef<number | null>(null);
@@ -41,8 +43,8 @@ export function ScrollToTop() {
<button
className={`scroll-to-top ${visible ? 'scroll-to-top--visible' : ''}`}
onClick={scrollToTop}
aria-label="Scroll to top"
title="Scroll to top"
aria-label={t.theme.scrollToTop}
title={t.theme.scrollToTop}
type="button"
tabIndex={visible ? 0 : -1}
aria-hidden={!visible}

View File

@@ -46,8 +46,12 @@ export function SetupScreen() {
}}
>
<p style={{ marginBottom: '1rem' }}>
To get started, follow these steps in your repository or mounted <code>content/</code>{' '}
volume:
The quickest way is the setup wizard at <code>/install</code>. It is gated by a one-time
token printed to the server log at startup (<code>docker logs</code>) — append it as{' '}
<code>?token=…</code>.
</p>
<p style={{ marginBottom: '1rem' }}>
Or configure it by hand, in your repository or mounted <code>content/</code> volume:
</p>
<ol style={{ margin: 0, paddingLeft: '1.25rem' }}>
<li style={{ marginBottom: '0.5rem' }}>

View File

@@ -7,6 +7,7 @@ import Link from 'next/link';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { listJournalEntries } from '@/lib/admin/journal-service';
import { getServerDictionary } from '@/lib/i18n/server';
export async function SubpageNav() {
const [subpages, standaloneAlbums, journalEntries] = await Promise.all([
@@ -17,6 +18,7 @@ export async function SubpageNav() {
// EXPERIMENTAL: external nav links from settings.yaml, appended after the
// internal entries. Sanitised to http(s) in getConfig().
const navLinks = getConfig().navLinks;
const t = getServerDictionary();
const hasPublicJournal = journalEntries.some((e) => !e.frontmatter.draft);
@@ -29,7 +31,7 @@ export async function SubpageNav() {
))}
{hasPublicJournal && (
<Link href="/journal" className="header__nav-link">
Journal
{t.nav.journal}
</Link>
)}
{standaloneAlbums.map((album) => (

View File

@@ -1,14 +1,22 @@
/**
* ThemeToggle — dark/light mode switch.
*
* Persists preference in localStorage and sets data-theme on <html>.
* Defaults to dark theme (matching existing design).
* A visitor's own choice is kept in localStorage and always wins. Without one,
* the starting mode comes from `mode:` in settings.yaml, which the layout has
* already rendered onto <html> as data-default-theme — reading it from the DOM
* rather than taking a prop keeps this component mountable anywhere without
* threading config through the tree. `auto` follows the operating system.
*
* It used to hardcode dark, so an operator who preferred light had no way to
* give visitors anything else (#512).
*
* Uses useSyncExternalStore to satisfy React strict mode lint rules.
*/
'use client';
import { useSyncExternalStore, useCallback } from 'react';
import { useDictionary } from './I18nProvider';
type Theme = 'dark' | 'light';
@@ -29,6 +37,22 @@ function getServerSnapshot(): Theme {
return 'dark'; // SSR default
}
/** What the OS asks for. Dark unless it says otherwise, as before. */
function systemTheme(): Theme {
return typeof window !== 'undefined' &&
window.matchMedia?.('(prefers-color-scheme: light)').matches
? 'light'
: 'dark';
}
/** The configured starting mode, read off the <html> the server rendered. */
function configuredTheme(): Theme {
const mode = document.documentElement.getAttribute('data-default-theme');
if (mode === 'light' || mode === 'dark') return mode;
// 'auto', or an older layout that renders no attribute at all.
return systemTheme();
}
function applyTheme(theme: Theme) {
currentTheme = theme;
if (typeof document !== 'undefined') {
@@ -40,13 +64,15 @@ function applyTheme(theme: Theme) {
// Initialize from localStorage on module load (client-side only)
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('theme') as Theme | null;
currentTheme = stored === 'light' ? 'light' : 'dark';
const stored = localStorage.getItem('theme');
currentTheme = stored === 'light' || stored === 'dark' ? stored : configuredTheme();
document.documentElement.setAttribute('data-theme', currentTheme);
}
export function ThemeToggle() {
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const t = useDictionary();
const label = theme === 'dark' ? t.theme.switchToLight : t.theme.switchToDark;
const toggle = useCallback(() => {
applyTheme(theme === 'dark' ? 'light' : 'dark');
@@ -56,9 +82,9 @@ export function ThemeToggle() {
<button
className="theme-toggle"
onClick={toggle}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
aria-label={label}
type="button"
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={label}
>
{theme === 'dark' ? (
// Sun icon