release: v0.20.0
This commit is contained in:
@@ -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
26
components/ExifText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
259
components/__tests__/LightboxZoom.test.tsx
Normal file
259
components/__tests__/LightboxZoom.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
97
components/__tests__/ThemeToggle.test.tsx
Normal file
97
components/__tests__/ThemeToggle.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
74
components/__tests__/useSwipe.test.tsx
Normal file
74
components/__tests__/useSwipe.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user