410 lines
11 KiB
TypeScript
410 lines
11 KiB
TypeScript
'use client';
|
|
|
|
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: '10px',
|
|
padding: '8px 12px',
|
|
borderRadius: 'var(--radius-lg)',
|
|
background: 'var(--bg-card, #1e1e1e)',
|
|
color: 'var(--text-primary, #ffffff)',
|
|
border: '1px solid var(--border-subtle, rgba(255,255,255,0.15))',
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
|
backdropFilter: 'blur(8px)',
|
|
maxWidth: 'calc(100vw - 32px)',
|
|
};
|
|
|
|
const barButton: React.CSSProperties = {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
gap: '0.4rem',
|
|
padding: '7px 12px',
|
|
borderRadius: 'var(--radius-md)',
|
|
background: 'var(--bg-card-hover)',
|
|
color: 'var(--text-primary)',
|
|
border: '1px solid var(--border-subtle)',
|
|
fontFamily: 'inherit',
|
|
fontSize: '0.85rem',
|
|
fontWeight: 500,
|
|
lineHeight: 1.3,
|
|
cursor: 'pointer',
|
|
textDecoration: 'none',
|
|
};
|
|
|
|
const downloadButton: React.CSSProperties = {
|
|
...barButton,
|
|
background: 'var(--accent, #e60012)',
|
|
color: 'var(--on-accent, #fff)',
|
|
border: 'none',
|
|
fontWeight: 600,
|
|
};
|
|
|
|
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)',
|
|
color: 'var(--on-accent, #fff)',
|
|
border: 'none',
|
|
fontWeight: 500,
|
|
fontFamily: 'inherit',
|
|
fontSize: '0.9rem',
|
|
lineHeight: 1.3,
|
|
cursor: 'pointer',
|
|
};
|
|
|
|
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,
|
|
albumCount,
|
|
toggleSelectAll,
|
|
} = proofing;
|
|
|
|
const count = getSelectedTokens().length;
|
|
const allSelected = albumCount > 0 && count === albumCount;
|
|
|
|
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 downloadsLeft = session.downloadsRemaining;
|
|
|
|
/*
|
|
* "selection" and legacy "album" are both treated simply as
|
|
* "downloads allowed" in our UI.
|
|
*/
|
|
const downloadsAllowed =
|
|
session.download !== 'none' &&
|
|
(downloadsLeft === null || downloadsLeft > 0);
|
|
|
|
/*
|
|
* The ZIP route reads the server-side saved selection, not merely the
|
|
* current browser state. Do not enable the button until autosave has
|
|
* caught up, otherwise a very fast click could download the previous
|
|
* selection.
|
|
*/
|
|
const selectionSaved =
|
|
!session.unsaved &&
|
|
session.saveState !== 'saving' &&
|
|
session.saveState !== 'error';
|
|
|
|
const downloadReady =
|
|
downloadsAllowed &&
|
|
count > 0 &&
|
|
selectionSaved;
|
|
|
|
const downloadHref =
|
|
`${session.archiveUrl}?scope=selection`;
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="proofing-sticky-bar proof-session-bar"
|
|
style={barStyle}
|
|
>
|
|
{/* Persistent download button */}
|
|
{session.download !== 'none' &&
|
|
(downloadReady ? (
|
|
<a
|
|
href={downloadHref}
|
|
onClick={session.countDownload}
|
|
style={downloadButton}
|
|
title={
|
|
downloadsLeft === null
|
|
? `Download ${count} selected photos as ZIP`
|
|
: `Download ${count} selected photos as ZIP — ${downloadsLeft} downloads remaining`
|
|
}
|
|
>
|
|
↓ Download ZIP ({count})
|
|
</a>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
disabled
|
|
style={{
|
|
...downloadButton,
|
|
cursor: 'not-allowed',
|
|
opacity: 0.45,
|
|
}}
|
|
title={
|
|
downloadsLeft === 0
|
|
? 'Download limit reached'
|
|
: count === 0
|
|
? 'Select at least one photo'
|
|
: session.saveState === 'error'
|
|
? 'Selection could not be saved'
|
|
: 'Saving selection…'
|
|
}
|
|
>
|
|
↓ Download ZIP ({count})
|
|
</button>
|
|
))}
|
|
|
|
{/* Select / deselect every photo */}
|
|
{!session.submitted && (
|
|
<button
|
|
type="button"
|
|
onClick={toggleSelectAll}
|
|
disabled={albumCount === 0}
|
|
style={{
|
|
...barButton,
|
|
opacity: albumCount === 0 ? 0.5 : 1,
|
|
cursor: albumCount === 0 ? 'not-allowed' : 'pointer',
|
|
}}
|
|
>
|
|
{allSelected ? 'Deselect all' : 'Select all'}
|
|
</button>
|
|
)}
|
|
|
|
{/* Selection counter / filter */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (count > 0 || isFilterActive) {
|
|
setIsFilterActive((prev) => !prev);
|
|
}
|
|
}}
|
|
aria-pressed={isFilterActive}
|
|
disabled={count === 0 && !isFilterActive}
|
|
style={{
|
|
...barButton,
|
|
background: isFilterActive
|
|
? 'var(--accent, #e60012)'
|
|
: 'var(--bg-card-hover)',
|
|
color: isFilterActive
|
|
? 'var(--on-accent, #fff)'
|
|
: 'var(--text-primary)',
|
|
cursor:
|
|
count === 0 && !isFilterActive
|
|
? 'default'
|
|
: 'pointer',
|
|
}}
|
|
>
|
|
{t.proofing.selected(count)}
|
|
</button>
|
|
|
|
<span
|
|
role="status"
|
|
aria-live="polite"
|
|
style={{
|
|
fontSize: '0.8rem',
|
|
...(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>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|