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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
components/I18nProvider.tsx
Normal file
29
components/I18nProvider.tsx
Normal 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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user