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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}>
|
||||
|
||||
272
components/ProofSessionControls.tsx
Normal file
272
components/ProofSessionControls.tsx
Normal 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,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
29
components/ProofingLazy.tsx
Normal file
29
components/ProofingLazy.tsx
Normal 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 })),
|
||||
);
|
||||
@@ -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}
|
||||
|
||||
11
components/ProofingParts.ts
Normal file
11
components/ProofingParts.ts
Normal 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';
|
||||
@@ -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) => (
|
||||
|
||||
55
components/__tests__/ContactForm.test.tsx
Normal file
55
components/__tests__/ContactForm.test.tsx
Normal 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' });
|
||||
});
|
||||
});
|
||||
79
components/__tests__/EssayMapLazy.test.tsx
Normal file
79
components/__tests__/EssayMapLazy.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
67
components/__tests__/HeroCarousel.test.tsx
Normal file
67
components/__tests__/HeroCarousel.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
50
components/__tests__/LeafletMapLabels.test.tsx
Normal file
50
components/__tests__/LeafletMapLabels.test.tsx
Normal 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 "&" mehr">Leaflet</a>',
|
||||
);
|
||||
});
|
||||
});
|
||||
123
components/__tests__/MobileNav.test.tsx
Normal file
123
components/__tests__/MobileNav.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
65
components/__tests__/PasswordGate.test.tsx
Normal file
65
components/__tests__/PasswordGate.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
109
components/__tests__/ProofSessionControls.test.tsx
Normal file
109
components/__tests__/ProofSessionControls.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
125
components/__tests__/ProofingSession.test.tsx
Normal file
125
components/__tests__/ProofingSession.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
148
components/__tests__/colorTokens.test.ts
Normal file
148
components/__tests__/colorTokens.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
76
components/__tests__/focusManagement.test.tsx
Normal file
76
components/__tests__/focusManagement.test.tsx
Normal 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
18
components/useProofing.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user