Compare commits

..

3 Commits

5 changed files with 523 additions and 122 deletions

View File

@@ -20,20 +20,12 @@ interface AlbumOption {
assetCount: number; assetCount: number;
} }
type Download = AdminProofSession['download'];
const STATE_LABEL: Record<AdminProofSession['state'], string> = { const STATE_LABEL: Record<AdminProofSession['state'], string> = {
open: 'Open', open: 'Open',
submitted: 'Submitted', submitted: 'Submitted',
expired: 'Expired', expired: 'Expired',
}; };
const DOWNLOAD_LABEL: Record<Download, string> = {
none: 'No downloads',
selection: 'Selection as ZIP',
album: 'Whole album as ZIP',
};
function formatDate(iso: string | null): string { function formatDate(iso: string | null): string {
if (!iso) return '—'; if (!iso) return '—';
const date = iso.length === 10 ? new Date(`${iso}T12:00:00`) : new Date(iso); const date = iso.length === 10 ? new Date(`${iso}T12:00:00`) : new Date(iso);
@@ -54,7 +46,7 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
const [albumId, setAlbumId] = useState(''); const [albumId, setAlbumId] = useState('');
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
const [expiresOn, setExpiresOn] = useState(''); const [expiresOn, setExpiresOn] = useState('');
const [download, setDownload] = useState<Download>('none'); const [downloadsAllowed, setDownloadsAllowed] = useState(false);
const [limit, setLimit] = useState(''); const [limit, setLimit] = useState('');
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -80,15 +72,15 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
alias: alias.trim() || null, alias: alias.trim() || null,
albumId, albumId,
expiresOn: expiresOn || null, expiresOn: expiresOn || null,
download, download: downloadsAllowed ? 'selection' : 'none',
downloadLimit: download !== 'none' && limit ? Number(limit) : null, downloadLimit: downloadsAllowed && limit ? Number(limit) : null,
}), }),
}); });
setClientName(''); setClientName('');
setAlias(''); setAlias('');
setAlbumId(''); setAlbumId('');
setExpiresOn(''); setExpiresOn('');
setDownload('none'); setDownloadsAllowed(false);
setLimit(''); setLimit('');
onCreated(); onCreated();
} catch (err) { } catch (err) {
@@ -116,23 +108,25 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
maxLength={120} maxLength={120}
required required
/> />
<div className="admin-field">
<label htmlFor="proof-alias">Client URL alias (optional)</label>
<input
id="proof-alias"
value={alias}
onChange={(e) =>
setAlias(
e.target.value
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9-]/g, ''),
)
}
placeholder="Client"
maxLength={64}
/>
</div>
</div> </div>
<div className="admin-field">
<label htmlFor="proof-alias">Client URL alias</label>
<input
id="proof-alias"
value={alias}
onChange={(e) =>
setAlias(
e.target.value
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9-]/g, ''),
)
}
placeholder="Client (Optional)"
maxLength={64}
/>
</div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor="proof-album">Album</label> <label htmlFor="proof-album">Album</label>
<select <select
@@ -142,7 +136,10 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
required required
disabled={!albums} disabled={!albums}
> >
<option value="">{albums ? 'Choose an Immich album…' : 'Loading albums…'}</option> <option value="">
{albums ? 'Choose an Immich album…' : 'Loading albums…'}
</option>
{albums?.map((album) => ( {albums?.map((album) => (
<option key={album.id} value={album.id}> <option key={album.id} value={album.id}>
{album.albumName} ({album.assetCount}) {album.albumName} ({album.assetCount})
@@ -150,6 +147,7 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
))} ))}
</select> </select>
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor="proof-expires">Valid until (optional)</label> <label htmlFor="proof-expires">Valid until (optional)</label>
<input <input
@@ -159,21 +157,22 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
onChange={(e) => setExpiresOn(e.target.value)} onChange={(e) => setExpiresOn(e.target.value)}
/> />
</div> </div>
<div className="admin-field"> <div className="admin-field">
<label htmlFor="proof-download">Downloads</label> <label htmlFor="proof-download">Downloads</label>
<select <select
id="proof-download" id="proof-download"
value={download} value={downloadsAllowed ? 'allowed' : 'none'}
onChange={(e) => setDownload(e.target.value as Download)} onChange={(e) =>
setDownloadsAllowed(e.target.value === 'allowed')
}
> >
{(Object.keys(DOWNLOAD_LABEL) as Download[]).map((key) => ( <option value="none">Downloads disabled</option>
<option key={key} value={key}> <option value="allowed">Downloads allowed</option>
{DOWNLOAD_LABEL[key]}
</option>
))}
</select> </select>
</div> </div>
{download !== 'none' && (
{downloadsAllowed && (
<div className="admin-field"> <div className="admin-field">
<label htmlFor="proof-limit">Download limit (optional)</label> <label htmlFor="proof-limit">Download limit (optional)</label>
<input <input
@@ -216,6 +215,94 @@ function SessionCard({
const [picks, setPicks] = useState<AdminProofPick[] | null>(null); const [picks, setPicks] = useState<AdminProofPick[] | null>(null);
const [copied, setCopied] = useState<'link' | 'lightroom' | null>(null); const [copied, setCopied] = useState<'link' | 'lightroom' | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [albums, setAlbums] = useState<AlbumOption[] | null>(null);
const [editClientName, setEditClientName] = useState(session.clientName);
const [editAlias, setEditAlias] = useState(session.alias ?? '');
const [editAlbumId, setEditAlbumId] = useState(session.albumId);
const [editExpiresOn, setEditExpiresOn] = useState(session.expiresOn ?? '');
const [editDownloadsAllowed, setEditDownloadsAllowed] = useState(
session.download !== 'none',
);
const [editLimit, setEditLimit] = useState(
session.downloadLimit !== null
? String(session.downloadLimit)
: '',
);
const [editBusy, setEditBusy] = useState(false);
const beginEdit = async () => {
setEditClientName(session.clientName);
setEditAlias(session.alias ?? '');
setEditAlbumId(session.albumId);
setEditExpiresOn(session.expiresOn ?? '');
setEditDownloadsAllowed(session.download !== 'none');
setEditLimit(
session.downloadLimit !== null
? String(session.downloadLimit)
: '',
);
setError(null);
if (!albums) {
try {
const data = await api<{ albums: AlbumOption[] }>('/api/admin/albums');
setAlbums(
[...data.albums].sort((a, b) =>
a.albumName.localeCompare(b.albumName),
),
);
} catch (err) {
setError((err as Error).message);
return;
}
}
setEditing(true);
};
const saveEdit = async () => {
setEditBusy(true);
setError(null);
try {
await api(`/api/admin/proofing/${session.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
clientName: editClientName.trim(),
alias: editAlias.trim() || null,
albumId: editAlbumId,
expiresOn: editExpiresOn || null,
// We keep "selection" internally as our generic
// "downloads allowed" state.
download: editDownloadsAllowed
? 'selection'
: 'none',
downloadLimit:
editDownloadsAllowed && editLimit
? Number(editLimit)
: null,
}),
});
setEditing(false);
onChanged();
} catch (err) {
setError((err as Error).message);
} finally {
setEditBusy(false);
}
};
const linkUrl = typeof window !== 'undefined' ? `${window.location.origin}${session.link}` : ''; const linkUrl = typeof window !== 'undefined' ? `${window.location.origin}${session.link}` : '';
useEffect(() => { useEffect(() => {
@@ -277,11 +364,13 @@ function SessionCard({
}; };
const downloadsText = const downloadsText =
session.download === 'none' session.download === 'none'
? DOWNLOAD_LABEL.none ? 'Downloads disabled'
: `${DOWNLOAD_LABEL[session.download]} · ${session.downloadsUsed}${ : `Downloads allowed · ${session.downloadsUsed}${
session.downloadLimit !== null ? ` / ${session.downloadLimit}` : '' session.downloadLimit !== null
} used`; ? ` / ${session.downloadLimit}`
: ''
} used`;
return ( return (
<div className={`analytics-panel proofing-card proofing-card--${session.state}`}> <div className={`analytics-panel proofing-card proofing-card--${session.state}`}>
@@ -313,6 +402,13 @@ function SessionCard({
{copied === 'link' ? <Icons.IconCheck size={14} /> : <Icons.IconCopy size={14} />}{' '} {copied === 'link' ? <Icons.IconCheck size={14} /> : <Icons.IconCopy size={14} />}{' '}
{copied === 'link' ? 'Copied' : 'Copy link'} {copied === 'link' ? 'Copied' : 'Copy link'}
</button> </button>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => void beginEdit()}
>
<Icons.IconLink size={14} /> Edit link
</button>
<button <button
type="button" type="button"
className="admin-btn admin-btn-ghost admin-btn-sm" className="admin-btn admin-btn-ghost admin-btn-sm"
@@ -359,6 +455,131 @@ function SessionCard({
{error && <p className="admin-error">{error}</p>} {error && <p className="admin-error">{error}</p>}
{editing && (
<div className="analytics-panel" style={{ marginTop: '1rem' }}>
<div className="proofing-create-grid">
<div className="admin-field">
<label>Client</label>
<input
value={editClientName}
onChange={(e) => setEditClientName(e.target.value)}
maxLength={120}
/>
</div>
<div className="admin-field">
<label>Client URL alias</label>
<input
value={editAlias}
onChange={(e) =>
setEditAlias(
e.target.value
.replace(/\s+/g, '-')
.replace(/[^A-Za-z0-9-]/g, ''),
)
}
maxLength={64}
placeholder="Mita"
/>
<small>
/c/{editAlias || 'Mita'}
</small>
</div>
<div className="admin-field">
<label>Album</label>
<select
value={editAlbumId}
onChange={(e) => setEditAlbumId(e.target.value)}
disabled={!albums}
>
{albums?.map((album) => (
<option key={album.id} value={album.id}>
{album.albumName} ({album.assetCount})
</option>
))}
</select>
</div>
<div className="admin-field">
<label>Valid until (optional)</label>
<input
type="date"
value={editExpiresOn}
onChange={(e) => setEditExpiresOn(e.target.value)}
/>
</div>
<div className="admin-field">
<label>Downloads</label>
<select
value={editDownloadsAllowed ? 'allowed' : 'none'}
onChange={(e) =>
setEditDownloadsAllowed(
e.target.value === 'allowed',
)
}
>
<option value="none">Downloads disabled</option>
<option value="allowed">Downloads allowed</option>
</select>
</div>
{editDownloadsAllowed && (
<div className="admin-field">
<label>Download limit (optional)</label>
<input
type="number"
min={1}
max={1000}
value={editLimit}
onChange={(e) => setEditLimit(e.target.value)}
placeholder="Unlimited"
/>
</div>
)}
</div>
{editAlbumId !== session.albumId && (
<p className="analytics-subtitle">
Changing the album will clear this client's current selection,
submission state and download counter.
</p>
)}
<div
className="proofing-card-actions"
style={{ marginTop: '1rem' }}
>
<button
type="button"
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={() => void saveEdit()}
disabled={
editBusy ||
!editClientName.trim() ||
!editAlbumId
}
>
{editBusy ? 'Saving…' : 'Save changes'}
</button>
<button
type="button"
className="admin-btn admin-btn-ghost admin-btn-sm"
onClick={() => setEditing(false)}
disabled={editBusy}
>
Cancel
</button>
</div>
</div>
)}
{expanded && ( {expanded && (
<div className="proofing-card-body"> <div className="proofing-card-body">
<div className="proofing-card-actions"> <div className="proofing-card-actions">

View File

@@ -39,8 +39,19 @@ export const PATCH = withAdmin(async (request: NextRequest, { params }: Context)
const album = await immich.getProofingAlbum(session.albumId).catch(() => null); const album = await immich.getProofingAlbum(session.albumId).catch(() => null);
return NextResponse.json({ session: toAdminSession(session, album) }); return NextResponse.json({ session: toAdminSession(session, album) });
} catch (err) { } catch (err) {
if (err instanceof ProofError) if (err instanceof ProofError) {
return NextResponse.json({ error: err.message }, { status: 404 }); return NextResponse.json(
{ error: err.message },
{
status:
err.code === 'alias-taken'
? 409
: err.code === 'not-found'
? 404
: 400,
},
);
}
throw err; throw err;
} }
}); });

View File

@@ -1,13 +1,5 @@
'use client'; '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 { useCallback, useState } from 'react';
import { useProofing } from './ProofingContext'; import { useProofing } from './ProofingContext';
import { useDictionary } from './I18nProvider'; import { useDictionary } from './I18nProvider';
@@ -22,8 +14,8 @@ const barStyle: React.CSSProperties = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: '12px', gap: '10px',
padding: '8px 16px', padding: '8px 12px',
borderRadius: 'var(--radius-lg)', borderRadius: 'var(--radius-lg)',
background: 'var(--bg-card, #1e1e1e)', background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)', color: 'var(--text-primary, #ffffff)',
@@ -33,6 +25,32 @@ const barStyle: React.CSSProperties = {
maxWidth: 'calc(100vw - 32px)', 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 = { const primaryButton: React.CSSProperties = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -41,37 +59,39 @@ const primaryButton: React.CSSProperties = {
padding: '0.75rem 1rem', padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm, 6px)', borderRadius: 'var(--radius-sm, 6px)',
background: 'var(--accent, #e60012)', background: 'var(--accent, #e60012)',
// Black or white, whichever the layout found to carry on this accent.
color: 'var(--on-accent, #fff)', color: 'var(--on-accent, #fff)',
border: 'none', border: 'none',
fontWeight: 500, fontWeight: 500,
// The same for the <button>s and the download <a>s, which would otherwise
// take the browser's differing defaults.
fontFamily: 'inherit', fontFamily: 'inherit',
fontSize: '0.9rem', fontSize: '0.9rem',
lineHeight: 1.3, lineHeight: 1.3,
cursor: 'pointer', 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() { export function ProofSessionControls() {
const t = useDictionary(); const t = useDictionary();
const proofing = useProofing(); const proofing = useProofing();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const close = useCallback(() => setOpen(false), []); const close = useCallback(() => setOpen(false), []);
const cardRef = useModalDialog(close, open); const cardRef = useModalDialog(close, open);
if (!proofing?.session) return null; if (!proofing?.session) return null;
const { session, isFilterActive, setIsFilterActive, getSelectedTokens } = proofing;
const {
session,
isFilterActive,
setIsFilterActive,
getSelectedTokens,
albumCount,
toggleSelectAll,
} = proofing;
const count = getSelectedTokens().length; const count = getSelectedTokens().length;
const allSelected = albumCount > 0 && count === albumCount;
const p = t.proofSession; const p = t.proofSession;
const status = const status =
@@ -89,49 +109,135 @@ export function ProofSessionControls() {
setSubmitting(false); setSubmitting(false);
}; };
const downloadHref = (scope: 'selection' | 'album') => `${session.archiveUrl}?scope=${scope}`;
const downloadsLeft = session.downloadsRemaining; 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 * "selection" and legacy "album" are both treated simply as
// autosave debounce counts, or the ZIP would hold the previous selection. * "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 = const selectionSaved =
!session.unsaved && session.saveState !== 'saving' && session.saveState !== 'error'; !session.unsaved &&
session.saveState !== 'saving' &&
session.saveState !== 'error';
const downloadReady =
downloadsAllowed &&
count > 0 &&
selectionSaved;
const downloadHref =
`${session.archiveUrl}?scope=selection`;
return ( return (
<> <>
<div className="proofing-sticky-bar proof-session-bar" style={barStyle}> <div
{(count > 0 || isFilterActive) && ( 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 <button
type="button" type="button"
onClick={() => setIsFilterActive((prev) => !prev)} onClick={toggleSelectAll}
aria-pressed={isFilterActive} disabled={albumCount === 0}
style={{ style={{
background: isFilterActive ? 'var(--accent, #e60012)' : 'var(--bg-card-hover)', ...barButton,
color: isFilterActive ? 'var(--on-accent, #fff)' : 'var(--text-primary)', opacity: albumCount === 0 ? 0.5 : 1,
border: 'none', cursor: albumCount === 0 ? 'not-allowed' : 'pointer',
padding: '6px 14px',
borderRadius: 'var(--radius-md)',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
}} }}
> >
{isFilterActive ? t.proofing.showAll : t.proofing.selected(count)} {allSelected ? 'Deselect all' : 'Select all'}
</button> </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 <span
role="status" role="status"
aria-live="polite" aria-live="polite"
style={{ style={{
fontSize: '0.8rem', fontSize: '0.8rem',
// A failed save is an error, not a brand mark: the accent can be ...(session.saveState === 'error'
// white (minimal) or pale, and the dimming would halve it again. ? { color: 'var(--error)' }
...(session.saveState === 'error' ? { color: 'var(--error)' } : { opacity: 0.75 }), : { opacity: 0.75 }),
}} }}
> >
{session.submitted ? p.locked : status} {session.submitted ? p.locked : status}
</span> </span>
<button <button
type="button" type="button"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
@@ -145,7 +251,9 @@ export function ProofSessionControls() {
textDecoration: 'underline', textDecoration: 'underline',
}} }}
> >
{session.submitted ? p.modalTitle(count) : p.review} {session.submitted
? p.modalTitle(count)
: p.review}
</button> </button>
</div> </div>
@@ -176,7 +284,8 @@ export function ProofSessionControls() {
background: 'var(--bg-card, #1e1e1e)', background: 'var(--bg-card, #1e1e1e)',
color: 'var(--text-primary, #ffffff)', color: 'var(--text-primary, #ffffff)',
borderRadius: 'var(--radius-md, 12px)', borderRadius: 'var(--radius-md, 12px)',
border: '1px solid var(--border-subtle, rgba(255,255,255,0.1))', border:
'1px solid var(--border-subtle, rgba(255,255,255,0.1))',
padding: '1.5rem', padding: '1.5rem',
maxWidth: '480px', maxWidth: '480px',
width: '100%', width: '100%',
@@ -193,10 +302,15 @@ export function ProofSessionControls() {
> >
<h3 <h3
id="proof-session-title" id="proof-session-title"
style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }} style={{
margin: 0,
fontSize: '1.2rem',
fontWeight: 600,
}}
> >
{p.modalTitle(count)} {p.modalTitle(count)}
</h3> </h3>
<button <button
type="button" type="button"
onClick={close} onClick={close}
@@ -215,11 +329,27 @@ export function ProofSessionControls() {
</button> </button>
</div> </div>
<p style={{ fontSize: '0.9rem', opacity: 0.85, marginBottom: '1rem' }}> <p
{session.submitted ? p.submitted : count === 0 ? p.empty : p.confirmSubmit(count)} style={{
fontSize: '0.9rem',
opacity: 0.85,
marginBottom: '1rem',
}}
>
{session.submitted
? p.submitted
: count === 0
? p.empty
: p.confirmSubmit(count)}
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}> <div
style={{
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
{!session.submitted && ( {!session.submitted && (
<button <button
type="button" type="button"
@@ -227,39 +357,46 @@ export function ProofSessionControls() {
disabled={count === 0 || submitting} disabled={count === 0 || submitting}
style={{ style={{
...primaryButton, ...primaryButton,
cursor: count === 0 || submitting ? 'not-allowed' : 'pointer', cursor:
opacity: count === 0 || submitting ? 0.6 : 1, count === 0 || submitting
? 'not-allowed'
: 'pointer',
opacity:
count === 0 || submitting
? 0.6
: 1,
}} }}
> >
{submitting ? p.submitting : p.submit} {submitting
? p.submitting
: p.submit}
</button> </button>
)} )}
{canDownload && count > 0 && selectionSaved && ( {session.download !== 'none' &&
<a downloadsLeft !== null && (
href={downloadHref('selection')} <p
onClick={session.countDownload} style={{
style={secondaryButton} fontSize: '0.8rem',
> opacity: 0.7,
{p.downloadSelection} margin: 0,
</a> textAlign: 'center',
)} }}
{canDownload && session.download === 'album' && ( >
<a {downloadsLeft > 0
href={downloadHref('album')} ? p.downloadsLeft(downloadsLeft)
onClick={session.countDownload} : t.download.limitReached}
style={secondaryButton} </p>
> )}
{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' && ( {session.saveState === 'error' && (
<p role="alert" style={{ fontSize: '0.85rem', margin: 0 }}> <p
role="alert"
style={{
fontSize: '0.85rem',
margin: 0,
}}
>
{p.saveFailed} {p.saveFailed}
</p> </p>
)} )}

View File

@@ -59,6 +59,11 @@ export interface ProofingContextType {
getFormattedList: () => string; getFormattedList: () => string;
/** Selected asset tokens in album order — the payload for a ZIP download. */ /** Selected asset tokens in album order — the payload for a ZIP download. */
getSelectedTokens: () => string[]; getSelectedTokens: () => string[];
/** Number of assets in the current album. */
albumCount: number;
/** Select every asset, or clear everything when all are already selected. */
toggleSelectAll: () => void;
allowMailto: boolean; allowMailto: boolean;
/** /**
* `encodeEmail()` of the "email to photographer" recipient, when one is * `encodeEmail()` of the "email to photographer" recipient, when one is
@@ -284,6 +289,20 @@ export function ProofingProvider({
} }
}; };
const toggleSelectAll = () => {
if (!sessionInit || submitted) return;
editSelection((prev) => {
const allSelected =
albumTokens.length > 0 &&
albumTokens.every((token) => prev.has(token));
return allSelected
? new Set<string>()
: new Set(albumTokens);
});
};
const getProofingUrl = () => { const getProofingUrl = () => {
if (albumTokens.length === 0 || favorites.size === 0) { if (albumTokens.length === 0 || favorites.size === 0) {
return typeof window !== 'undefined' ? window.location.href : ''; return typeof window !== 'undefined' ? window.location.href : '';
@@ -335,6 +354,8 @@ export function ProofingProvider({
getProofingUrl, getProofingUrl,
getFormattedList, getFormattedList,
getSelectedTokens, getSelectedTokens,
albumCount: albumTokens.length,
toggleSelectAll,
allowMailto, allowMailto,
encodedMailto, encodedMailto,
downloadArchiveUrl, downloadArchiveUrl,

View File

@@ -134,7 +134,6 @@ export const sessionInputSchema = z.object({
export type SessionInput = z.infer<typeof sessionInputSchema>; export type SessionInput = z.infer<typeof sessionInputSchema>;
export const sessionPatchSchema = sessionInputSchema export const sessionPatchSchema = sessionInputSchema
.omit({ albumId: true })
.partial() .partial()
.extend({ .extend({
/** Unlock a submitted selection so the client can change it again. */ /** Unlock a submitted selection so the client can change it again. */
@@ -295,6 +294,18 @@ export async function updateSession(id: string, patch: SessionPatch): Promise<Pr
} }
} }
if (patch.albumId !== undefined && patch.albumId !== session.albumId) {
session.albumId = patch.albumId;
// Picks from the previous album are no longer meaningful.
session.selection = [];
delete session.updatedAt;
delete session.submittedAt;
// A changed album starts fresh from the client's perspective.
session.downloadsUsed = 0;
}
if (patch.clientName !== undefined) session.clientName = patch.clientName; if (patch.clientName !== undefined) session.clientName = patch.clientName;
if (patch.download !== undefined) session.download = patch.download; if (patch.download !== undefined) session.download = patch.download;
// `null` clears, `undefined` leaves alone. // `null` clears, `undefined` leaves alone.