release: v0.19.0

Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of
[CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md)
for the full list.

Highlights: client proofing links (#683), custom content pages (#738),
asset picker search (#740), concurrent-edit detection (#742), env-locked
settings (#739), an accessibility baseline (#741), three security fixes
with advisories (#737, #755, #759), and a pre-release quality pass
(#780–#797).

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ralksta <ralksta@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ralf <rohlenbo79@gmail.com>
Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com>
Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com>
Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
This commit is contained in:
Ralksta
2026-09-29 22:50:30 +02:00
committed by GitHub
parent 5f251923f0
commit ad8b09700b
297 changed files with 19398 additions and 1800 deletions

View File

@@ -7,6 +7,12 @@
* Spam protection without a captcha, which would be another third party: a
* honeypot field people never see, and `startedAt`, the time the form was
* rendered, which the server compares against the submission.
*
* `startedAt` is the server's clock, handed down by the page. The check
* subtracts it from the server's clock at submission, and a stamp from the
* visitor's device made that a comparison between two clocks: a phone running
* a few minutes fast looked like a bot posting before the form existed, and
* its message was dropped behind a thank-you page.
*/
import { useState } from 'react';
@@ -14,15 +20,14 @@ import { useDictionary } from './I18nProvider';
interface Props {
retentionDays: number;
/** `Date.now()` on the server when the page was rendered. */
renderedAt: number;
}
type State = 'idle' | 'sending' | 'done';
export default function ContactForm({ retentionDays }: Props) {
export default function ContactForm({ retentionDays, renderedAt }: Props) {
const t = useDictionary().contact;
// When the form became usable. A bot posting straight to the endpoint has
// none; one filling the form on load is faster than MIN_FILL_MS.
const [startedAt] = useState(() => Date.now());
const [state, setState] = useState<State>('idle');
const [error, setError] = useState('');
@@ -40,7 +45,9 @@ export default function ContactForm({ retentionDays }: Props) {
email: data.get('email'),
message: data.get('message'),
website: data.get('website'),
startedAt,
// A bot posting straight to the endpoint has no stamp; one filling
// the form on load is faster than MIN_FILL_MS.
startedAt: renderedAt,
}),
});
if (res.ok) {

View File

@@ -2,13 +2,17 @@
* HeroCarousel — crossfade between hero images on a timer.
*
* If only one image is provided, renders a static image with no timer.
* When multiple images are provided, crossfades between them every 6 seconds.
* When multiple images are provided, crossfades between them every 6 seconds
* — unless the visitor prefers reduced motion, or has paused it with the
* pause control (WCAG 2.2.2: anything that moves on its own for more than
* five seconds needs a way to stop it, #696).
*/
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
import Image from 'next/image';
import { useDictionary } from './I18nProvider';
interface HeroImage {
src: string;
@@ -19,23 +23,51 @@ interface HeroImage {
interface HeroCarouselProps {
images: HeroImage[];
/**
* `sizes` for next/image. Most heroes are full-width; only the split hero's
* image panel is half the viewport, and only above the mobile breakpoint.
*/
sizes?: string;
}
const INTERVAL_MS = 6000;
export const INTERVAL_MS = 6000;
export function HeroCarousel({ images }: HeroCarouselProps) {
const REDUCED_MOTION = '(prefers-reduced-motion: reduce)';
function subscribeReducedMotion(onChange: () => void) {
const mql = window.matchMedia(REDUCED_MOTION);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
/** True when the visitor asked the OS for less motion. False during SSR. */
function usePrefersReducedMotion(): boolean {
return useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_MOTION).matches,
() => false,
);
}
export function HeroCarousel({ images, sizes = '100vw' }: HeroCarouselProps) {
const t = useDictionary();
const [currentIndex, setCurrentIndex] = useState(0);
const reducedMotion = usePrefersReducedMotion();
// null = the visitor has not touched the control; the default then follows
// the motion preference. An explicit play overrides reduced motion.
const [pausedByUser, setPausedByUser] = useState<boolean | null>(null);
const paused = pausedByUser ?? reducedMotion;
const advance = useCallback(() => {
setCurrentIndex((prev) => (prev + 1) % images.length);
}, [images.length]);
useEffect(() => {
if (images.length <= 1) return;
if (images.length <= 1 || paused) return;
const timer = setInterval(advance, INTERVAL_MS);
return () => clearInterval(timer);
}, [images.length, advance]);
}, [images.length, advance, paused]);
if (images.length === 0) {
return <div className="hero__image-placeholder" />;
@@ -50,7 +82,7 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
alt=""
className="hero__image"
fill
sizes="50vw"
sizes={sizes}
priority
{...(img.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
@@ -70,7 +102,7 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
alt=""
className={`hero__carousel-image${i === currentIndex ? ' hero__carousel-image--active' : ''}`}
fill
sizes="50vw"
sizes={sizes}
priority={i === 0}
{...(img.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
@@ -78,6 +110,25 @@ export function HeroCarousel({ images }: HeroCarouselProps) {
/>
))}
<HeroExifChip text={images[currentIndex]?.exif} />
<button
type="button"
className="hero__carousel-toggle"
aria-label={paused ? t.home.playSlideshow : t.home.pauseSlideshow}
title={paused ? t.home.playSlideshow : t.home.pauseSlideshow}
data-paused={paused}
onClick={() => setPausedByUser(!paused)}
>
{paused ? (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor">
<path d="M8 5v14l11-7z" />
</svg>
) : (
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="5" width="4" height="14" />
<rect x="14" y="5" width="4" height="14" />
</svg>
)}
</button>
</>
);
}

View File

@@ -13,6 +13,8 @@
import { useEffect, useRef } from 'react';
import 'leaflet/dist/leaflet.css';
import { useDictionary } from './I18nProvider';
import { escapeHtml } from '@/lib/escapeHtml';
export interface LeafletMarker {
lat: number;
@@ -33,14 +35,14 @@ interface LeafletMapProps {
onError?: (err: unknown) => void;
}
/** Escapes special HTML characters for Leaflet's string-based popups and icons. */
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
export { escapeHtml } from '@/lib/escapeHtml';
/**
* Leaflet's attribution prefix with its English link title swapped for
* `title`. The rest — link, flag, name — is Leaflet's own markup, kept as is.
*/
export function localizedLeafletPrefix(prefix: string, title: string): string {
return prefix.replace(/title="[^"]*"/, `title="${escapeHtml(title)}"`);
}
export function LeafletMap({
@@ -53,6 +55,11 @@ export function LeafletMap({
}: LeafletMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
// Read inside the effect through a ref: the locale never changes on a
// mounted page, and a new dictionary object must not rebuild the map.
const t = useDictionary();
const labelsRef = useRef(t.map);
labelsRef.current = t.map;
// Callers rebuild the markers array on every render; the map is only
// rebuilt when its content changes.
@@ -71,8 +78,17 @@ export function LeafletMap({
const L = (await import('leaflet')).default;
if (cancelled || !containerRef.current || mapRef.current) return;
const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true });
// The zoom buttons' titles (which Leaflet also uses as their aria-label)
// and the credit's link title are English in Leaflet; the site's
// dictionary supplies them instead.
const labels = labelsRef.current;
const map = L.map(containerRef.current, { zoomControl: false, attributionControl: true });
mapRef.current = map;
L.control.zoom({ zoomInTitle: labels.zoomIn, zoomOutTitle: labels.zoomOut }).addTo(map);
const prefix = map.attributionControl.options.prefix;
if (typeof prefix === 'string') {
map.attributionControl.setPrefix(localizedLeafletPrefix(prefix, labels.leafletTitle));
}
// OpenStreetMap's own tiles: free to use with attribution and a Referer,
// which the Referrer-Policy sends. CARTO's Dark Matter, used before,

View File

@@ -11,6 +11,20 @@
animation: lightbox-fade-in var(--transition-normal) ease forwards;
}
/* 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
them (close, arrows, counter, the bottom-bar buttons, and the focus ring in
app/tokens.css, which is var(--text-primary)) sat at 1.1–2:1. Inside the
overlay they are pointed at the lightbox tokens instead. Dark mode is left
alone: its tokens are already light, and tinted per preset. */
:global([data-theme='light']) .overlay {
--text-primary: var(--lightbox-text);
--text-secondary: var(--lightbox-text-secondary);
--text-muted: var(--lightbox-text-muted);
--border-subtle: var(--lightbox-border);
}
@keyframes lightbox-fade-in {
from {
opacity: 0;
@@ -109,10 +123,10 @@
top: 50%;
transform: translateY(-50%);
z-index: 1001;
background: rgba(255, 255, 255, 0.08);
background: var(--lightbox-control-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--lightbox-control-border);
border-radius: 50%;
width: 48px;
height: 48px;
@@ -127,7 +141,7 @@
}
.nav:hover {
background: rgba(255, 255, 255, 0.15);
background: var(--lightbox-control-bg-hover);
color: var(--text-primary);
}
@@ -203,10 +217,10 @@
align-items: center;
gap: 6px;
z-index: 1001;
background: rgba(255, 255, 255, 0.08);
background: var(--lightbox-control-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--lightbox-control-border);
border-radius: var(--radius-md);
padding: 8px 12px;
color: var(--text-secondary);
@@ -219,10 +233,22 @@
}
.infoToggle:hover {
background: rgba(255, 255, 255, 0.15);
background: var(--lightbox-control-bg-hover);
color: var(--text-primary);
}
/* A saved favourite. Set by class rather than inline, so a preset's own
colour for the toggle (studio-modern's) still applies to the unsaved state —
the inline `inherit` it used to fall back to picked up the page's near-black
text in light mode (1.08:1). The extra class outranks that preset block,
which comes later in this file, hover included: studio-modern hovers in
the accent, which a dark custom accent drops to about 1.3:1. */
.bottomBar .infoToggle.favActive,
.bottomBar .infoToggle.favActive:hover {
color: var(--lightbox-fav);
font-weight: 600;
}
/* Anchors are not buttons; strip the underline so it matches its neighbours. */
.downloadToggle {
text-decoration: none;

View File

@@ -19,7 +19,7 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import { useExif } from '@/hooks/useExif';
import { useSwipe } from '@/hooks/useSwipe';
import styles from './Lightbox.module.css';
import { useProofing } from './ProofingContext';
import { useProofing } from './useProofing';
import { IconHeart } from './Icons';
import { useDictionary } from './I18nProvider';
// From lib/config/schema directly: lib/config/index.ts pulls in `fs`, which a
@@ -27,6 +27,8 @@ import { useDictionary } from './I18nProvider';
import { resolveWatermarkOpacity } from '@/lib/config/schema';
import { formatCamera } from '@/lib/exif';
import { buildPhotoPermalink } from '@/lib/photoHash';
import { trapTabKey } from '@/lib/focusTrap';
import { canonicalImageUrl } from '@/lib/imageSize';
import { nextSlideshowSpeed, type SlideshowSpeed } from '@/lib/slideshow';
import {
LIGHTBOX_SHORTCUTS,
@@ -105,6 +107,34 @@ export function Lightbox({
}
}, [mounted]);
/*
* Focus stays inside the dialog while it is open, and goes back to the grid
* when it closes (#696) — to the tile of the photo that was on screen last,
* not necessarily the one that opened the viewer, so a keyboard visitor who
* browsed ahead continues from where they are. Tiles mark themselves with
* `data-lightbox-index` inside a `data-lightbox-group`; without them the
* opener gets focus back.
*/
const indexRef = useRef(currentIndex);
useEffect(() => {
indexRef.current = currentIndex;
}, [currentIndex]);
useEffect(() => {
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const onTab = (e: KeyboardEvent) => {
if (overlayRef.current) trapTabKey(overlayRef.current, e);
};
document.addEventListener('keydown', onTab);
return () => {
document.removeEventListener('keydown', onTab);
const group = opener?.closest('[data-lightbox-group]');
const tile = group?.querySelector<HTMLElement>(`[data-lightbox-index="${indexRef.current}"]`);
const target = tile ?? opener;
if (target?.isConnected) target.focus();
};
}, []);
// Reset EXIF data when switching images; refetch if panel is open
useEffect(() => {
clearExif();
@@ -260,7 +290,7 @@ export function Lightbox({
const preload = (index: number) => {
if (index >= 0 && index < assets.length && assets[index].type !== 'video') {
const img = new Image();
img.src = assets[index].previewUrl;
img.src = canonicalImageUrl(assets[index].previewUrl);
}
};
preload(currentIndex + 1);
@@ -487,7 +517,7 @@ export function Lightbox({
// eslint-disable-next-line @next/next/no-img-element
<img
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
src={current.previewUrl}
src={canonicalImageUrl(current.previewUrl)}
alt={current.caption ?? ''}
draggable={false}
onLoad={() => setImageLoaded(true)}
@@ -609,9 +639,7 @@ export function Lightbox({
{/* Counter */}
<div className={styles.counter} aria-live="polite" aria-atomic="true">
<span className="sr-only">
Photo {currentIndex + 1} of {assets.length}
</span>
<span className="sr-only">{t.lightbox.position(currentIndex + 1, assets.length)}</span>
<span aria-hidden="true">
{currentIndex + 1} / {assets.length}
</span>
@@ -621,11 +649,7 @@ export function Lightbox({
{/* Proofing favorite button */}
{proofing && current && (
<button
className={styles.infoToggle}
style={{
color: isFav ? '#ff4d4f' : 'inherit',
fontWeight: isFav ? 600 : 400,
}}
className={`${styles.infoToggle}${isFav ? ` ${styles.favActive}` : ''}`}
onClick={() => proofing.toggleFavorite(current.id)}
aria-label={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}
title={isFav ? t.proofing.removeFromFavorites : t.proofing.addToFavorites}

View File

@@ -115,8 +115,9 @@ export function MapView() {
{loading && (
<div
className="map-container__loading"
role="alert"
aria-live="polite"
// A loading notice is a status, not an alert (role="alert" is
// assertive and contradicted the aria-live="polite" beside it).
role="status"
style={{
position: 'absolute',
inset: 0,

View File

@@ -1,23 +1,31 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { focusableIn } from '@/lib/focusTrap';
import { usePathname } from 'next/navigation';
import { useDictionary } from './I18nProvider';
/** Must match the `min-width: 641px` query in globals.css. */
const PHONE_QUERY = '(max-width: 640px)';
/**
* MobileNav — hamburger button + dropdown panel for the header links.
*
* `children` — Home, subpages, standalone albums, About, Map — is the exact
* same server-rendered markup the desktop header already used. It is never
* duplicated: `.header__nav-links` is `display: contents` above the mobile
* breakpoint, so on desktop this component is invisible and the links sit
* directly in `.header__nav`'s flex row exactly as before. Below the
* breakpoint the same markup becomes the dropdown panel's content, toggled
* by the `data-open` attribute rather than by mounting/unmounting (#590).
* duplicated: `.header__nav-links` is `display: contents` while the links fit
* in one row, so on desktop this component is invisible and the links sit
* directly in `.header__nav`'s flex row exactly as before. Otherwise the same
* markup becomes the dropdown panel's content, toggled by the `data-open`
* attribute rather than by mounting/unmounting (#590).
*
* The header used to scroll sideways with the scrollbar hidden on narrow
* viewports, so a visitor with more subpages than fit the width had no sign
* that any were missing.
* "Otherwise" is two conditions. Phones always get the menu, and the CSS says
* so on its own, before hydration. Wider viewports get it when the row is
* wider than the header — measured here, because it depends on how many
* subpages the site has and on the preset's type, and published as
* `data-collapsed`. The header used to scroll sideways with the scrollbar
* hidden instead, which on a tablet left About, Map and the theme toggle
* off-screen with no sign they existed.
*/
export function MobileNav({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
@@ -37,10 +45,61 @@ export function MobileNav({ children }: { children: React.ReactNode }) {
setOpen(false);
}
const buttonRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [collapsed, setCollapsed] = useState(false);
// The width the inline row needs. It can only be read while the row is laid
// out, so once collapsed this last reading is what a wider header is
// compared against; expanding re-measures, and collapses again if the
// reading was stale (a late web font, a different active link).
const neededRef = useRef(0);
// A layout effect, so a row that does not fit is swapped for the button
// before the browser paints it.
useLayoutEffect(() => {
const nav = buttonRef.current?.parentElement;
if (!nav) return;
const phone = typeof window.matchMedia === 'function' ? window.matchMedia(PHONE_QUERY) : null;
const measure = () => {
const isPhone = phone?.matches ?? false;
if (!collapsed && !isPhone) neededRef.current = nav.scrollWidth;
const next = isPhone || neededRef.current > nav.clientWidth;
if (next === collapsed) return;
setCollapsed(next);
// A panel left open would otherwise keep its backdrop and the page's
// scroll lock once the links are back in the row.
if (!next) setOpen(false);
};
measure();
const resize = typeof ResizeObserver === 'function' ? new ResizeObserver(measure) : null;
resize?.observe(nav);
phone?.addEventListener('change', measure);
let active = true;
document.fonts?.ready.then(() => {
if (active) measure();
});
return () => {
active = false;
resize?.disconnect();
phone?.removeEventListener('change', measure);
};
}, [collapsed, pathname]);
useEffect(() => {
if (!open) return;
// Focus moves into the panel when it opens (#696), so a keyboard or
// screen-reader visitor lands on the first link rather than behind it.
// Only when the panel is actually the dropdown — while the links sit in
// the row the button is hidden, so this never runs.
if (panelRef.current) focusableIn(panelRef.current)[0]?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
if (e.key === 'Escape') {
close();
// Esc returns focus to the button that opened the panel; otherwise
// it would be left on a link that is no longer visible.
buttonRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
document.body.style.overflow = 'hidden';
@@ -53,8 +112,10 @@ export function MobileNav({ children }: { children: React.ReactNode }) {
return (
<>
<button
ref={buttonRef}
type="button"
className="header__menu-btn"
data-collapsed={collapsed}
aria-expanded={open}
aria-controls="header-nav-panel"
aria-label={open ? t.nav.closeMenu : t.nav.openMenu}
@@ -90,7 +151,13 @@ export function MobileNav({ children }: { children: React.ReactNode }) {
)}
</button>
{open && <div className="header__nav-backdrop" onClick={close} aria-hidden="true" />}
<div id="header-nav-panel" className="header__nav-links" data-open={open}>
<div
ref={panelRef}
id="header-nav-panel"
className="header__nav-links"
data-open={open}
data-collapsed={collapsed}
>
{children}
</div>
</>

View File

@@ -91,7 +91,9 @@
.error {
margin-top: 12px;
font-size: 0.8rem;
color: #e74c3c;
/* Per-mode token; the fixed red it replaces measured 3.2–3.6:1 on the
light presets. */
color: var(--error);
font-weight: 400;
}

View File

@@ -12,7 +12,13 @@ import { useDictionary } from './I18nProvider';
interface PasswordGateProps {
slug: string;
title: string;
type?: 'subpage' | 'album' | 'journal' | 'site';
type?: 'subpage' | 'album' | 'journal' | 'page' | 'site';
}
/** Whole seconds from a `Retry-After` header, or null when there is none. */
function retryAfterSeconds(res: Response): number | null {
const seconds = Number.parseInt(res.headers.get('retry-after') ?? '', 10);
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
}
export default function PasswordGate({ slug, title, type = 'subpage' }: PasswordGateProps) {
@@ -35,9 +41,15 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
if (res.ok) {
window.location.reload();
} else {
} else if (res.status === 401) {
setError(t.password.incorrect);
setPassword('');
} else if (res.status === 429) {
// The limit says nothing about the password, which may well be right:
// keep it in the field and say when the next attempt will be heard.
setError(t.password.tooManyAttempts(retryAfterSeconds(res)));
} else {
setError(t.password.failed);
}
} catch {
setError(t.password.failed);
@@ -51,7 +63,13 @@ export default function PasswordGate({ slug, title, type = 'subpage' }: Password
<div className={styles.card}>
<h2 className={styles.title}>{title}</h2>
<p className={styles.subtitle}>
{type === 'site' ? t.password.siteSubtitle : t.password.subtitle}
{type === 'site'
? t.password.siteSubtitle
: type === 'page'
? t.password.pageSubtitle
: type === 'journal'
? t.password.journalSubtitle
: t.password.subtitle}
</p>
<form onSubmit={handleSubmit} className={styles.form}>

View File

@@ -0,0 +1,272 @@
'use client';
/**
* The selection bar and review dialog of a client proofing link.
*
* Stands in for the anonymous mode's bar and share/export modal: there is
* nothing to share (the link already is the share), the selection saves itself,
* and the one action that matters is submitting it to the photographer.
*/
import { useCallback, useState } from 'react';
import { useProofing } from './ProofingContext';
import { useDictionary } from './I18nProvider';
import { useModalDialog } from '@/hooks/useModalDialog';
const barStyle: React.CSSProperties = {
position: 'fixed',
bottom: '24px',
right: '24px',
left: 'auto',
zIndex: 990,
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
maxWidth: 'calc(100vw - 32px)',
};
const primaryButton: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm, 6px)',
background: 'var(--accent, #e60012)',
// Black or white, whichever the layout found to carry on this accent.
color: 'var(--on-accent, #fff)',
border: 'none',
fontWeight: 500,
// The same for the <button>s and the download <a>s, which would otherwise
// take the browser's differing defaults.
fontFamily: 'inherit',
fontSize: '0.9rem',
lineHeight: 1.3,
cursor: 'pointer',
textDecoration: 'none',
};
const secondaryButton: React.CSSProperties = {
...primaryButton,
background: 'var(--bg-card-hover)',
color: 'inherit',
border: '1px solid var(--border-subtle)',
};
export function ProofSessionControls() {
const t = useDictionary();
const proofing = useProofing();
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const close = useCallback(() => setOpen(false), []);
const cardRef = useModalDialog(close, open);
if (!proofing?.session) return null;
const { session, isFilterActive, setIsFilterActive, getSelectedTokens } = proofing;
const count = getSelectedTokens().length;
const p = t.proofSession;
const status =
session.saveState === 'saving'
? p.saving
: session.saveState === 'error'
? p.saveFailed
: session.saveState === 'saved'
? p.saved
: '';
const handleSubmit = async () => {
setSubmitting(true);
await session.submit();
setSubmitting(false);
};
const downloadHref = (scope: 'selection' | 'album') => `${session.archiveUrl}?scope=${scope}`;
const downloadsLeft = session.downloadsRemaining;
const canDownload = session.download !== 'none' && (downloadsLeft === null || downloadsLeft > 0);
// The selection ZIP is built from what the server has saved, so it is only
// offered once there is nothing left to save — a heart still waiting out the
// autosave debounce counts, or the ZIP would hold the previous selection.
const selectionSaved =
!session.unsaved && session.saveState !== 'saving' && session.saveState !== 'error';
return (
<>
<div className="proofing-sticky-bar proof-session-bar" style={barStyle}>
{(count > 0 || isFilterActive) && (
<button
type="button"
onClick={() => setIsFilterActive((prev) => !prev)}
aria-pressed={isFilterActive}
style={{
background: isFilterActive ? 'var(--accent, #e60012)' : 'var(--bg-card-hover)',
color: isFilterActive ? 'var(--on-accent, #fff)' : 'var(--text-primary)',
border: 'none',
padding: '6px 14px',
borderRadius: '20px',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
{isFilterActive ? t.proofing.showAll : t.proofing.selected(count)}
</button>
)}
<span
role="status"
aria-live="polite"
style={{
fontSize: '0.8rem',
// A failed save is an error, not a brand mark: the accent can be
// white (minimal) or pale, and the dimming would halve it again.
...(session.saveState === 'error' ? { color: 'var(--error)' } : { opacity: 0.75 }),
}}
>
{session.submitted ? p.locked : status}
</span>
<button
type="button"
onClick={() => setOpen(true)}
style={{
background: 'none',
border: 'none',
color: 'inherit',
fontSize: '0.85rem',
fontWeight: 600,
cursor: 'pointer',
textDecoration: 'underline',
}}
>
{session.submitted ? p.modalTitle(count) : p.review}
</button>
</div>
{open && (
<div
className="proofing-modal-overlay"
onClick={close}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<div
className="proofing-modal-card"
ref={cardRef}
role="dialog"
aria-modal="true"
aria-labelledby="proof-session-title"
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
borderRadius: 'var(--radius-md, 12px)',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.1))',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
boxShadow: '0 20px 40px rgba(0,0,0,0.5)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1rem',
}}
>
<h3
id="proof-session-title"
style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }}
>
{p.modalTitle(count)}
</h3>
<button
type="button"
onClick={close}
aria-label={t.proofing.closeModal}
style={{
background: 'none',
border: 'none',
color: 'currentColor',
fontSize: '1.5rem',
cursor: 'pointer',
opacity: 0.7,
lineHeight: 1,
}}
>
&times;
</button>
</div>
<p style={{ fontSize: '0.9rem', opacity: 0.85, marginBottom: '1rem' }}>
{session.submitted ? p.submitted : count === 0 ? p.empty : p.confirmSubmit(count)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{!session.submitted && (
<button
type="button"
onClick={handleSubmit}
disabled={count === 0 || submitting}
style={{
...primaryButton,
cursor: count === 0 || submitting ? 'not-allowed' : 'pointer',
opacity: count === 0 || submitting ? 0.6 : 1,
}}
>
{submitting ? p.submitting : p.submit}
</button>
)}
{canDownload && count > 0 && selectionSaved && (
<a
href={downloadHref('selection')}
onClick={session.countDownload}
style={secondaryButton}
>
{p.downloadSelection}
</a>
)}
{canDownload && session.download === 'album' && (
<a
href={downloadHref('album')}
onClick={session.countDownload}
style={secondaryButton}
>
{p.downloadAll}
</a>
)}
{session.download !== 'none' && downloadsLeft !== null && (
<p style={{ fontSize: '0.8rem', opacity: 0.7, margin: 0, textAlign: 'center' }}>
{downloadsLeft > 0 ? p.downloadsLeft(downloadsLeft) : t.download.limitReached}
</p>
)}
{session.saveState === 'error' && (
<p role="alert" style={{ fontSize: '0.85rem', margin: 0 }}>
{p.saveFailed}
</p>
)}
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -1,10 +1,52 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { encodeProofBitmask, decodeProofBitmask } from '@/lib/proofing';
import { useDictionary } from './I18nProvider';
import { ProofingContext } from './useProofing';
interface ProofingContextType {
export { useProofing } from './useProofing';
/** What the server tells a client proofing page about its session. */
export interface ProofSessionInit {
/** The link token — the capability for this session's API routes. */
token: string;
/** Asset tokens already selected, from the server. */
selected: string[];
submitted: boolean;
download: 'none' | 'selection' | 'album';
/** ZIP downloads left, or null for unlimited. */
downloadsRemaining: number | null;
}
export type ProofSaveState = 'idle' | 'saving' | 'saved' | 'error';
/**
* Session mode: a client proofing link (/proof/<token>). The selection lives on
* the server instead of localStorage and the URL, and can be submitted once.
*/
export interface ProofSessionState {
saveState: ProofSaveState;
/**
* Hearts the server has not confirmed yet — including those still waiting
* out the autosave debounce, while `saveState` reads 'idle' or 'saved'.
*/
unsaved: boolean;
submitted: boolean;
/** Save anything pending, then lock the selection. Resolves to success. */
submit: () => Promise<boolean>;
download: ProofSessionInit['download'];
downloadsRemaining: number | null;
/** Count a download that was just started against `downloadsRemaining`. */
countDownload: () => void;
/** The ZIP route for this session. */
archiveUrl: string;
}
/** How long the selection may sit unsaved after the last heart. */
const SAVE_DEBOUNCE_MS = 600;
export interface ProofingContextType {
favorites: Set<string>;
toggleFavorite: (token: string) => void;
isFavorite: (token: string) => boolean;
@@ -18,27 +60,68 @@ interface ProofingContextType {
/** Selected asset tokens in album order — the payload for a ZIP download. */
getSelectedTokens: () => string[];
allowMailto: boolean;
/**
* `encodeEmail()` of the "email to photographer" recipient, when one is
* configured (#736). The modal decodes it in the browser; without it the
* button is hidden.
*/
encodedMailto?: string;
/** The ZIP endpoint when the album offers downloads; undefined otherwise. */
downloadArchiveUrl?: string;
/** Present on a client proofing link only. */
session?: ProofSessionState;
}
const ProofingContext = createContext<ProofingContextType | null>(null);
export function ProofingProvider({
children,
albumTokens = [],
albumName = 'Gallery',
allowMailto = true,
encodedMailto,
downloadArchiveUrl,
session: sessionInit,
}: {
children: React.ReactNode;
albumTokens?: string[];
albumName?: string;
allowMailto?: boolean;
encodedMailto?: string;
downloadArchiveUrl?: string;
session?: ProofSessionInit;
}) {
const t = useDictionary();
const [favorites, setFavorites] = useState<Set<string>>(new Set());
// A session's selection comes from the server, so it can be rendered on the
// first pass; the anonymous mode hydrates from the browser in an effect below.
const [favorites, setFavorites] = useState<Set<string>>(
() => new Set(sessionInit?.selected ?? []),
);
const [saveState, setSaveState] = useState<ProofSaveState>('idle');
const [submitted, setSubmitted] = useState(Boolean(sessionInit?.submitted));
/**
* Edits made vs. edits the server has confirmed. A counter rather than a
* dirty flag, so a heart clicked while a save is in flight is not marked
* saved by that earlier request.
*/
const [edits, setEdits] = useState(0);
const editsRef = useRef(0);
const savedEdits = useRef(0);
/** `savedEdits` for rendering: what makes `unsaved` clear once a save lands. */
const [savedEditsShown, setSavedEditsShown] = useState(0);
/**
* Starts from the page load and counts down with each download started here,
* so the dialog does not keep offering a download the limit has used up.
*/
const [downloadsRemaining, setDownloadsRemaining] = useState(
sessionInit?.downloadsRemaining ?? null,
);
useEffect(() => {
editsRef.current = edits;
}, [edits]);
/** The selection as the save callbacks see it; kept current by the effect below. */
const favoritesRef = useRef(favorites);
useEffect(() => {
favoritesRef.current = favorites;
}, [favorites]);
const [isFilterActive, setIsFilterActive] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
@@ -48,6 +131,7 @@ export function ProofingProvider({
// an effect: both sources are browser-only, so reading them during render would
// break SSR and produce a hydration mismatch.
useEffect(() => {
if (sessionInit) return;
try {
const saved = localStorage.getItem(storageKey);
const initialSet = new Set<string>();
@@ -72,14 +156,103 @@ export function ProofingProvider({
// Mount-time hydration from an external store, not a render cascade:
// the state lands in the same commit and never re-triggers this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setFavorites(initialSet);
} catch (e) {
console.error('Failed to load favorites', e);
}
}, [storageKey, albumTokens]);
}, [storageKey, albumTokens, sessionInit]);
const sessionToken = sessionInit?.token;
/** Send the current selection, in album order. Resolves to success. */
const saveNow = useCallback(async (): Promise<boolean> => {
if (!sessionToken) return true;
const version = editsRef.current;
setSaveState('saving');
const selected = albumTokens.filter((token) => favoritesRef.current.has(token));
try {
const res = await fetch(`/api/proof/${encodeURIComponent(sessionToken)}/selection`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assets: selected }),
});
if (res.status === 409) {
// Submitted from another tab or device: show the locked state here too.
setSubmitted(true);
setSaveState('saved');
return false;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
savedEdits.current = Math.max(savedEdits.current, version);
setSavedEditsShown(savedEdits.current);
// A heart clicked while this request was in flight has its own save queued.
if (editsRef.current <= savedEdits.current) setSaveState('saved');
return true;
} catch {
setSaveState('error');
return false;
}
}, [sessionToken, albumTokens]);
// Debounced autosave: a burst of hearts becomes one request.
useEffect(() => {
if (!sessionToken || edits <= savedEdits.current) return;
const timer = setTimeout(() => void saveNow(), SAVE_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [edits, sessionToken, saveNow]);
// Leaving with an unsaved heart: send it anyway. keepalive lets the request
// outlive the page, which a normal fetch in `pagehide` would not.
useEffect(() => {
if (!sessionToken) return;
const flush = () => {
if (editsRef.current <= savedEdits.current) return;
const selected = albumTokens.filter((token) => favoritesRef.current.has(token));
void fetch(`/api/proof/${encodeURIComponent(sessionToken)}/selection`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assets: selected }),
keepalive: true,
}).catch(() => {});
};
window.addEventListener('pagehide', flush);
return () => window.removeEventListener('pagehide', flush);
}, [sessionToken, albumTokens]);
const submit = useCallback(async (): Promise<boolean> => {
if (!sessionToken) return false;
if (editsRef.current > savedEdits.current && !(await saveNow())) return false;
try {
const res = await fetch(`/api/proof/${encodeURIComponent(sessionToken)}/submit`, {
method: 'POST',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setSubmitted(true);
return true;
} catch {
setSaveState('error');
return false;
}
}, [sessionToken, saveNow]);
/** A session edit: counted for autosave, refused once submitted. */
const editSelection = (update: (prev: Set<string>) => Set<string>) => {
// A submitted selection is the photographer's to reopen, not the client's.
if (submitted) return;
setEdits((n) => n + 1);
setFavorites(update);
};
const toggleFavorite = (token: string) => {
if (sessionInit) {
editSelection((prev) => {
const next = new Set(prev);
if (next.has(token)) next.delete(token);
else next.add(token);
return next;
});
return;
}
setFavorites((prev) => {
const next = new Set(prev);
if (next.has(token)) {
@@ -99,6 +272,10 @@ export function ProofingProvider({
const isFavorite = (token: string) => favorites.has(token);
const clearFavorites = () => {
if (sessionInit) {
editSelection(() => new Set());
return;
}
setFavorites(new Set());
try {
localStorage.removeItem(storageKey);
@@ -159,15 +336,24 @@ export function ProofingProvider({
getFormattedList,
getSelectedTokens,
allowMailto,
encodedMailto,
downloadArchiveUrl,
session: sessionInit
? {
saveState,
unsaved: edits > savedEditsShown,
submitted,
submit,
download: sessionInit.download,
downloadsRemaining,
countDownload: () =>
setDownloadsRemaining((n) => (n === null ? null : Math.max(0, n - 1))),
archiveUrl: `/api/proof/${encodeURIComponent(sessionInit.token)}/archive`,
}
: undefined,
}}
>
{children}
</ProofingContext.Provider>
);
}
export function useProofing() {
const context = useContext(ProofingContext);
return context;
}

View File

@@ -0,0 +1,29 @@
'use client';
import dynamic from 'next/dynamic';
/**
* The proofing provider, modal and session controls, loaded on demand.
*
* Proofing is off on most album pages, and these three are only rendered when
* it is on — yet imported statically they shipped in every album, essay and
* journal bundle. Through `next/dynamic` they become their own chunk: still
* rendered on the server (`ssr` stays on, so the hearts and the session bar
* are in the HTML as before) and preloaded by the page that renders them.
*
* All three come from `ProofingParts.ts`, so they arrive as one chunk.
* `useProofing()` itself lives in `useProofing.ts` so the grid and the
* lightbox can ask for it without pulling the provider back in.
*/
export const ProofingProvider = dynamic(() =>
import('./ProofingParts').then((m) => ({ default: m.ProofingProvider })),
);
export const ProofingModal = dynamic(() =>
import('./ProofingParts').then((m) => ({ default: m.ProofingModal })),
);
export const ProofSessionControls = dynamic(() =>
import('./ProofingParts').then((m) => ({ default: m.ProofSessionControls })),
);

View File

@@ -5,6 +5,7 @@ import { useProofing } from './ProofingContext';
import { IconCheck, IconCopy, IconLink } from './Icons';
import { useDictionary } from './I18nProvider';
import { useModalDialog } from '@/hooks/useModalDialog';
import { decodeEmail } from '@/lib/emailObfuscation';
export function ProofingModal() {
const t = useDictionary();
@@ -27,22 +28,28 @@ export function ProofingModal() {
if (!proofing || !proofing.isModalOpen) return null;
const {
favorites,
setIsModalOpen,
getProofingUrl,
getFormattedList,
getSelectedTokens,
clearFavorites,
allowMailto,
encodedMailto,
downloadArchiveUrl,
} = proofing;
// What the archive would actually receive. Gating on this rather than on
// `favorites.size` means a favourite left over from another album (they share
// the provider's storage key when no `albumName` is passed) can never light up
// a button that would post an empty selection and 404.
// What the archive would actually receive, and the only count shown here.
// `favorites.size` also counts a favourite left over from another album (they
// share a storage key when their names slug alike) or a photo since removed
// from this one, so it would light up a button that posts an empty selection
// and 404s, and put the wrong number in the title and the email subject.
const selectedCount = getSelectedTokens().length;
// Decoded in the browser only: the address never travels in the clear. The
// email button is hidden without a recipient, so a mailto is never built with
// an empty To: — which opened a compose that went nowhere (#736).
const mailtoRecipient = encodedMailto ? decodeEmail(encodedMailto) : '';
/** Copy to the clipboard, or show the text to copy by hand where it is unavailable. */
const copy = (kind: 'link' | 'list', text: string) => {
if (!navigator.clipboard) {
@@ -64,9 +71,12 @@ export function ProofingModal() {
const handleCopyList = () => copy('list', getFormattedList());
const handleMailto = () => {
const subject = encodeURIComponent(t.proofing.mailSubject(favorites.size));
const subject = encodeURIComponent(t.proofing.mailSubject(getSelectedTokens().length));
const body = encodeURIComponent(t.proofing.mailBody(getFormattedList(), getProofingUrl()));
window.location.href = `mailto:?subject=${subject}&body=${body}`;
// Percent-encode the address, keeping '@' readable, so a value from the
// settings cannot smuggle its own `?bcc=`/`&…` header fields into the draft.
const to = encodeURIComponent(mailtoRecipient).replace(/%40/g, '@');
window.location.href = `mailto:${to}?subject=${subject}&body=${body}`;
};
const handleDownloadSelection = () => {
@@ -143,7 +153,7 @@ export function ProofingModal() {
}}
>
<h3 id="proofing-modal-title" style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }}>
{t.proofing.modalTitle(favorites.size)}
{t.proofing.modalTitle(selectedCount)}
</h3>
<button
type="button"
@@ -164,7 +174,7 @@ export function ProofingModal() {
</div>
<p style={{ fontSize: '0.9rem', opacity: 0.8, marginBottom: '1rem' }}>
{t.proofing.intro(favorites.size)}
{t.proofing.intro(selectedCount)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
@@ -219,13 +229,21 @@ export function ProofingModal() {
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm, 6px)',
// Secondary matches the other buttons below: a white-alpha fill
// and border vanished on the light theme's white card. Primary
// text takes --on-accent, which the layout computes from the
// accent; a fixed white was invisible on minimal's white accent.
...(downloadArchiveUrl
? {
background: 'rgba(255,255,255,0.1)',
background: 'var(--bg-card-hover)',
color: 'inherit',
border: '1px solid rgba(255,255,255,0.15)',
border: '1px solid var(--border-subtle)',
}
: { background: 'var(--accent, #e60012)', color: '#fff', border: 'none' }),
: {
background: 'var(--accent, #e60012)',
color: 'var(--on-accent, #fff)',
border: 'none',
}),
fontWeight: 500,
cursor: 'pointer',
}}
@@ -296,7 +314,7 @@ export function ProofingModal() {
</div>
)}
{allowMailto && (
{allowMailto && mailtoRecipient && (
<button
type="button"
onClick={handleMailto}
@@ -330,10 +348,12 @@ export function ProofingModal() {
marginTop: '0.5rem',
background: 'none',
border: 'none',
color: '#ff4d4f',
// Per-mode token, at full strength: the fixed red at 80% opacity it
// replaces measured 2.7:1 on the light card and under 4:1 on the
// dark ones.
color: 'var(--error)',
fontSize: '0.85rem',
cursor: 'pointer',
opacity: 0.8,
}}
>
{t.proofing.clearSelection}

View File

@@ -0,0 +1,11 @@
'use client';
/**
* Everything `ProofingLazy.tsx` loads, behind one import so the three share a
* single chunk — a page with proofing on needs the provider and a control
* together, and separate imports would each carry their own copy of the
* provider.
*/
export { ProofingProvider } from './ProofingContext';
export { ProofingModal } from './ProofingModal';
export { ProofSessionControls } from './ProofSessionControls';

View File

@@ -1,38 +1,28 @@
/**
* SubpageNav — server component that renders navigation links
* for all subpages and standalone albums in the header.
* SubpageNav — server component that renders the header's navigation links
* after Home: subpages and content pages, standalone albums, Journal, About,
* Map (`lib/siteNav.ts`, shared with the home page hero), then any external
* links.
*
* Subpages and content pages (#722) share one order, the order of the
* `subpages:` list in gallery.yaml, where a page appears as `- page: <slug>`.
*/
import { NavLink } from './NavLink';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { listJournalEntries } from '@/lib/admin/journal-service';
import { getServerDictionary } from '@/lib/i18n/server';
import { loadSiteNav } from '@/lib/siteNav.server';
export async function SubpageNav() {
const [subpages, standaloneAlbums, journalEntries] = await Promise.all([
immich.getSubpages(),
immich.getStandaloneAlbums(),
listJournalEntries().catch(() => []),
]);
const links = await loadSiteNav();
// EXPERIMENTAL: external nav links from settings.yaml, appended after the
// internal entries. Sanitised to http(s) in getConfig().
const navLinks = getConfig().navLinks;
const t = getServerDictionary();
const hasPublicJournal = journalEntries.some((e) => !e.frontmatter.draft);
return (
<>
{subpages.map((sp) => (
<NavLink key={sp.slug} href={`/${sp.slug}`}>
{sp.name}
</NavLink>
))}
{hasPublicJournal && <NavLink href="/journal">{t.nav.journal}</NavLink>}
{standaloneAlbums.map((album) => (
<NavLink key={album.id} href={`/${album.slug}`}>
{album.albumName}
{links.map((item) => (
<NavLink key={item.key} href={item.href}>
{item.label}
</NavLink>
))}
{navLinks.map((link) => (

View File

@@ -0,0 +1,55 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import ContactForm from '../ContactForm';
import { validateContact } from '@/lib/contact';
/**
* The fill-time check compares `startedAt` against the server's clock. When
* the form stamped it with the visitor's clock, a device running a few
* minutes fast made every message look "sent before the form existed": the
* route answered `ok` (as it does for bots) and dropped the message, and the
* visitor saw the thank-you page.
*/
const SERVER_RENDERED_AT = 1_800_000_000_000;
const MINUTE = 60_000;
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
async function submitWithClientClock(clientNow: number) {
vi.useFakeTimers({ now: clientNow, toFake: ['Date'] });
const fetchMock = vi.fn(async (_url: string, _init: RequestInit) => Response.json({ ok: true }));
vi.stubGlobal('fetch', fetchMock);
render(<ContactForm retentionDays={30} renderedAt={SERVER_RENDERED_AT} />);
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Ada' } });
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'ada@example.com' } });
fireEvent.change(screen.getByLabelText('Message'), { target: { value: 'Hello there' } });
fireEvent.submit(screen.getByRole('button', { name: 'Send message' }).closest('form')!);
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
return JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;
}
describe('ContactForm fill-time stamp', () => {
it('survives a visitor clock running ahead of the server', async () => {
const body = await submitWithClientClock(SERVER_RENDERED_AT + 10 * MINUTE);
// The visitor took two minutes, measured on the server's clock.
expect(validateContact(body, SERVER_RENDERED_AT + 2 * MINUTE).ok).toBe(true);
});
it('survives a visitor clock running a day behind the server', async () => {
const body = await submitWithClientClock(SERVER_RENDERED_AT - 25 * 60 * MINUTE);
expect(validateContact(body, SERVER_RENDERED_AT + 2 * MINUTE).ok).toBe(true);
});
it('still treats an instant submission as spam', async () => {
const body = await submitWithClientClock(SERVER_RENDERED_AT);
expect(validateContact(body, SERVER_RENDERED_AT + 500)).toEqual({ ok: false, reason: 'spam' });
});
});

View File

@@ -0,0 +1,79 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { parseJournalMarkdown } from '@/lib/journal';
import { escapeHtml } from '@/lib/escapeHtml';
/**
* Most essays have no map block, yet a static import of LeafletMap put its
* wrapper and Leaflet's render-blocking stylesheet on every album, subpage and
* journal page. EssayView now loads it only when a map block renders.
*/
const leafletModuleLoaded = vi.fn();
const mapProps = vi.fn();
vi.mock('@/components/LeafletMap', () => {
leafletModuleLoaded();
return {
LeafletMap: (props: { className?: string; markers: unknown[]; line?: boolean }) => {
mapProps(props);
return <div className={props.className} data-testid="leaflet-map" />;
},
};
});
beforeAll(() => {
// FadeIn reveals at once under reduced motion instead of needing IntersectionObserver.
window.matchMedia = ((query: string) => ({
matches: query.includes('reduce'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
})) as unknown as typeof window.matchMedia;
});
afterEach(() => cleanup());
describe('EssayView map block', () => {
it('does not load the map module just by being imported', async () => {
await import('@/app/[...path]/EssayView');
expect(leafletModuleLoaded).not.toHaveBeenCalled();
});
it('renders the map on demand with the same markers and escaped popups', async () => {
const { EssayView } = await import('@/app/[...path]/EssayView');
const essay = parseJournalMarkdown(
[
'---',
'title: "Trip"',
'---',
'',
'::map The route',
"Berlin's <b>: 52.52, 13.405",
'48.137, 11.575',
].join('\n'),
);
const block = essay.blocks.find((b) => b.type === 'map');
const label =
block?.type === 'map' && block.items[0].kind === 'point' ? block.items[0].label : '';
expect(label).toBeTruthy();
const { container } = render(<EssayView essay={essay} assets={[]} />);
// Same container the eager version rendered, before and after the load.
expect(container.querySelector('.essay-map')).not.toBeNull();
await screen.findByTestId('leaflet-map');
expect(leafletModuleLoaded).toHaveBeenCalledTimes(1);
expect(container.querySelectorAll('.essay-map')).toHaveLength(1);
const props = mapProps.mock.calls.at(-1)![0];
expect(props.className).toBe('essay-map');
expect(props.line).toBe(true);
expect(props.markers).toHaveLength(2);
expect(props.markers[0]).toMatchObject({ lat: 52.52, lng: 13.405 });
expect(props.markers[0].popupHtml).toContain(`>${escapeHtml(label!)}</h3>`);
expect(props.markers[0].popupHtml).not.toContain('<b>');
expect(props.markers[1].popupHtml).toBeUndefined();
});
});

View File

@@ -0,0 +1,67 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import { HeroCarousel, INTERVAL_MS } from '../HeroCarousel';
vi.mock('next/image', () => ({
default: ({ className, src }: { className?: string; src: string }) => (
// eslint-disable-next-line @next/next/no-img-element
<img alt="" className={className} src={src} />
),
}));
const images = [{ src: '/a' }, { src: '/b' }, { src: '/c' }];
function mockReducedMotion(reduce: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
matches: reduce && query.includes('reduce'),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}));
}
const activeSrc = (container: HTMLElement) =>
container.querySelector('.hero__carousel-image--active')?.getAttribute('src');
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe('HeroCarousel (#696)', () => {
it('advances on its own by default', () => {
mockReducedMotion(false);
const { container } = render(<HeroCarousel images={images} />);
expect(activeSrc(container)).toBe('/a');
act(() => vi.advanceTimersByTime(INTERVAL_MS));
expect(activeSrc(container)).toBe('/b');
});
it('does not advance under prefers-reduced-motion', () => {
mockReducedMotion(true);
const { container } = render(<HeroCarousel images={images} />);
act(() => vi.advanceTimersByTime(INTERVAL_MS * 3));
expect(activeSrc(container)).toBe('/a');
expect(screen.getByRole('button', { name: 'Play slideshow' })).toBeTruthy();
});
it('stops and restarts with the pause control', () => {
mockReducedMotion(false);
const { container } = render(<HeroCarousel images={images} />);
fireEvent.click(screen.getByRole('button', { name: 'Pause slideshow' }));
act(() => vi.advanceTimersByTime(INTERVAL_MS * 2));
expect(activeSrc(container)).toBe('/a');
fireEvent.click(screen.getByRole('button', { name: 'Play slideshow' }));
act(() => vi.advanceTimersByTime(INTERVAL_MS));
expect(activeSrc(container)).toBe('/b');
});
it('shows no control for a single image', () => {
mockReducedMotion(false);
render(<HeroCarousel images={[images[0]]} />);
expect(screen.queryByRole('button')).toBeNull();
});
});

View File

@@ -0,0 +1,50 @@
// @vitest-environment jsdom
import { describe, it, expect, afterEach } from 'vitest';
import { render, cleanup, waitFor } from '@testing-library/react';
import { I18nProvider } from '@/components/I18nProvider';
import { LeafletMap, localizedLeafletPrefix } from '@/components/LeafletMap';
import { de } from '@/lib/i18n/locales/de';
/**
* Leaflet ships English control labels: "Zoom in" / "Zoom out" on the zoom
* buttons (also their aria-label) and "A JavaScript library for interactive
* maps" on the credit link. On a German site they now come from the
* dictionary like every other visitor-facing string.
*/
afterEach(() => cleanup());
describe('LeafletMap control labels', () => {
it('takes the zoom button and credit titles from the dictionary', async () => {
const { container } = render(
<I18nProvider locale="de">
<LeafletMap markers={[]} />
</I18nProvider>,
);
const zoomIn = await waitFor(() => {
const el = container.querySelector('.leaflet-control-zoom-in');
if (!el) throw new Error('map not initialised yet');
return el;
});
const zoomOut = container.querySelector('.leaflet-control-zoom-out');
expect(zoomIn.getAttribute('title')).toBe(de.map.zoomIn);
expect(zoomIn.getAttribute('aria-label')).toBe(de.map.zoomIn);
expect(zoomOut?.getAttribute('title')).toBe(de.map.zoomOut);
const credit = container.querySelector('.leaflet-control-attribution a[href*="leafletjs"]');
expect(credit?.getAttribute('title')).toBe(de.map.leafletTitle);
expect(container.innerHTML).not.toContain('Zoom in');
expect(container.innerHTML).not.toContain('A JavaScript library');
});
});
describe('localizedLeafletPrefix', () => {
it('swaps only the title and escapes it', () => {
const prefix = '<a href="https://leafletjs.com" title="English">Leaflet</a>';
expect(localizedLeafletPrefix(prefix, 'Karten "&" mehr')).toBe(
'<a href="https://leafletjs.com" title="Karten &quot;&amp;&quot; mehr">Leaflet</a>',
);
});
});

View File

@@ -0,0 +1,123 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import { MobileNav } from '../MobileNav';
vi.mock('next/navigation', () => ({ usePathname: () => '/' }));
// jsdom lays nothing out, so the header's geometry is stubbed: `navWidth` is
// the space the header offers, `rowWidth` what the links need in one row.
let navWidth = 1440;
let rowWidth = 900;
let phone = false;
let resize: (() => void) | null = null;
beforeEach(() => {
navWidth = 1440;
rowWidth = 900;
phone = false;
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (
this: HTMLElement,
) {
return this.tagName === 'NAV' ? navWidth : 0;
});
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (
this: HTMLElement,
) {
return this.tagName === 'NAV' ? Math.max(rowWidth, navWidth) : 0;
});
vi.stubGlobal(
'ResizeObserver',
class {
constructor(cb: () => void) {
resize = cb;
}
observe() {}
disconnect() {}
},
);
vi.stubGlobal('matchMedia', (query: string) => ({
media: query,
get matches() {
return phone;
},
addEventListener() {},
removeEventListener() {},
}));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
resize = null;
});
function header() {
render(
<nav>
<MobileNav>
<a href="#home">Home</a>
<a href="#about">About</a>
</MobileNav>
</nav>,
);
return {
button: screen.getByRole('button', { name: 'Open menu' }),
panel: document.getElementById('header-nav-panel')!,
};
}
describe('MobileNav collapse (QA P-5)', () => {
it('keeps the links in the row while they fit', () => {
const { button, panel } = header();
expect(button.dataset.collapsed).toBe('false');
expect(panel.dataset.collapsed).toBe('false');
});
it('collapses into the menu when the row is wider than the header', () => {
navWidth = 820;
rowWidth = 1080;
const { button, panel } = header();
expect(button.dataset.collapsed).toBe('true');
expect(panel.dataset.collapsed).toBe('true');
});
it('follows the header width in both directions', () => {
const { button } = header();
expect(button.dataset.collapsed).toBe('false');
navWidth = 820;
rowWidth = 1080;
act(() => resize?.());
expect(button.dataset.collapsed).toBe('true');
// Collapsed, the row is not laid out and cannot be measured; the width
// read before collapsing decides when it fits again.
navWidth = 1440;
act(() => resize?.());
expect(button.dataset.collapsed).toBe('false');
});
it('closes an open panel when the links return to the row', () => {
navWidth = 820;
rowWidth = 1080;
const { button } = header();
fireEvent.click(button);
expect(button.getAttribute('aria-expanded')).toBe('true');
navWidth = 1440;
act(() => resize?.());
expect(button.dataset.collapsed).toBe('false');
expect(button.getAttribute('aria-expanded')).toBe('false');
expect(document.querySelector('.header__nav-backdrop')).toBeNull();
expect(document.body.style.overflow).toBe('');
});
it('always collapses on a phone, whatever the row measures', () => {
phone = true;
rowWidth = 100;
const { button } = header();
expect(button.dataset.collapsed).toBe('true');
});
});

View File

@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import PasswordGate from '../PasswordGate';
/**
* The gate treated every non-ok answer as a wrong password. After ten attempts
* the rate limit answers 429 — and the visitor who finally typed the right
* password was told it was wrong, and had it wiped from the field.
*/
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
async function submit(response: Response, type?: 'journal') {
vi.stubGlobal(
'fetch',
vi.fn(async () => response),
);
render(<PasswordGate slug="polen" title="Polen" type={type} />);
const input = screen.getByLabelText('Enter password') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'right-password' } });
fireEvent.submit(input.closest('form')!);
const alert = await screen.findByRole('alert');
await waitFor(() => expect(input.disabled).toBe(false));
return { alert, input };
}
describe('PasswordGate refusals', () => {
it('says a wrong password is wrong, and clears the field', async () => {
const { alert, input } = await submit(new Response(null, { status: 401 }));
expect(alert.textContent).toBe('Incorrect password. Please try again.');
expect(input.value).toBe('');
});
it('says a rate limit is a rate limit, with the wait, and keeps the password', async () => {
const { alert, input } = await submit(
new Response(null, { status: 429, headers: { 'Retry-After': '42' } }),
);
expect(alert.textContent).toBe('Too many attempts. Please try again in 42 seconds.');
expect(alert.textContent).not.toContain('Incorrect');
expect(input.value).toBe('right-password');
});
it('copes with a rate limit that names no wait', async () => {
const { alert } = await submit(new Response(null, { status: 429 }));
expect(alert.textContent).toBe('Too many attempts. Please try again later.');
});
it('reports a server failure as a failure, not a wrong password', async () => {
const { alert, input } = await submit(new Response(null, { status: 500 }));
expect(alert.textContent).toBe('Unable to verify password. Please try again later.');
expect(input.value).toBe('right-password');
});
});
describe('PasswordGate subtitle', () => {
it('calls a journal entry a journal entry, not a gallery', () => {
render(<PasswordGate slug="tagebuch" title="Journal" type="journal" />);
expect(screen.getByText('This journal entry is password-protected.')).toBeTruthy();
expect(screen.queryByText('This gallery is password-protected.')).toBeNull();
});
});

View File

@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import { ProofingProvider, useProofing, type ProofSessionInit } from '../ProofingContext';
import { ProofSessionControls } from '../ProofSessionControls';
/**
* The review dialog of a client proofing link. The selection ZIP is built from
* what the server has saved, so it must not be offered while a heart is still
* waiting out the autosave debounce; and the download counter has to follow
* the downloads started from this page.
*/
const TOKEN = 'k'.repeat(32);
function Heart() {
const p = useProofing()!;
return <button onClick={() => p.toggleFavorite('t1')}>heart t1</button>;
}
function renderControls(overrides: Partial<ProofSessionInit> = {}) {
return render(
<ProofingProvider
albumTokens={['t1', 't2', 't3']}
albumName="Wedding"
session={{
token: TOKEN,
selected: ['t3'],
submitted: false,
download: 'album',
downloadsRemaining: null,
...overrides,
}}
>
<Heart />
<ProofSessionControls />
</ProofingProvider>,
);
}
const selectionLink = () => screen.queryByText('Download selection (.zip)');
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
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, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('ProofSessionControls', () => {
it('offers the selection ZIP when nothing is waiting to be saved', () => {
renderControls();
fireEvent.click(screen.getByText('Review & submit'));
expect(selectionLink()).not.toBeNull();
});
it('withholds the selection ZIP while a heart waits out the autosave debounce', async () => {
renderControls();
// A first save, so the state reads 'saved' rather than 'idle'.
fireEvent.click(screen.getByText('heart t1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Another heart, and the dialog opened before its save is sent.
fireEvent.click(screen.getByText('heart t1'));
fireEvent.click(screen.getByText('Review & submit'));
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(selectionLink()).toBeNull();
// Once the save lands the link comes back, now for the saved selection.
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(selectionLink()).not.toBeNull();
});
it('counts a started download against the limit shown', () => {
renderControls({ downloadsRemaining: 2 });
fireEvent.click(screen.getByText('Review & submit'));
expect(screen.getByText('2 downloads left')).toBeTruthy();
const link = screen.getByText('Download all photos (.zip)');
// jsdom does not navigate; keep it from trying.
link.addEventListener('click', (e) => e.preventDefault());
fireEvent.click(link);
expect(screen.getByText('1 download left')).toBeTruthy();
fireEvent.click(screen.getByText('Download all photos (.zip)'));
expect(screen.queryByText('Download all photos (.zip)')).toBeNull();
expect(selectionLink()).toBeNull();
});
});

View File

@@ -1,19 +1,30 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
import { ProofingModal } from '../ProofingModal';
import { encodeEmail } from '@/lib/emailObfuscation';
/**
* `navigator.clipboard` only exists in a secure context. A portfolio reached
* over plain http on a LAN is not one, and both copy buttons used to throw on
* the missing API and appear dead. They now show the text to copy by hand.
*
* The context is mutable so a test can turn the email action on and off: the
* recipient is `proofing.email`, else the footer contact email (#736), encoded
* until the browser decodes it.
*/
const URL_ = 'http://192.168.1.20:7211/deutschland/kloster-chorin?fav=abc';
const LIST = '#1, #3';
const RECIPIENT = 'hello@example.com';
vi.mock('../ProofingContext', () => ({
useProofing: () => ({
const { proofing } = vi.hoisted(() => ({ proofing: { current: {} as Record<string, unknown> } }));
vi.mock('../ProofingContext', () => ({ useProofing: () => proofing.current }));
/** The context the modal reads; reset per test so flags cannot leak. */
function setContext(overrides: Record<string, unknown> = {}) {
proofing.current = {
favorites: new Set(['a', 'b']),
isModalOpen: true,
setIsModalOpen: vi.fn(),
@@ -22,16 +33,21 @@ vi.mock('../ProofingContext', () => ({
getSelectedTokens: () => ['a', 'b'],
clearFavorites: vi.fn(),
allowMailto: false,
encodedMailto: undefined,
downloadArchiveUrl: undefined,
}),
}));
...overrides,
};
}
const setClipboard = (value: unknown) =>
Object.defineProperty(navigator, 'clipboard', { value, configurable: true });
beforeEach(() => setContext());
afterEach(() => {
cleanup();
setClipboard(undefined);
vi.unstubAllGlobals();
});
describe('ProofingModal copy buttons', () => {
@@ -66,3 +82,42 @@ describe('ProofingModal copy buttons', () => {
expect(screen.queryByLabelText('Copy this link')).toBeNull();
});
});
describe('ProofingModal email to photographer (#736)', () => {
it('hides the email action when no recipient is configured', () => {
setContext({ allowMailto: true, encodedMailto: undefined });
render(<ProofingModal />);
expect(screen.queryByText(/Send Email/)).toBeNull();
});
it('hides the email action when the site turned it off', () => {
setContext({ allowMailto: false, encodedMailto: encodeEmail(RECIPIENT) });
render(<ProofingModal />);
expect(screen.queryByText(/Send Email/)).toBeNull();
});
it('addresses the compose to the configured recipient', () => {
setContext({ allowMailto: true, encodedMailto: encodeEmail(RECIPIENT) });
const location = { href: '' };
vi.stubGlobal('location', location);
render(<ProofingModal />);
fireEvent.click(screen.getByText(/Send Email/));
expect(location.href.startsWith(`mailto:${RECIPIENT}?subject=`)).toBe(true);
});
it('keeps a crafted address from adding its own header fields', () => {
setContext({ allowMailto: true, encodedMailto: encodeEmail('a@b.c?bcc=evil@x.y') });
const location = { href: '' };
vi.stubGlobal('location', location);
render(<ProofingModal />);
fireEvent.click(screen.getByText(/Send Email/));
// The `?` and `=` are escaped, so the draft gains no bcc field.
expect(location.href.startsWith('mailto:a@b.c%3Fbcc%3Devil@x.y?subject=')).toBe(true);
});
it('keeps the plain address out of the rendered markup', () => {
setContext({ allowMailto: true, encodedMailto: encodeEmail(RECIPIENT) });
const { container } = render(<ProofingModal />);
expect(container.innerHTML).not.toContain(RECIPIENT);
});
});

View File

@@ -0,0 +1,125 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render, screen, cleanup, fireEvent, act } from '@testing-library/react';
import { ProofingProvider, useProofing, type ProofSessionInit } from '../ProofingContext';
/**
* Session mode of the proofing context: the selection starts from the server,
* a burst of hearts becomes one save, and a submitted selection stays put.
*/
function Probe() {
const p = useProofing()!;
return (
<div>
<span data-testid="count">{p.favorites.size}</span>
<span data-testid="state">{p.session?.saveState}</span>
<span data-testid="submitted">{String(p.session?.submitted)}</span>
<button onClick={() => p.toggleFavorite('t1')}>t1</button>
<button onClick={() => p.toggleFavorite('t2')}>t2</button>
<button onClick={() => void p.session?.submit()}>submit</button>
</div>
);
}
const TOKEN = 'k'.repeat(32);
function renderSession(overrides: Partial<ProofSessionInit> = {}) {
return render(
<ProofingProvider
albumTokens={['t1', 't2', 't3']}
albumName="Wedding"
session={{
token: TOKEN,
selected: ['t3'],
submitted: false,
download: 'none',
downloadsRemaining: null,
...overrides,
}}
>
<Probe />
</ProofingProvider>,
);
}
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
// Node 26 defines its own global localStorage (undefined without
// --localstorage-file), which shadows jsdom's; give the test a working one.
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, String(v)),
removeItem: (k: string) => void store.delete(k),
clear: () => store.clear(),
});
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe('ProofingProvider session mode', () => {
it('starts from the server selection, not localStorage', () => {
localStorage.setItem('folio_fav_wedding', JSON.stringify(['t1', 't2']));
renderSession();
expect(screen.getByTestId('count').textContent).toBe('1');
});
it('debounces hearts into one save, in album order', async () => {
renderSession();
fireEvent.click(screen.getByText('t2'));
fireEvent.click(screen.getByText('t1'));
expect(fetchMock).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`/api/proof/${TOKEN}/selection`);
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body as string)).toEqual({ assets: ['t1', 't2', 't3'] });
expect(screen.getByTestId('state').textContent).toBe('saved');
});
it('reports a failed save', async () => {
fetchMock.mockResolvedValue(new Response('{}', { status: 500 }));
renderSession();
fireEvent.click(screen.getByText('t1'));
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(screen.getByTestId('state').textContent).toBe('error');
});
it('saves pending hearts before submitting, then locks', async () => {
renderSession();
fireEvent.click(screen.getByText('t1'));
await act(async () => {
fireEvent.click(screen.getByText('submit'));
await vi.advanceTimersByTimeAsync(0);
});
const urls = fetchMock.mock.calls.map((call) => call[0]);
expect(urls).toEqual([`/api/proof/${TOKEN}/selection`, `/api/proof/${TOKEN}/submit`]);
expect(screen.getByTestId('submitted').textContent).toBe('true');
fireEvent.click(screen.getByText('t2'));
expect(screen.getByTestId('count').textContent).toBe('2');
});
it('ignores hearts on a submitted selection', () => {
renderSession({ submitted: true });
fireEvent.click(screen.getByText('t1'));
expect(screen.getByTestId('count').textContent).toBe('1');
});
});

View File

@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import { THEME_PRESETS } from '@/lib/config/theme';
/**
* Colours that used to be hard-coded and failed in one mode or with one
* accent (QA T-1, T-2, T-5, T-6, T-8). The values live in app/tokens.css;
* these checks read them from there, so a palette change that breaks one
* fails here.
*/
const read = (...p: string[]) => fs.readFileSync(path.join(process.cwd(), ...p), 'utf8');
const tokens = read('app', 'tokens.css');
type RGBA = { r: number; g: number; b: number; a: number };
function parse(value: string): RGBA {
const v = value.trim();
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v)?.[1];
if (hex) {
const h = hex.length === 3 ? [...hex].map((c) => c + c).join('') : hex;
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
a: 1,
};
}
const rgba = /^rgba?\(([^)]+)\)$/.exec(v)?.[1];
if (!rgba) throw new Error(`unparsed colour ${value}`);
const [r, g, b, a = 1] = rgba.split(',').map(Number);
return { r, g, b, a };
}
function over(fg: RGBA, bg: RGBA): RGBA {
const m = (x: number, y: number) => x * fg.a + y * (1 - fg.a);
return { r: m(fg.r, bg.r), g: m(fg.g, bg.g), b: m(fg.b, bg.b), a: 1 };
}
function ratio(a: RGBA, b: RGBA): number {
const lum = (c: RGBA) =>
[c.r, c.g, c.b]
.map((v) => v / 255)
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
.reduce((sum, v, i) => sum + v * [0.2126, 0.7152, 0.0722][i], 0);
const [x, y] = [lum(a), lum(b)];
return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}
function block(selector: string): string | undefined {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`).exec(tokens)?.[1];
}
function token(body: string | undefined, name: string): string | undefined {
return body ? new RegExp(`--${name}:\\s*([^;]+);`).exec(body)?.[1] : undefined;
}
const ROOT = block(':root');
const LIGHT = block("[data-theme='light']");
const SURFACES = ['bg-primary', 'bg-secondary', 'bg-card', 'bg-card-hover'];
/** Every opaque surface token a preset resolves to in one mode. */
function surfaces(preset: string, mode: 'dark' | 'light'): string[] {
const own = block(
mode === 'dark' ? `[data-preset='${preset}']` : `[data-preset='${preset}'][data-theme='light']`,
);
const base = mode === 'dark' ? ROOT : LIGHT;
return SURFACES.map((s) => token(own, s) ?? token(base, s)!).filter((v) => v.startsWith('#'));
}
describe('--error (T-8)', () => {
it('is defined for both modes', () => {
expect(token(ROOT, 'error')).toBeDefined();
expect(token(LIGHT, 'error')).toBeDefined();
});
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')!);
for (const surface of surfaces(preset, mode)) {
expect(ratio(error, parse(surface)), surface).toBeGreaterThanOrEqual(4.5);
}
});
}
}
it('replaces the fixed reds in the error texts', () => {
for (const file of [
['app', 'contact', 'contact.css'],
['components', 'PasswordGate.module.css'],
['components', 'ProofingModal.tsx'],
]) {
expect(read(...file), file.join('/')).not.toMatch(/#e74c3c|#ff4d4f/i);
}
});
});
describe('lightbox tokens (T-1, T-2)', () => {
const lb = (name: string) => parse(token(ROOT, `lightbox-${name}`)!);
// The two overlays the lightbox paints (the default, and studio-modern's),
// over the lightest page a light preset has — the worst case for white text.
const lightest = parse('#ffffff');
const overlays = [parse('rgba(0, 0, 0, 0.85)'), parse('rgba(10, 10, 10, 0.92)')].map((o) =>
over(o, lightest),
);
it('keeps text and the saved-favourite colour at 4.5:1 on the overlay', () => {
for (const bg of overlays) {
for (const name of ['text', 'text-secondary', 'text-muted', 'fav']) {
expect(ratio(over(lb(name), bg), bg), name).toBeGreaterThanOrEqual(4.5);
}
}
});
it('points the text tokens at them inside the overlay in light mode', () => {
const css = read('components', 'Lightbox.module.css');
const rule = /:global\(\[data-theme='light'\]\) \.overlay \{([^}]*)\}/.exec(css)?.[1];
expect(rule).toBeDefined();
expect(rule).toMatch(/--text-primary:\s*var\(--lightbox-text\)/);
expect(rule).toMatch(/--text-secondary:\s*var\(--lightbox-text-secondary\)/);
expect(rule).toMatch(/--text-muted:\s*var\(--lightbox-text-muted\)/);
});
it('drops the inline favourite colour that overrode the preset', () => {
const tsx = read('components', 'Lightbox.tsx');
expect(tsx).not.toMatch(/#ff4d4f/i);
expect(tsx).not.toMatch(/color: isFav/);
});
});
describe('text on the accent (T-5, T-6)', () => {
it('uses --on-accent instead of a fixed white', () => {
for (const file of [
['components', 'ProofingModal.tsx'],
['components', 'ProofSessionControls.tsx'],
]) {
expect(read(...file), file.join('/')).not.toMatch(/color: '#fff'/);
}
const studioModern = read('app', 'themes', 'studio-modern.css');
const button = /\[data-preset='studio-modern'\] \.empty-state__button \{([^}]*)\}/.exec(
studioModern,
)?.[1];
expect(button).toBeDefined();
expect(button).not.toMatch(/color:\s*#fff/i);
});
});

View File

@@ -0,0 +1,76 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { trapTabKey } from '@/lib/focusTrap';
import { MobileNav } from '../MobileNav';
vi.mock('next/navigation', () => ({ usePathname: () => '/' }));
afterEach(() => {
cleanup();
document.body.innerHTML = '';
});
function tab(container: HTMLElement, shiftKey = false) {
const e = new KeyboardEvent('keydown', { key: 'Tab', shiftKey, cancelable: true });
const moved = trapTabKey(container, e);
return { moved, prevented: e.defaultPrevented };
}
describe('trapTabKey (#696)', () => {
function dialog() {
document.body.innerHTML = `
<button id="outside">outside</button>
<div id="dlg">
<button id="a">a</button>
<button id="hidden" aria-hidden="true">x</button>
<a id="b" href="#">b</a>
</div>`;
return document.getElementById('dlg')!;
}
it('wraps Tab from the last element to the first', () => {
const dlg = dialog();
document.getElementById('b')!.focus();
expect(tab(dlg)).toEqual({ moved: true, prevented: true });
expect(document.activeElement?.id).toBe('a');
});
it('wraps Shift+Tab from the first element to the last', () => {
const dlg = dialog();
document.getElementById('a')!.focus();
tab(dlg, true);
expect(document.activeElement?.id).toBe('b');
});
it('leaves Tab between inner elements to the browser', () => {
const dlg = dialog();
document.getElementById('a')!.focus();
expect(tab(dlg)).toEqual({ moved: false, prevented: false });
});
it('pulls escaped focus back in', () => {
const dlg = dialog();
document.getElementById('outside')!.focus();
tab(dlg);
expect(document.activeElement?.id).toBe('a');
});
});
describe('MobileNav focus (#696)', () => {
it('moves focus into the panel and back to the button on Esc', () => {
render(
<MobileNav>
<a href="#home">Home</a>
<a href="#about">About</a>
</MobileNav>,
);
const button = screen.getByRole('button', { name: 'Open menu' });
fireEvent.click(button);
expect(document.activeElement?.textContent).toBe('Home');
fireEvent.keyDown(document, { key: 'Escape' });
expect(document.activeElement).toBe(button);
expect(button.getAttribute('aria-expanded')).toBe('false');
});
});

18
components/useProofing.ts Normal file
View File

@@ -0,0 +1,18 @@
'use client';
import { createContext, useContext } from 'react';
import type { ProofingContextType } from './ProofingContext';
/**
* The proofing context on its own, apart from the provider in
* `ProofingContext.tsx`. The grid, the essay and the lightbox read it on every
* album page, proofing or not; the provider, the modal and the session
* controls are only loaded where proofing is on (`ProofingLazy.tsx`). Keeping
* the context here is what lets them stay out of the page's first bundle.
*/
export const ProofingContext = createContext<ProofingContextType | null>(null);
/** The proofing state, or null outside a provider — proofing is off. */
export function useProofing() {
return useContext(ProofingContext);
}