'use client'; import React, { useState, useCallback } from 'react'; 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(); 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. */ const isOpen = Boolean(proofing?.isModalOpen); const close = useCallback(() => proofing?.setIsModalOpen(false), [proofing]); const cardRef = useModalDialog(close, isOpen); if (!proofing || !proofing.isModalOpen) return null; const { setIsModalOpen, getProofingUrl, getFormattedList, getSelectedTokens, clearFavorites, allowMailto, encodedMailto, downloadArchiveUrl, } = proofing; // 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) { setManualCopy(kind); return; } navigator.clipboard.writeText(text).then( () => { setManualCopy(null); setCopiedState(kind); setTimeout(() => setCopiedState('none'), 2000); }, () => setManualCopy(kind), ); }; const handleCopyLink = () => copy('link', getProofingUrl()); const handleCopyList = () => copy('list', getFormattedList()); const handleMailto = () => { const subject = encodeURIComponent(t.proofing.mailSubject(getSelectedTokens().length)); const body = encodeURIComponent(t.proofing.mailBody(getFormattedList(), getProofingUrl())); // 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 = () => { 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 (
setIsModalOpen(false)} 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', }} > {/* This card used to read `--bg-surface` and `--border-color`, which are defined nowhere, so it always fell back to the hard-coded dark values. In the light theme that meant a #1e1e1e card under `--text-primary`, which does exist there and resolves to #1a1a18: dark text on a dark background, about 1.05:1. It now uses tokens that exist. */}
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)', }} >

{t.proofing.modalTitle(selectedCount)}

{t.proofing.intro(selectedCount)}

{downloadArchiveUrl && ( )} {/* The primary action is the download when there is one; otherwise copying the link stays the accent button it always was. */} {manualCopy && (