release: v0.17.0 (#677)
See CHANGELOG.md, section [0.17.0] — 2026-09-23.
This commit is contained in:
@@ -59,7 +59,7 @@ export function Footer() {
|
||||
</a>
|
||||
)}
|
||||
{footer?.email && (
|
||||
<a href={`mailto:${footer.email}`} className="footer__link" aria-label="Email">
|
||||
<a href={`mailto:${footer.email}`} className="footer__link" aria-label={t.common.email}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="16"
|
||||
@@ -82,7 +82,7 @@ export function Footer() {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer__link"
|
||||
aria-label="Website"
|
||||
aria-label={t.common.website}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
|
||||
150
components/LeafletMap.tsx
Normal file
150
components/LeafletMap.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* LeafletMap — the Leaflet core shared by the /map page and journal map
|
||||
* blocks: tiles, markers from props, an optional line through them, fit to
|
||||
* bounds. It knows nothing about /api/map; MapView fetches and hands over.
|
||||
*
|
||||
* The Leaflet stylesheet is emitted here rather than by each page, so a map
|
||||
* anywhere brings its own CSS. Marker and popup styling lives in
|
||||
* app/leaflet.css, loaded globally.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export interface LeafletMarker {
|
||||
lat: number;
|
||||
lng: number;
|
||||
/** Inner HTML of the marker icon — already escaped by the caller. */
|
||||
html: string;
|
||||
popupHtml?: string;
|
||||
}
|
||||
|
||||
interface LeafletMapProps {
|
||||
markers: LeafletMarker[];
|
||||
/** Draw a dashed line through the markers in the order given. */
|
||||
line?: boolean;
|
||||
className?: string;
|
||||
/** Cap the zoom fitBounds may pick — a single pin otherwise zooms to the rooftop. */
|
||||
fitMaxZoom?: number;
|
||||
onReady?: () => void;
|
||||
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, ''');
|
||||
}
|
||||
|
||||
const LEAFLET_CSS_HREF = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
const LEAFLET_CSS_INTEGRITY = 'sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=';
|
||||
|
||||
export function LeafletMap({
|
||||
markers,
|
||||
line = false,
|
||||
className = 'map-container',
|
||||
fitMaxZoom,
|
||||
onReady,
|
||||
onError,
|
||||
}: LeafletMapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<L.Map | null>(null);
|
||||
|
||||
// Callers rebuild the markers array on every render; the map is only
|
||||
// rebuilt when its content changes.
|
||||
const markersKey = JSON.stringify(markers);
|
||||
const markersRef = useRef(markers);
|
||||
markersRef.current = markers;
|
||||
const onReadyRef = useRef(onReady);
|
||||
onReadyRef.current = onReady;
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
const L = (await import('leaflet')).default;
|
||||
if (cancelled || !containerRef.current || mapRef.current) return;
|
||||
|
||||
const map = L.map(containerRef.current, { zoomControl: true, attributionControl: true });
|
||||
mapRef.current = map;
|
||||
|
||||
// Dark-themed tiles (CartoDB Dark Matter)
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
const current = markersRef.current;
|
||||
if (current.length === 0) {
|
||||
// Nothing to place — world view
|
||||
map.setView([20, 0], 2);
|
||||
} else {
|
||||
const group = L.featureGroup(
|
||||
current.map((m) => {
|
||||
const marker = L.marker([m.lat, m.lng], {
|
||||
icon: L.divIcon({
|
||||
className: '',
|
||||
html: m.html,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
popupAnchor: [0, -20],
|
||||
}),
|
||||
});
|
||||
if (m.popupHtml) marker.bindPopup(m.popupHtml, { maxWidth: 260, minWidth: 200 });
|
||||
return marker;
|
||||
}),
|
||||
).addTo(map);
|
||||
|
||||
if (line && current.length > 1) {
|
||||
const accent =
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--accent').trim() ||
|
||||
'#e60012';
|
||||
L.polyline(
|
||||
current.map((m) => [m.lat, m.lng] as [number, number]),
|
||||
{ color: accent, weight: 2, opacity: 0.75, dashArray: '4 6' },
|
||||
).addTo(map);
|
||||
}
|
||||
|
||||
map.fitBounds(group.getBounds().pad(0.15), fitMaxZoom ? { maxZoom: fitMaxZoom } : {});
|
||||
}
|
||||
|
||||
// Force Leaflet to recalculate the tile grid once laid out
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
onReadyRef.current?.();
|
||||
}
|
||||
|
||||
init().catch((err) => {
|
||||
console.error('[Map] Init error:', err);
|
||||
if (!cancelled) onErrorRef.current?.(err);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [markersKey, line, fitMaxZoom]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href={LEAFLET_CSS_HREF}
|
||||
integrity={LEAFLET_CSS_INTEGRITY}
|
||||
crossOrigin=""
|
||||
/>
|
||||
<div ref={containerRef} className={className} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,13 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* MapView — client component that initializes Leaflet, fetches /api/map,
|
||||
* and renders location-level markers with themed popups.
|
||||
* MapView — fetches /api/map and renders its location-level markers with
|
||||
* themed popups through LeafletMap, which owns the Leaflet setup.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useDictionary } from './I18nProvider';
|
||||
|
||||
/** Escapes special HTML characters to prevent XSS in Leaflet popup templates. */
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
import { LeafletMap, escapeHtml, type LeafletMarker } from './LeafletMap';
|
||||
|
||||
interface MapLocationPublic {
|
||||
city: string;
|
||||
@@ -30,21 +21,14 @@ interface MapLocationPublic {
|
||||
|
||||
export function MapView() {
|
||||
const t = useDictionary();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<unknown>(null);
|
||||
const [locations, setLocations] = useState<MapLocationPublic[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
// Dynamically import Leaflet (client-only)
|
||||
const L = (await import('leaflet')).default;
|
||||
|
||||
if (cancelled || !containerRef.current || mapRef.current) return;
|
||||
|
||||
// Fetch map data
|
||||
async function load() {
|
||||
const res = await fetch('/api/map');
|
||||
if (!res.ok) {
|
||||
let detail = '';
|
||||
@@ -54,48 +38,32 @@ export function MapView() {
|
||||
/* ignore */
|
||||
}
|
||||
console.error('[Map] API error', res.status, detail);
|
||||
setError(t.map.loadFailed(res.status));
|
||||
setLoading(false);
|
||||
if (!cancelled) {
|
||||
setError(t.map.loadFailed(res.status));
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const locations: MapLocationPublic[] = await res.json();
|
||||
const data: MapLocationPublic[] = await res.json();
|
||||
if (!cancelled) setLocations(data);
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// Initialize map
|
||||
const map = L.map(containerRef.current, {
|
||||
zoomControl: true,
|
||||
attributionControl: true,
|
||||
});
|
||||
mapRef.current = map;
|
||||
|
||||
// Dark-themed tiles (CartoDB Dark Matter)
|
||||
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
|
||||
attribution:
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
if (locations.length === 0) {
|
||||
// No geotagged photos — show world view
|
||||
map.setView([20, 0], 2);
|
||||
load().catch((err) => {
|
||||
console.error('[Map] Init error:', err);
|
||||
if (!cancelled) {
|
||||
setError(t.map.initFailed);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Create markers with custom HTML icons
|
||||
const markers: L.Marker[] = [];
|
||||
|
||||
for (const loc of locations) {
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div class="map-marker">${loc.photoCount}</div>`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
popupAnchor: [0, -20],
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const markers = useMemo<LeafletMarker[]>(
|
||||
() =>
|
||||
(locations ?? []).map((loc) => {
|
||||
const albumLinks = loc.albums
|
||||
.map(
|
||||
(a) =>
|
||||
@@ -111,7 +79,11 @@ export function MapView() {
|
||||
const heading = loc.city || loc.country;
|
||||
const subheading = loc.city ? loc.country : '';
|
||||
|
||||
const popupHtml = `
|
||||
return {
|
||||
lat: loc.lat,
|
||||
lng: loc.lng,
|
||||
html: `<div class="map-marker">${loc.photoCount}</div>`,
|
||||
popupHtml: `
|
||||
<div class="map-popup">
|
||||
<img src="${escapeHtml(loc.coverUrl)}" alt="${escapeHtml(heading)}" class="map-popup__cover" loading="lazy" />
|
||||
<div class="map-popup__body">
|
||||
@@ -121,43 +93,11 @@ export function MapView() {
|
||||
<div class="map-popup__albums">${albumLinks}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const marker = L.marker([loc.lat, loc.lng], { icon })
|
||||
.addTo(map)
|
||||
.bindPopup(popupHtml, { maxWidth: 260, minWidth: 200 });
|
||||
|
||||
markers.push(marker);
|
||||
}
|
||||
|
||||
// Fit bounds to show all markers
|
||||
if (markers.length > 0) {
|
||||
const group = L.featureGroup(markers);
|
||||
map.fitBounds(group.getBounds().pad(0.15));
|
||||
}
|
||||
|
||||
// Force Leaflet to recalculate tile grid
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
init().catch((err) => {
|
||||
console.error('[Map] Init error:', err);
|
||||
if (!cancelled) {
|
||||
setError(t.map.initFailed);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (mapRef.current) {
|
||||
(mapRef.current as L.Map).remove();
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
`,
|
||||
};
|
||||
}),
|
||||
[locations, t],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -213,7 +153,18 @@ export function MapView() {
|
||||
`}</style>
|
||||
</div>
|
||||
)}
|
||||
<div ref={containerRef} className="map-container" />
|
||||
{locations ? (
|
||||
<LeafletMap
|
||||
markers={markers}
|
||||
onReady={() => setLoading(false)}
|
||||
onError={() => {
|
||||
setError(t.map.initFailed);
|
||||
setLoading(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="map-container" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,11 @@ interface ProofingContextType {
|
||||
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
getProofingUrl: () => string;
|
||||
getFormattedList: () => string;
|
||||
/** Selected asset tokens in album order — the payload for a ZIP download. */
|
||||
getSelectedTokens: () => string[];
|
||||
allowMailto: boolean;
|
||||
/** The ZIP endpoint when the album offers downloads; undefined otherwise. */
|
||||
downloadArchiveUrl?: string;
|
||||
}
|
||||
|
||||
const ProofingContext = createContext<ProofingContextType | null>(null);
|
||||
@@ -25,11 +29,13 @@ export function ProofingProvider({
|
||||
albumTokens = [],
|
||||
albumName = 'Gallery',
|
||||
allowMailto = true,
|
||||
downloadArchiveUrl,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
albumTokens?: string[];
|
||||
albumName?: string;
|
||||
allowMailto?: boolean;
|
||||
downloadArchiveUrl?: string;
|
||||
}) {
|
||||
const t = useDictionary();
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
@@ -121,6 +127,8 @@ export function ProofingProvider({
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const getSelectedTokens = () => albumTokens.filter((token) => favorites.has(token));
|
||||
|
||||
const getFormattedList = () => {
|
||||
const selectedIndices: number[] = [];
|
||||
albumTokens.forEach((token, index) => {
|
||||
@@ -149,7 +157,9 @@ export function ProofingProvider({
|
||||
setIsModalOpen,
|
||||
getProofingUrl,
|
||||
getFormattedList,
|
||||
getSelectedTokens,
|
||||
allowMailto,
|
||||
downloadArchiveUrl,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -10,6 +10,13 @@ export function ProofingModal() {
|
||||
const t = useDictionary();
|
||||
const proofing = useProofing();
|
||||
const [copiedState, setCopiedState] = useState<'none' | 'link' | 'list'>('none');
|
||||
/**
|
||||
* What to show for copying by hand. `navigator.clipboard` only exists in a
|
||||
* secure context, and a self-hosted portfolio reached over plain http on a
|
||||
* LAN is not one — the buttons used to throw and appear to do nothing. The
|
||||
* lightbox's permalink falls back the same way.
|
||||
*/
|
||||
const [manualCopy, setManualCopy] = useState<'link' | 'list' | null>(null);
|
||||
|
||||
/* Before the early `return null`: hooks must not run conditionally.
|
||||
`proofing` can be null, hence the optional calls. */
|
||||
@@ -24,25 +31,37 @@ export function ProofingModal() {
|
||||
setIsModalOpen,
|
||||
getProofingUrl,
|
||||
getFormattedList,
|
||||
getSelectedTokens,
|
||||
clearFavorites,
|
||||
allowMailto,
|
||||
downloadArchiveUrl,
|
||||
} = proofing;
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const url = getProofingUrl();
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopiedState('link');
|
||||
setTimeout(() => setCopiedState('none'), 2000);
|
||||
});
|
||||
// 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.
|
||||
const selectedCount = getSelectedTokens().length;
|
||||
|
||||
/** 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) {
|
||||
setManualCopy(kind);
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => {
|
||||
setManualCopy(null);
|
||||
setCopiedState(kind);
|
||||
setTimeout(() => setCopiedState('none'), 2000);
|
||||
},
|
||||
() => setManualCopy(kind),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyList = () => {
|
||||
const list = getFormattedList();
|
||||
navigator.clipboard.writeText(list).then(() => {
|
||||
setCopiedState('list');
|
||||
setTimeout(() => setCopiedState('none'), 2000);
|
||||
});
|
||||
};
|
||||
const handleCopyLink = () => copy('link', getProofingUrl());
|
||||
|
||||
const handleCopyList = () => copy('list', getFormattedList());
|
||||
|
||||
const handleMailto = () => {
|
||||
const subject = encodeURIComponent(t.proofing.mailSubject(favorites.size));
|
||||
@@ -50,6 +69,31 @@ export function ProofingModal() {
|
||||
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||
};
|
||||
|
||||
const handleDownloadSelection = () => {
|
||||
if (!downloadArchiveUrl) return;
|
||||
const tokens = getSelectedTokens();
|
||||
if (tokens.length === 0) return;
|
||||
|
||||
// A form POST, not a fetch: the browser streams the response straight to
|
||||
// disk, so a large selection never has to fit in memory — a phone cannot
|
||||
// hold a whole ZIP in a blob. The route answers with
|
||||
// `Content-Disposition: attachment`, so the page stays where it was.
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = downloadArchiveUrl;
|
||||
form.style.display = 'none';
|
||||
for (const token of tokens) {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'assets';
|
||||
input.value = token;
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
form.remove();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="proofing-modal-overlay"
|
||||
@@ -124,6 +168,47 @@ export function ProofingModal() {
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{downloadArchiveUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadSelection}
|
||||
disabled={selectedCount === 0}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
background: 'var(--accent, #e60012)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
cursor: selectedCount === 0 ? 'not-allowed' : 'pointer',
|
||||
opacity: selectedCount === 0 ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="7 10 12 15 17 10" />
|
||||
<line x1="12" y1="15" x2="12" y2="3" />
|
||||
</svg>
|
||||
{t.proofing.downloadSelected}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* The primary action is the download when there is one; otherwise
|
||||
copying the link stays the accent button it always was. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLink}
|
||||
@@ -134,9 +219,13 @@ export function ProofingModal() {
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
background: 'var(--accent, #e60012)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
...(downloadArchiveUrl
|
||||
? {
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: 'inherit',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
}
|
||||
: { background: 'var(--accent, #e60012)', color: '#fff', border: 'none' }),
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
@@ -180,6 +269,33 @@ export function ProofingModal() {
|
||||
)}
|
||||
</button>
|
||||
|
||||
{manualCopy && (
|
||||
<div role="status" style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<label htmlFor="proofing-manual-copy" style={{ fontSize: '0.85rem', opacity: 0.8 }}>
|
||||
{manualCopy === 'link' ? t.proofing.copyManualLink : t.proofing.copyManualList}
|
||||
</label>
|
||||
<textarea
|
||||
id="proofing-manual-copy"
|
||||
readOnly
|
||||
autoFocus
|
||||
rows={manualCopy === 'link' ? 2 : 4}
|
||||
value={manualCopy === 'link' ? getProofingUrl() : getFormattedList()}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem 0.75rem',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
background: 'var(--bg-card-hover)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
font: 'inherit',
|
||||
fontSize: '0.85rem',
|
||||
resize: 'vertical',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{allowMailto && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
68
components/__tests__/ProofingModal.test.tsx
Normal file
68
components/__tests__/ProofingModal.test.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { ProofingModal } from '../ProofingModal';
|
||||
|
||||
/**
|
||||
* `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.
|
||||
*/
|
||||
|
||||
const URL_ = 'http://192.168.1.20:7211/deutschland/kloster-chorin?fav=abc';
|
||||
const LIST = '#1, #3';
|
||||
|
||||
vi.mock('../ProofingContext', () => ({
|
||||
useProofing: () => ({
|
||||
favorites: new Set(['a', 'b']),
|
||||
isModalOpen: true,
|
||||
setIsModalOpen: vi.fn(),
|
||||
getProofingUrl: () => URL_,
|
||||
getFormattedList: () => LIST,
|
||||
getSelectedTokens: () => ['a', 'b'],
|
||||
clearFavorites: vi.fn(),
|
||||
allowMailto: false,
|
||||
downloadArchiveUrl: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
const setClipboard = (value: unknown) =>
|
||||
Object.defineProperty(navigator, 'clipboard', { value, configurable: true });
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setClipboard(undefined);
|
||||
});
|
||||
|
||||
describe('ProofingModal copy buttons', () => {
|
||||
it('shows the link to copy by hand when there is no clipboard (plain http)', () => {
|
||||
setClipboard(undefined);
|
||||
render(<ProofingModal />);
|
||||
fireEvent.click(screen.getByText('Copy Shareable Link'));
|
||||
expect((screen.getByLabelText('Copy this link') as HTMLTextAreaElement).value).toBe(URL_);
|
||||
});
|
||||
|
||||
it('shows the list to copy by hand when there is no clipboard', () => {
|
||||
setClipboard(undefined);
|
||||
render(<ProofingModal />);
|
||||
fireEvent.click(screen.getByText('Copy Text List (#1, #2...)'));
|
||||
expect((screen.getByLabelText('Copy this list') as HTMLTextAreaElement).value).toBe(LIST);
|
||||
});
|
||||
|
||||
it('falls back as well when the browser refuses the write', async () => {
|
||||
setClipboard({ writeText: vi.fn(() => Promise.reject(new Error('NotAllowedError'))) });
|
||||
render(<ProofingModal />);
|
||||
fireEvent.click(screen.getByText('Copy Shareable Link'));
|
||||
await waitFor(() => expect(screen.getByLabelText('Copy this link')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('copies silently where the clipboard works', async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve());
|
||||
setClipboard({ writeText });
|
||||
render(<ProofingModal />);
|
||||
fireEvent.click(screen.getByText('Copy Shareable Link'));
|
||||
await waitFor(() => expect(screen.getByText('Link Copied!')).toBeTruthy());
|
||||
expect(writeText).toHaveBeenCalledWith(URL_);
|
||||
expect(screen.queryByLabelText('Copy this link')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user