release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View File

@@ -17,8 +17,9 @@
import React, { useState, useEffect, useCallback, useLayoutEffect } from 'react';
import { IconPalette, IconX } from './Icons';
import { getFontsCssUrl } from '@/lib/config/theme';
import { PRESET_IDS, PRESET_REGISTRY } from '@/lib/config/presets';
const PRESETS = ['studio', 'studio-modern', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
const PRESETS = [...PRESET_IDS];
const FRAMES = ['none', 'passepartout', 'shadow'];
const HERO_STYLES = ['split', 'fullbleed', 'minimal', 'stacked', 'typographic', 'mosaic'];
const GRID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
@@ -26,26 +27,13 @@ const ASPECT_RATIOS = ['1', '3/2', '2/3', '4/3', '16/9', 'auto'];
type Tab = 'theme' | 'grid' | 'server';
// Font stacks for each preset (must match THEME_PRESETS in config.ts)
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> = {
studio: { heading: 'Playfair Display', body: 'DM Sans', caption: 'EB Garamond' },
'studio-modern': { heading: 'Archivo', body: 'Archivo', caption: 'IBM Plex Mono' },
minimal: { heading: 'Geist', body: 'Geist', caption: 'IBM Plex Mono' },
editorial: { heading: 'Bodoni Moda', body: 'Newsreader', caption: 'Spectral' },
classic: { heading: 'Cinzel', body: 'Crimson Pro', caption: 'Crimson Pro' },
noir: { heading: 'Libre Baskerville', body: 'Source Sans 3', caption: 'Space Mono' },
monograph: { heading: 'Instrument Serif', body: 'Inter', caption: 'IBM Plex Mono' },
};
// Font stacks and accents for each preset, from the preset registry.
const PRESET_FONTS: Record<string, { heading: string; body: string; caption: string }> =
Object.fromEntries(PRESET_REGISTRY.map((p) => [p.id, p.theme.fonts]));
const PRESET_ACCENTS: Record<string, string> = {
studio: 'var(--accent-studio, #e60012)',
'studio-modern': 'var(--accent-studio-modern, #e60012)',
minimal: 'var(--accent-minimal, #000000)',
editorial: 'var(--accent-editorial, #8B2500)',
classic: 'var(--accent-classic, #c49a3c)',
noir: 'var(--accent-noir, #ff6b35)',
monograph: 'var(--accent-monograph, #333333)',
};
const PRESET_ACCENTS: Record<string, string> = Object.fromEntries(
PRESET_REGISTRY.map((p) => [p.id, `var(--accent-${p.id}, ${p.theme.accent})`]),
);
function getAttr(attr: string): string {
return document.documentElement.getAttribute(attr) ?? '';

26
components/ExifText.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { Fragment } from 'react';
import { APERTURE_SIGN } from '@/lib/exif';
/**
* EXIF text with the aperture sign kept lowercase.
*
* Several presets set their EXIF lines in `text-transform: uppercase`, which
* maps "ƒ" to "Ƒ" — "Ƒ/4" is not how an aperture is written. The sign is
* wrapped in `.exif-fsign` (app/globals.css), which opts it out of the
* transform; the rest of the line keeps the preset's casing.
*/
export default function ExifText({ text }: { text: string }) {
const parts = text.split(APERTURE_SIGN);
// One wrapping span: the hero chip is a flex container, and loose text
// around the sign would become separate flex items that drop the spaces.
return (
<span>
{parts.map((part, i) => (
<Fragment key={i}>
{i > 0 && <span className="exif-fsign">{APERTURE_SIGN}</span>}
{part}
</Fragment>
))}
</span>
);
}

View File

@@ -20,10 +20,28 @@ interface FadeInProps {
className?: string;
/** Extra inline styles for the wrapper (e.g. flex sizing in justified grids) */
style?: React.CSSProperties;
/**
* The wrapper element. `figure` lets a photo grid hang a figcaption next to
* the photo; the reset in tokens.css removes its default margins, so it
* lays out exactly like the div.
*/
as?: 'div' | 'figure';
}
export function FadeIn({ children, delay = 0, direction = 'up', className, style }: FadeInProps) {
const ref = useRef<HTMLDivElement>(null);
export function FadeIn({
children,
delay = 0,
direction = 'up',
className,
style,
as: Wrapper = 'div',
}: FadeInProps) {
// HTMLElement, not HTMLDivElement: the wrapper may be a <figure>. Set
// through a callback, which either element's ref accepts.
const ref = useRef<HTMLElement | null>(null);
const setRef = useCallback((el: HTMLElement | null) => {
ref.current = el;
}, []);
const reveal = useCallback(() => {
ref.current?.classList.add('fade-in--visible');
@@ -54,12 +72,12 @@ export function FadeIn({ children, delay = 0, direction = 'up', className, style
}, [reveal]);
return (
<div
ref={ref}
<Wrapper
ref={setRef}
className={`fade-in ${direction === 'up' ? 'fade-in--up' : ''} ${className ?? ''}`}
style={{ ...style, transitionDelay: `${delay}ms` }}
>
{children}
</div>
</Wrapper>
);
}

View File

@@ -13,6 +13,7 @@
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
import Image from 'next/image';
import { useDictionary } from './I18nProvider';
import ExifText from './ExifText';
interface HeroImage {
src: string;
@@ -138,7 +139,7 @@ function HeroExifChip({ text }: { text?: string }) {
if (!text) return null;
return (
<span className="hero__exif-chip" aria-hidden="true">
{text}
<ExifText text={text} />
</span>
);
}

View File

@@ -21,6 +21,12 @@ export interface LeafletMarker {
lng: number;
/** Inner HTML of the marker icon — already escaped by the caller. */
html: string;
/**
* The marker's accessible name and hover title (plain text, not HTML).
* Without it a screen reader announces only what the icon shows — for the
* location map, a bare photo count like "12".
*/
label?: string;
popupHtml?: string;
}
@@ -115,7 +121,14 @@ export function LeafletMap({
iconAnchor: [16, 16],
popupAnchor: [0, -20],
}),
...(m.label ? { title: m.label } : {}),
});
// Leaflet makes the icon a focusable role="button"; the label
// becomes its name once the element exists.
if (m.label) {
const label = m.label;
marker.on('add', () => marker.getElement()?.setAttribute('aria-label', label));
}
if (m.popupHtml) marker.bindPopup(m.popupHtml, { maxWidth: 260, minWidth: 200 });
return marker;
}),

View File

@@ -11,6 +11,17 @@
animation: lightbox-fade-in var(--transition-normal) ease forwards;
}
/* The overlay is translucent (0.85–0.92), so the fixed page chrome under it
showed through: the header's wordmark and red dot, and the proofing bar,
as ghosts beside the photo. They cannot be used while the viewer is open,
so they are hidden for as long as it is mounted (it is portaled straight
into <body>). */
:global(body):has(> .overlay) :global(.header),
:global(body):has(> .overlay) :global(.proofing-sticky-bar),
:global(body):has(> .overlay) :global(.scroll-to-top) {
visibility: hidden;
}
/* The overlay is dark in both modes — see "Studio-Inspired Lightbox Defaults"
below and the studio-modern block, which both paint it near-black. In light
mode the page's text tokens are near-black too, so every control that reads
@@ -634,7 +645,7 @@
}
:global([data-preset='studio-modern']) .close:hover {
color: var(--accent);
color: var(--accent-text-dark, var(--accent));
}
:global([data-preset='studio-modern']) .close svg {
@@ -660,7 +671,7 @@
:global([data-preset='studio-modern']) .nav:hover {
background: none;
border-color: var(--accent);
color: var(--accent);
color: var(--accent-text-dark, var(--accent));
}
:global([data-preset='studio-modern']) .navPrev {
@@ -689,7 +700,7 @@
:global([data-preset='studio-modern']) .infoToggle:hover {
background: none;
border-color: var(--accent);
color: var(--accent);
color: var(--accent-text-dark, var(--accent));
}
/* The EXIF strip owns the bottom edge in this preset, so the panel opens from
@@ -797,3 +808,123 @@
right: 8px;
}
}
/* ══ Zoom (#467) ═════════════════════════════════════════════════
The photo keeps its fit-size box; zooming is a transform on the images in
it (hooks/useZoom.ts), so the layout never moves. */
/* The viewer owns every touch on a zoomable photo: a pinch zooms the photo,
not the page. Swipes still arrive as touch events (hooks/useSwipe.ts). */
.zoomable {
touch-action: none;
cursor: zoom-in;
}
.zoomable.zoomed {
cursor: grab;
}
.zoomable.zoomed:active {
cursor: grabbing;
}
/* Once zoom has been used the photo may outgrow its box; the overlay, which
covers the viewport, does the clipping instead. */
.zoomEngaged {
overflow: visible;
}
.overlayZoom {
overflow: hidden;
}
/* The full-resolution file sits exactly on the preview's box. It takes no
pointer events, so gestures always land on the same element. */
.zoomImage {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
pointer-events: none;
user-select: none;
-webkit-user-drag: none;
}
.zoomImageLoaded {
opacity: 1;
}
.zoomToggle {
padding: 8px 10px;
}
.zoomSpinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: zoom-spin 800ms linear infinite;
}
@keyframes zoom-spin {
to {
transform: rotate(360deg);
}
}
/* "Not available" — a toast that fades on its own. */
.zoomNotice {
position: fixed;
left: 50%;
bottom: 76px;
z-index: 1001;
max-width: min(28rem, calc(100vw - 32px));
padding: 8px 14px;
transform: translateX(-50%);
background: rgba(20, 20, 20, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: var(--radius-md);
color: rgba(255, 255, 255, 0.9);
font-family: var(--font-sans);
font-size: 0.8rem;
text-align: center;
pointer-events: none;
animation: zoom-notice 4s ease forwards;
}
@keyframes zoom-notice {
0%,
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}
:global([data-preset='studio-modern']) .zoomNotice {
top: 76px;
bottom: auto;
}
@media (prefers-reduced-motion: reduce) {
/* Still shown long enough to read, then gone without a fade. */
.zoomNotice {
animation: zoom-notice-static 4s steps(1, end) forwards;
}
.zoomSpinner {
animation-duration: 2s;
}
}
@keyframes zoom-notice-static {
to {
opacity: 0;
}
}

View File

@@ -2,22 +2,25 @@
* Lightbox — fullscreen image viewer with navigation and EXIF info.
*
* Features:
* - Full-resolution image display
* - The 1440px preview, and — where zoom is on — the full-resolution file,
* fetched only when the visitor zooms (#467; gestures in hooks/useZoom.ts)
* - 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
* - Preloads adjacent previews (never the full-resolution files)
*/
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import { useExif } from '@/hooks/useExif';
import { useSwipe } from '@/hooks/useSwipe';
import { useZoom } from '@/hooks/useZoom';
import { zoomFitsDevice } from '@/lib/zoom';
import styles from './Lightbox.module.css';
import { useProofing } from './useProofing';
import { IconHeart } from './Icons';
@@ -25,7 +28,7 @@ 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';
import { APERTURE_SIGN, formatCamera, formatLens } from '@/lib/exif';
import { buildPhotoPermalink } from '@/lib/photoHash';
import { trapTabKey } from '@/lib/focusTrap';
import { canonicalImageUrl } from '@/lib/imageSize';
@@ -57,6 +60,23 @@ interface LightboxProps {
showExifToggle?: boolean;
}
const COARSE_POINTER = '(pointer: coarse)';
function subscribeCoarsePointer(onChange: () => void): () => void {
const query = window.matchMedia?.(COARSE_POINTER);
query?.addEventListener?.('change', onChange);
return () => query?.removeEventListener?.('change', onChange);
}
/** A touch device, where very large images are not offered for zoom. */
function useCoarsePointer(): boolean {
return useSyncExternalStore(
subscribeCoarsePointer,
() => !!window.matchMedia?.(COARSE_POINTER)?.matches,
() => false,
);
}
export function Lightbox({
assets,
currentIndex,
@@ -89,8 +109,26 @@ export function Lightbox({
const [slideshowSeconds, setSlideshowSeconds] = useState<SlideshowSpeed>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const closeBtnRef = useRef<HTMLButtonElement>(null);
// State, not a ref: useZoom attaches its listeners when this element
// appears, which is after the portal mounts.
const [imageContainer, setImageContainer] = useState<HTMLDivElement | null>(null);
const previewRef = useRef<HTMLImageElement>(null);
const current = assets[currentIndex];
const coarsePointer = useCoarsePointer();
const zoomable =
current.type === 'image' &&
!!current.zoomUrl &&
zoomFitsDevice(current.zoomWidth, current.zoomHeight, coarsePointer);
const zoom = useZoom({
photoKey: current.id,
naturalWidth: zoomable ? current.zoomWidth : undefined,
imageRef: previewRef,
surface: imageContainer,
});
const { zoomed, reset: resetZoom, zoomIn, zoomOut } = zoom;
/** The zoom control and keys are offered for this photo. */
const canZoom = zoomable && zoom.enabled;
const proofing = useProofing();
const isFav = proofing && current ? proofing.isFavorite(current.id) : false;
const [mounted, setMounted] = useState(false);
@@ -199,11 +237,13 @@ export function Lightbox({
setSlideshowSeconds((current) => nextSlideshowSpeed(current));
}, []);
// Paused, not stopped, while zoomed: advancing would throw away the view
// someone is inspecting. Back at fit, the slideshow carries on.
useEffect(() => {
if (slideshowSeconds === null) return;
if (slideshowSeconds === null || zoom.zoomed) return;
const timer = setInterval(onNext, slideshowSeconds * 1000);
return () => clearInterval(timer);
}, [slideshowSeconds, onNext]);
}, [slideshowSeconds, onNext, zoom.zoomed]);
/*
* Any deliberate move through the album stops the slideshow. Someone
@@ -297,9 +337,11 @@ export function Lightbox({
preload(currentIndex - 1);
}, [currentIndex, assets]);
// While zoomed one finger pans the photo, so it must not also navigate.
const { handleTouchStart, handleTouchEnd } = useSwipe({
onSwipeLeft: manualNext,
onSwipeRight: manualPrev,
enabled: !zoom.zoomed,
});
/*
@@ -334,12 +376,13 @@ export function Lightbox({
switch (action) {
case 'close':
// 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.
// the zoom, then 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 — that 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 (zoomed) resetZoom();
else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {});
else onClose();
break;
@@ -366,6 +409,15 @@ export function Lightbox({
e.preventDefault();
handleCopyLink();
break;
case 'zoomIn':
if (canZoom) zoomIn();
break;
case 'zoomOut':
if (canZoom) zoomOut();
break;
case 'zoomReset':
if (zoomed) resetZoom();
break;
case 'shortcutList':
toggleShortcuts();
break;
@@ -390,6 +442,11 @@ export function Lightbox({
};
}, [
canFullscreen,
canZoom,
zoomed,
resetZoom,
zoomIn,
zoomOut,
current,
cycleSlideshow,
handleCopyLink,
@@ -424,6 +481,7 @@ export function Lightbox({
if (shortcut.availability === 'exifPanel') return showExifToggle;
if (shortcut.availability === 'fullscreen') return canFullscreen;
if (shortcut.availability === 'download') return Boolean(current?.downloadUrl);
if (shortcut.availability === 'zoom') return canZoom;
return true;
}).map((shortcut) => {
let label: string = t.lightbox[shortcut.labelKey];
@@ -438,7 +496,7 @@ export function Lightbox({
const lightboxJsx = (
<div
className={styles.overlay}
className={`${styles.overlay}${zoom.requested ? ` ${styles.overlayZoom}` : ''}`}
ref={overlayRef}
onClick={handleOverlayClick}
onTouchStart={handleTouchStart}
@@ -491,7 +549,15 @@ export function Lightbox({
{/* Image or Video */}
<div
className={styles.imageContainer}
ref={setImageContainer}
className={[
styles.imageContainer,
canZoom ? styles.zoomable : '',
zoom.zoomed ? styles.zoomed : '',
zoom.requested ? styles.zoomEngaged : '',
]
.filter(Boolean)
.join(' ')}
style={{
backgroundColor: current.dominantColor || '#000',
backgroundImage:
@@ -516,15 +582,41 @@ export function Lightbox({
) : (
// eslint-disable-next-line @next/next/no-img-element
<img
ref={previewRef}
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
src={canonicalImageUrl(current.previewUrl)}
alt={current.caption ?? ''}
draggable={false}
style={zoom.imageStyle}
onLoad={() => setImageLoaded(true)}
onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
/>
)}
{/*
The full-resolution file, mounted on the first zoom and not before.
It covers the preview's own box under the same transform, so when it
arrives it replaces the scaled-up preview without moving. Keyed by
the photo, so a new photo never shows the last one's file.
*/}
{current.type === 'image' &&
current.zoomUrl &&
zoom.requested &&
zoom.status !== 'failed' && (
// eslint-disable-next-line @next/next/no-img-element
<img
key={current.id}
className={`${styles.zoomImage}${zoom.status === 'loaded' ? ` ${styles.zoomImageLoaded}` : ''}`}
src={current.zoomUrl}
alt=""
aria-hidden="true"
draggable={false}
style={zoom.imageStyle}
onLoad={(e) => zoom.onFullLoad(e.currentTarget)}
onError={zoom.onFullError}
/>
)}
{watermark?.enabled && watermark.text && (
<div
className={`${styles.watermark} ${
@@ -572,6 +664,19 @@ export function Lightbox({
: t.lightbox.slideshowRunning(slideshowSeconds)}
</p>
{/* Zoom loading, for screen readers; sighted visitors see the spinner
in the zoom button. */}
<p className="sr-only" role="status">
{zoom.zoomed && zoom.status === 'loading' ? t.lightbox.zoomLoading : ''}
</p>
{/* No full-resolution file: say so once, and stay on the preview. */}
{zoom.status === 'failed' && (
<div key={current.id} className={styles.zoomNotice} role="status">
{t.lightbox.zoomUnavailable}
</div>
)}
{/*
One bottom bar rather than five separately anchored controls.
They used to carry hard-coded offsets, which broke in two ways: the
@@ -646,6 +751,40 @@ export function Lightbox({
</div>
<div className={`${styles.bottomBarGroup} ${styles.bottomBarRight}`}>
{/* Zoom to 1:1 and back (#467). Icon only: the bar is already full at
phone width, and the magnifier says what it does. */}
{canZoom && (
<button
className={`${styles.infoToggle} ${styles.zoomToggle}`}
onClick={() => zoom.toggle()}
aria-pressed={zoom.zoomed}
aria-busy={zoom.zoomed && zoom.status === 'loading'}
aria-label={zoom.zoomed ? t.lightbox.zoomOut : t.lightbox.zoomIn}
title={zoom.zoomed ? t.lightbox.zoomOutTitle : t.lightbox.zoomInTitle}
>
{zoom.zoomed && zoom.status === 'loading' ? (
<span className={styles.zoomSpinner} aria-hidden="true" />
) : (
<svg
aria-hidden="true"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16" y2="16" />
<line x1="8" y1="11" x2="14" y2="11" />
{!zoom.zoomed && <line x1="11" y1="8" x2="11" y2="14" />}
</svg>
)}
</button>
)}
{/* Proofing favorite button */}
{proofing && current && (
<button
@@ -711,10 +850,12 @@ export function Lightbox({
</span>
</div>
)}
{exifData.lensModel && (
{formatLens(exifData.lensModel, exifData.model) && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>{t.lightbox.lens}</span>
<span className={styles.exifValue}>{exifData.lensModel}</span>
<span className={styles.exifValue}>
{formatLens(exifData.lensModel, exifData.model)}
</span>
</div>
)}
{exifData.focalLength && (
@@ -726,7 +867,9 @@ export function Lightbox({
{exifData.fNumber && (
<div className={styles.exifRow}>
<span className={styles.exifLabel}>{t.lightbox.aperture}</span>
<span className={styles.exifValue}>ƒ/{exifData.fNumber}</span>
<span className={styles.exifValue}>
<span className="exif-fsign">{APERTURE_SIGN}</span>/{exifData.fNumber}
</span>
</div>
)}
{exifData.exposureTime && (

View File

@@ -83,6 +83,8 @@ export function MapView() {
lat: loc.lat,
lng: loc.lng,
html: `<div class="map-marker">${loc.photoCount}</div>`,
// "Dźwirzyno, Polen, 12 Fotos" instead of a bare "12".
label: [heading, subheading, t.common.photos(loc.photoCount)].filter(Boolean).join(', '),
popupHtml: `
<div class="map-popup">
<img src="${escapeHtml(loc.coverUrl)}" alt="${escapeHtml(heading)}" class="map-popup__cover" loading="lazy" />

View File

@@ -2,6 +2,7 @@
.gate {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - var(--header-height));
@@ -43,23 +44,17 @@
width: 100%;
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border-subtle);
border: 1px solid var(--input-border);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-sans);
font-size: 0.9rem;
transition: border-color var(--transition-fast);
}
/* The base rule used to set `outline: none`, leaving a 1px border colour
change as the only focus signal. That is too little on a page whose only
purpose is typing, and a colour-only cue can be lost on colour-blind
visitors. The border change stays; the ring is added. */
.input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-color: var(--accent);
}
/* No focus rule of its own: the global one in app/tokens.css draws the ring.
This used to add an accent ring on top of an accent border — two red
outlines on the one page whose only purpose is typing, and a different
signal from every other control on the site. */
.input::placeholder {
color: var(--text-muted);
@@ -114,6 +109,17 @@
}
}
/* Legal links under the site gate's card: the footer the gate has no room
for. The links themselves take the footer's .footer__legal-link styling, so
every preset sets them the way it sets its footer. */
.legal {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px 20px;
margin-top: 24px;
}
/* ── Responsive ──────────────────────────────────── */
@media (max-width: 640px) {
.card {

View File

@@ -6,6 +6,7 @@
*/
import { useState } from 'react';
import Link from 'next/link';
import styles from './PasswordGate.module.css';
import { useDictionary } from './I18nProvider';
@@ -13,6 +14,11 @@ interface PasswordGateProps {
slug: string;
title: string;
type?: 'subpage' | 'album' | 'journal' | 'page' | 'site';
/**
* Links shown under the card. The site gate passes its legal pages here,
* which a locked site still serves (proxy.ts, UNGATED_PAGES).
*/
legalLinks?: { href: string; label: string }[];
}
/** Whole seconds from a `Retry-After` header, or null when there is none. */
@@ -21,7 +27,12 @@ function retryAfterSeconds(res: Response): number | null {
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
}
export default function PasswordGate({ slug, title, type = 'subpage' }: PasswordGateProps) {
export default function PasswordGate({
slug,
title,
type = 'subpage',
legalLinks = [],
}: PasswordGateProps) {
const t = useDictionary();
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -119,6 +130,15 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
</p>
)}
</div>
{legalLinks.length > 0 && (
<nav className={styles.legal} aria-label={t.password.legalNavAria}>
{legalLinks.map(({ href, label }) => (
<Link key={href} href={href} className="footer__legal-link">
{label}
</Link>
))}
</nav>
)}
</div>
);
}

View File

@@ -24,7 +24,7 @@ const barStyle: React.CSSProperties = {
flexWrap: 'wrap',
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
borderRadius: 'var(--radius-lg)',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
@@ -111,7 +111,7 @@ export function ProofSessionControls() {
color: isFilterActive ? 'var(--on-accent, #fff)' : 'var(--text-primary)',
border: 'none',
padding: '6px 14px',
borderRadius: '20px',
borderRadius: 'var(--radius-md)',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',

View File

@@ -15,7 +15,7 @@
'use client';
import { useSyncExternalStore, useCallback } from 'react';
import { useSyncExternalStore, useCallback, useEffect } from 'react';
import { useDictionary } from './I18nProvider';
type Theme = 'dark' | 'light';
@@ -57,16 +57,63 @@ function applyTheme(theme: Theme) {
currentTheme = theme;
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
try {
localStorage.setItem('theme', theme);
} catch {
// Blocked site data: the switch still applies for this page view.
}
}
listeners.forEach((cb) => cb());
}
// Initialize from localStorage on module load (client-side only)
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('theme');
currentTheme = stored === 'light' || stored === 'dark' ? stored : configuredTheme();
/** The visitor's own choice, if they made one and storage is readable. */
function storedTheme(): Theme | null {
try {
const stored = localStorage.getItem('theme');
return stored === 'light' || stored === 'dark' ? stored : null;
} catch {
return null;
}
}
/**
* `mode: auto` follows the OS while the page is open, not only at load: a
* visitor whose system switches to dark at sunset sees the site follow.
* Nothing is stored, so the site keeps following — a stored choice (or a fixed
* configured mode) takes it out of the loop. Exported for the test.
*/
export function followSystemTheme() {
if (storedTheme() !== null) return;
const mode = document.documentElement.getAttribute('data-default-theme');
if (mode === 'light' || mode === 'dark') return;
currentTheme = systemTheme();
document.documentElement.setAttribute('data-theme', currentTheme);
listeners.forEach((cb) => cb());
}
/**
* Re-resolve the mode once the component is mounted and write it back onto
* <html>. A hard 404 is served as Next's bare error shell: the inline script
* never runs there, the module above loaded before the layout's attributes
* existed, and the client render then writes the server's data-theme over
* whatever was applied. Without this, a visitor who stored light got a dark
* 404 page. Exported for the test.
*/
export function syncThemeFromDom() {
currentTheme = storedTheme() ?? configuredTheme();
document.documentElement.setAttribute('data-theme', currentTheme);
listeners.forEach((cb) => cb());
}
// Initialize on module load (client-side only). The inline script in the root
// layout (lib/themeScript.ts) has already set data-theme by the same rules;
// this brings the module state in line with it.
if (typeof window !== 'undefined') {
currentTheme = storedTheme() ?? configuredTheme();
document.documentElement.setAttribute('data-theme', currentTheme);
window
.matchMedia?.('(prefers-color-scheme: light)')
?.addEventListener?.('change', followSystemTheme);
}
export function ThemeToggle() {
@@ -74,6 +121,10 @@ export function ThemeToggle() {
const t = useDictionary();
const label = theme === 'dark' ? t.theme.switchToLight : t.theme.switchToDark;
useEffect(() => {
syncThemeFromDom();
}, []);
const toggle = useCallback(() => {
applyTheme(theme === 'dark' ? 'light' : 'dark');
}, [theme]);

View File

@@ -48,3 +48,31 @@ describe('localizedLeafletPrefix', () => {
);
});
});
describe('LeafletMap marker names', () => {
it('names a marker by its label, not by the count it shows', async () => {
const { container } = render(
<I18nProvider locale="de">
<LeafletMap
markers={[
{
lat: 54.16,
lng: 15.39,
html: '<div class="map-marker">12</div>',
label: 'Dźwirzyno, Polen, 12 Fotos',
},
]}
/>
</I18nProvider>,
);
const icon = await waitFor(() => {
const el = container.querySelector('.leaflet-marker-icon');
if (!el) throw new Error('marker not placed yet');
return el;
});
expect(icon.getAttribute('aria-label')).toBe('Dźwirzyno, Polen, 12 Fotos');
expect(icon.getAttribute('title')).toBe('Dźwirzyno, Polen, 12 Fotos');
expect(icon.getAttribute('role')).toBe('button');
});
});

View File

@@ -0,0 +1,259 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
import { useState } from 'react';
import { Lightbox } from '../Lightbox';
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
/**
* The lightbox zoom (#467), wired up: the control, the keys, Esc order,
* double-click, swipe while zoomed, and that the full-resolution file is
* requested only when the visitor zooms.
*
* jsdom does no layout, so the fit box is stubbed: every image is laid out at
* 1000×600, at the origin. A 6000px photo on a 1× screen is 1:1 at 6×.
*/
const photo = (id: string, extra: Partial<PhotoItem> = {}): PhotoItem => ({
id,
type: 'image',
thumbUrl: `/t/${id}`,
previewUrl: `/p/${id}`,
exifUrl: `/e/${id}`,
aspectRatio: 5 / 3,
...extra,
});
const ZOOMABLE = photo('z1', { zoomUrl: '/api/zoom/a/z1', zoomWidth: 6000, zoomHeight: 3600 });
const ZOOMABLE_2 = photo('z2', { zoomUrl: '/api/zoom/a/z2', zoomWidth: 6000, zoomHeight: 3600 });
const PLAIN = photo('p1');
const VIDEO: PhotoItem = {
...photo('v1', { zoomUrl: '/api/zoom/a/v1', zoomWidth: 6000, zoomHeight: 3600 }),
type: 'video',
videoUrl: '/v/v1',
};
function Harness({
assets,
start = 0,
onClose = () => {},
onNextSpy,
}: {
assets: PhotoItem[];
start?: number;
onClose?: () => void;
onNextSpy?: () => void;
}) {
const [index, setIndex] = useState(start);
return (
<Lightbox
assets={assets}
currentIndex={index}
onClose={onClose}
onNext={() => {
onNextSpy?.();
setIndex((i) => (i + 1) % assets.length);
}}
onPrev={() => setIndex((i) => (i - 1 + assets.length) % assets.length)}
/>
);
}
const restore: Array<() => void> = [];
function stub<T extends object>(target: T, key: string, get: () => unknown) {
const original = Object.getOwnPropertyDescriptor(target, key);
Object.defineProperty(target, key, { configurable: true, get });
restore.push(() => {
if (original) Object.defineProperty(target, key, original);
else delete (target as Record<string, unknown>)[key];
});
}
beforeEach(() => {
stub(HTMLElement.prototype, 'offsetWidth', function (this: HTMLElement) {
return this.tagName === 'IMG' ? 1000 : 0;
});
stub(HTMLElement.prototype, 'offsetHeight', function (this: HTMLElement) {
return this.tagName === 'IMG' ? 600 : 0;
});
stub(window, 'devicePixelRatio', () => 1);
window.matchMedia = vi.fn(() => ({ matches: false })) as unknown as typeof window.matchMedia;
});
afterEach(() => {
cleanup();
while (restore.length) restore.pop()!();
});
const dialog = () => screen.getByRole('dialog');
const preview = () => dialog().querySelector<HTMLImageElement>('img[src^="/p/"]')!;
const fullImage = () => dialog().querySelector<HTMLImageElement>('img[src^="/api/zoom/"]');
const zoomButton = () =>
within(dialog()).queryByRole('button', { name: /Zoom to full resolution|Fit the photo/ });
const key = (k: string) => fireEvent.keyDown(document, { key: k });
describe('the zoom control', () => {
it('is offered for a zoomable photo', () => {
render(<Harness assets={[ZOOMABLE]} />);
expect(zoomButton()).not.toBeNull();
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('false');
});
it('is absent without a zoom source, and for a video', () => {
render(<Harness assets={[PLAIN, VIDEO]} />);
expect(zoomButton()).toBeNull();
fireEvent.click(screen.getByLabelText('Next photo'));
expect(zoomButton()).toBeNull();
});
it('toggles 1:1 and back', () => {
render(<Harness assets={[ZOOMABLE]} />);
fireEvent.click(zoomButton()!);
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('true');
expect(preview().style.transform).toContain('scale(6)');
fireEvent.click(zoomButton()!);
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('false');
expect(preview().style.transform).toContain('scale(1)');
});
});
describe('on a touch device (review of #830)', () => {
const coarse = () => {
window.matchMedia = vi.fn((q: string) => ({
matches: q === '(pointer: coarse)',
})) as unknown as typeof window.matchMedia;
};
const HUGE = photo('h1', { zoomUrl: '/api/zoom/a/h1', zoomWidth: 9520, zoomHeight: 6336 });
it('offers no zoom above 50 MP', () => {
coarse();
render(<Harness assets={[HUGE]} />);
expect(zoomButton()).toBeNull();
key('+');
expect(fullImage()).toBeNull();
});
it('still offers it for a smaller photo', () => {
coarse();
render(
<Harness
assets={[photo('s1', { zoomUrl: '/api/zoom/a/s1', zoomWidth: 4032, zoomHeight: 3024 })]}
/>,
);
expect(zoomButton()).not.toBeNull();
});
it('keeps the large photo zoomable with a mouse', () => {
render(<Harness assets={[HUGE]} />);
expect(zoomButton()).not.toBeNull();
});
});
describe('the full-resolution file', () => {
it('is not requested on open or on the next photo', () => {
render(<Harness assets={[ZOOMABLE, ZOOMABLE_2]} />);
expect(fullImage()).toBeNull();
fireEvent.click(screen.getByLabelText('Next photo'));
expect(fullImage()).toBeNull();
});
it('is requested on the first zoom, under the same transform as the preview', () => {
render(<Harness assets={[ZOOMABLE]} />);
key('+');
const full = fullImage();
expect(full?.getAttribute('src')).toBe('/api/zoom/a/z1');
expect(full!.style.transform).toBe(preview().style.transform);
});
it('when missing, says so, returns to fit and withdraws the control', () => {
render(<Harness assets={[ZOOMABLE]} />);
key('+');
fireEvent.error(fullImage()!);
expect(screen.getByText('Full resolution is not available for this photo.')).toBeTruthy();
expect(zoomButton()).toBeNull();
expect(preview().style.transform).toContain('scale(1)');
});
it('starts over on the next photo', () => {
render(<Harness assets={[ZOOMABLE, ZOOMABLE_2]} />);
key('+');
expect(fullImage()).not.toBeNull();
fireEvent.click(screen.getByLabelText('Next photo'));
expect(fullImage()).toBeNull();
expect(preview().style.transform).toBe('');
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('false');
});
});
describe('keys', () => {
it('+ / = zoom in by steps up to 1:1, - out, 0 resets', () => {
render(<Harness assets={[ZOOMABLE]} />);
key('+');
expect(preview().style.transform).toContain('scale(2)');
key('=');
expect(preview().style.transform).toContain('scale(4)');
key('+');
expect(preview().style.transform).toContain('scale(6)');
key('-');
expect(preview().style.transform).toContain('scale(3)');
key('0');
expect(preview().style.transform).toContain('scale(1)');
});
it('leave a photo without zoom alone', () => {
render(<Harness assets={[PLAIN]} />);
key('+');
expect(preview().style.transform).toBe('');
});
it('Esc leaves the zoom first, and closes the viewer only after that', () => {
const onClose = vi.fn();
render(<Harness assets={[ZOOMABLE]} onClose={onClose} />);
key('+');
key('Escape');
expect(onClose).not.toHaveBeenCalled();
expect(zoomButton()!.getAttribute('aria-pressed')).toBe('false');
key('Escape');
expect(onClose).toHaveBeenCalledTimes(1);
});
it('lists the zoom keys only where zoom is offered', () => {
const { unmount } = render(<Harness assets={[ZOOMABLE]} />);
key('?');
expect(screen.queryByText('Zoom in / out')).not.toBeNull();
unmount();
render(<Harness assets={[PLAIN]} />);
key('?');
expect(screen.queryByText('Zoom in / out')).toBeNull();
});
});
describe('gestures', () => {
it('double-click goes to 1:1 around the cursor, and back', () => {
render(<Harness assets={[ZOOMABLE]} />);
const surface = preview().parentElement!;
fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 });
// Fit box centre (500, 300); the cursor 100px right of it stays put at 6×.
expect(preview().style.transform).toBe('translate3d(-500px, 0px, 0) scale(6)');
fireEvent.doubleClick(surface, { clientX: 600, clientY: 300 });
expect(preview().style.transform).toContain('scale(1)');
});
it('no swipe navigation while zoomed', () => {
const onNextSpy = vi.fn();
render(<Harness assets={[ZOOMABLE, ZOOMABLE_2]} onNextSpy={onNextSpy} />);
const swipeLeft = () => {
fireEvent.touchStart(dialog(), { touches: [{ clientX: 600, clientY: 300 }] });
fireEvent.touchEnd(dialog(), {
touches: [],
changedTouches: [{ clientX: 300, clientY: 300 }],
});
};
key('+');
swipeLeft();
expect(onNextSpy).not.toHaveBeenCalled();
key('0');
swipeLeft();
expect(onNextSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,97 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
/**
* `mode: auto` follows OS changes while the page is open (T-10/T-18), unless
* the visitor made a choice or the operator fixed a mode.
*/
let osLight = false;
let onChange: (() => void) | null = null;
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; a Map-backed stub stands in.
function stubStorage() {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
});
}
beforeEach(() => {
vi.resetModules();
osLight = false;
onChange = null;
stubStorage();
document.documentElement.setAttribute('data-default-theme', 'auto');
document.documentElement.removeAttribute('data-theme');
vi.stubGlobal('matchMedia', (query: string) => ({
media: query,
get matches() {
return query === '(prefers-color-scheme: light)' ? osLight : false;
},
addEventListener: (_: string, cb: () => void) => {
onChange = cb;
},
}));
});
afterEach(() => {
vi.unstubAllGlobals();
});
async function load() {
await import('../ThemeToggle');
return document.documentElement;
}
describe('ThemeToggle under mode: auto', () => {
it('follows the OS when it switches', async () => {
const html = await load();
expect(html.getAttribute('data-theme')).toBe('dark');
osLight = true;
onChange?.();
expect(html.getAttribute('data-theme')).toBe('light');
// Following the OS is not a choice — nothing is stored.
expect(localStorage.getItem('theme')).toBeNull();
});
it('keeps a stored choice when the OS switches', async () => {
localStorage.setItem('theme', 'dark');
const html = await load();
osLight = true;
onChange?.();
expect(html.getAttribute('data-theme')).toBe('dark');
});
it('keeps a fixed configured mode when the OS switches', async () => {
document.documentElement.setAttribute('data-default-theme', 'dark');
const html = await load();
osLight = true;
onChange?.();
expect(html.getAttribute('data-theme')).toBe('dark');
});
});
describe('ThemeToggle on a page rendered from the error shell', () => {
it('re-applies the stored mode after the client render overwrote it', async () => {
localStorage.setItem('theme', 'light');
const html = await load();
// The 404 shell's client render writes the server default back onto <html>.
html.setAttribute('data-theme', 'dark');
const { syncThemeFromDom } = await import('../ThemeToggle');
syncThemeFromDom();
expect(html.getAttribute('data-theme')).toBe('light');
});
it('picks up a configured mode that only appeared after the module loaded', async () => {
document.documentElement.removeAttribute('data-default-theme');
const html = await load();
expect(html.getAttribute('data-theme')).toBe('dark');
html.setAttribute('data-default-theme', 'light');
const { syncThemeFromDom } = await import('../ThemeToggle');
syncThemeFromDom();
expect(html.getAttribute('data-theme')).toBe('light');
});
});

View File

@@ -69,6 +69,19 @@ function surfaces(preset: string, mode: 'dark' | 'light'): string[] {
return SURFACES.map((s) => token(own, s) ?? token(base, s)!).filter((v) => v.startsWith('#'));
}
/**
* The --error a preset resolves to in one mode, in cascade order: a preset
* may set its own where the default red misses 4.5:1 on its surfaces. The
* preset's dark block comes after `[data-theme='light']` in tokens.css with
* the same specificity, so in light mode it beats the global light value.
*/
function errorToken(preset: string, mode: 'dark' | 'light'): string | undefined {
const dark = token(block(`[data-preset='${preset}']`), 'error');
if (mode === 'dark') return dark ?? token(ROOT, 'error');
const light = token(block(`[data-preset='${preset}'][data-theme='light']`), 'error');
return light ?? dark ?? token(LIGHT, 'error');
}
describe('--error (T-8)', () => {
it('is defined for both modes', () => {
expect(token(ROOT, 'error')).toBeDefined();
@@ -78,7 +91,7 @@ describe('--error (T-8)', () => {
for (const preset of Object.keys(THEME_PRESETS)) {
for (const mode of ['dark', 'light'] as const) {
it(`${preset} (${mode}): clears 4.5:1 on every surface`, () => {
const error = parse(token(mode === 'dark' ? ROOT : LIGHT, 'error')!);
const error = parse(errorToken(preset, mode)!);
for (const surface of surfaces(preset, mode)) {
expect(ratio(error, parse(surface)), surface).toBeGreaterThanOrEqual(4.5);
}

View File

@@ -0,0 +1,74 @@
// @vitest-environment jsdom
import { describe, it, expect, vi } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useSwipe } from '@/hooks/useSwipe';
/**
* A swipe is one finger (#467). A pinch that drifts sideways used to jump to
* the next photo, because only the first touch was ever looked at.
*/
type T = { clientX: number; clientY: number };
const at = (x: number, y = 100): T => ({ clientX: x, clientY: y });
const ev = (touches: T[], changedTouches: T[] = []) =>
({ touches, changedTouches }) as unknown as TouchEvent;
function setup(enabled = true) {
const onSwipeLeft = vi.fn();
const onSwipeRight = vi.fn();
const { result, rerender } = renderHook(
(props: { enabled: boolean }) =>
useSwipe({ onSwipeLeft, onSwipeRight, enabled: props.enabled }),
{ initialProps: { enabled } },
);
return { onSwipeLeft, onSwipeRight, handlers: () => result.current, rerender };
}
describe('useSwipe', () => {
it('turns a one-finger horizontal drag into a swipe', () => {
const s = setup();
s.handlers().handleTouchStart(ev([at(300)]));
s.handlers().handleTouchEnd(ev([], [at(100)]));
expect(s.onSwipeLeft).toHaveBeenCalledTimes(1);
s.handlers().handleTouchStart(ev([at(100)]));
s.handlers().handleTouchEnd(ev([], [at(300)]));
expect(s.onSwipeRight).toHaveBeenCalledTimes(1);
});
it('ignores a pinch, however far it drifts', () => {
const s = setup();
// First finger down, second joins, both drift left, lift one by one.
s.handlers().handleTouchStart(ev([at(400)]));
s.handlers().handleTouchStart(ev([at(400), at(500)]));
s.handlers().handleTouchEnd(ev([at(150)], [at(250)]));
s.handlers().handleTouchEnd(ev([], [at(100)]));
expect(s.onSwipeLeft).not.toHaveBeenCalled();
expect(s.onSwipeRight).not.toHaveBeenCalled();
});
it('counts the next one-finger swipe again after a pinch', () => {
const s = setup();
s.handlers().handleTouchStart(ev([at(400), at(500)]));
s.handlers().handleTouchEnd(ev([], [at(100)]));
s.handlers().handleTouchStart(ev([at(300)]));
s.handlers().handleTouchEnd(ev([], [at(100)]));
expect(s.onSwipeLeft).toHaveBeenCalledTimes(1);
});
it('does nothing while disabled (the lightbox is zoomed)', () => {
const s = setup(false);
s.handlers().handleTouchStart(ev([at(300)]));
s.handlers().handleTouchEnd(ev([], [at(100)]));
expect(s.onSwipeLeft).not.toHaveBeenCalled();
});
it('ignores a gesture that started disabled even if it ends enabled', () => {
// Pinched out to fit and let go: the pan that preceded it is no swipe.
const s = setup(false);
s.handlers().handleTouchStart(ev([at(300)]));
s.rerender({ enabled: true });
s.handlers().handleTouchEnd(ev([], [at(100)]));
expect(s.onSwipeLeft).not.toHaveBeenCalled();
});
});