Files
immich-folio/components/ProofSessionControls.tsx

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,
}}
>
&times;
</button>
</div>
<p
style={{
fontSize: '0.9rem',
opacity: 0.85,
marginBottom: '1rem',
}}
>
{session.submitted
? p.submitted
: count === 0
? p.empty
: p.confirmSubmit(count)}
</p>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
{!session.submitted && (
<button
type="button"
onClick={handleSubmit}
disabled={count === 0 || submitting}
style={{
...primaryButton,
cursor:
count === 0 || submitting
? 'not-allowed'
: 'pointer',
opacity:
count === 0 || submitting
? 0.6
: 1,
}}
>
{submitting
? p.submitting
: p.submit}
</button>
)}
{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>
)}
</>
);
}