Compare commits
3 Commits
416fae33a2
...
koko-custo
| Author | SHA1 | Date | |
|---|---|---|---|
| 183910c62f | |||
| 2e6f027b71 | |||
| fe4c716aa8 |
@@ -20,20 +20,12 @@ interface AlbumOption {
|
||||
assetCount: number;
|
||||
}
|
||||
|
||||
type Download = AdminProofSession['download'];
|
||||
|
||||
const STATE_LABEL: Record<AdminProofSession['state'], string> = {
|
||||
open: 'Open',
|
||||
submitted: 'Submitted',
|
||||
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 {
|
||||
if (!iso) return '—';
|
||||
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 [alias, setAlias] = useState('');
|
||||
const [expiresOn, setExpiresOn] = useState('');
|
||||
const [download, setDownload] = useState<Download>('none');
|
||||
const [downloadsAllowed, setDownloadsAllowed] = useState(false);
|
||||
const [limit, setLimit] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -80,15 +72,15 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
|
||||
alias: alias.trim() || null,
|
||||
albumId,
|
||||
expiresOn: expiresOn || null,
|
||||
download,
|
||||
downloadLimit: download !== 'none' && limit ? Number(limit) : null,
|
||||
download: downloadsAllowed ? 'selection' : 'none',
|
||||
downloadLimit: downloadsAllowed && limit ? Number(limit) : null,
|
||||
}),
|
||||
});
|
||||
setClientName('');
|
||||
setAlias('');
|
||||
setAlbumId('');
|
||||
setExpiresOn('');
|
||||
setDownload('none');
|
||||
setDownloadsAllowed(false);
|
||||
setLimit('');
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
@@ -116,23 +108,25 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
|
||||
maxLength={120}
|
||||
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 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">
|
||||
<label htmlFor="proof-album">Album</label>
|
||||
<select
|
||||
@@ -142,7 +136,10 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
|
||||
required
|
||||
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) => (
|
||||
<option key={album.id} value={album.id}>
|
||||
{album.albumName} ({album.assetCount})
|
||||
@@ -150,6 +147,7 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proof-expires">Valid until (optional)</label>
|
||||
<input
|
||||
@@ -159,21 +157,22 @@ function CreateForm({ onCreated }: { onCreated: () => void }) {
|
||||
onChange={(e) => setExpiresOn(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proof-download">Downloads</label>
|
||||
<select
|
||||
id="proof-download"
|
||||
value={download}
|
||||
onChange={(e) => setDownload(e.target.value as Download)}
|
||||
value={downloadsAllowed ? 'allowed' : 'none'}
|
||||
onChange={(e) =>
|
||||
setDownloadsAllowed(e.target.value === 'allowed')
|
||||
}
|
||||
>
|
||||
{(Object.keys(DOWNLOAD_LABEL) as Download[]).map((key) => (
|
||||
<option key={key} value={key}>
|
||||
{DOWNLOAD_LABEL[key]}
|
||||
</option>
|
||||
))}
|
||||
<option value="none">Downloads disabled</option>
|
||||
<option value="allowed">Downloads allowed</option>
|
||||
</select>
|
||||
</div>
|
||||
{download !== 'none' && (
|
||||
|
||||
{downloadsAllowed && (
|
||||
<div className="admin-field">
|
||||
<label htmlFor="proof-limit">Download limit (optional)</label>
|
||||
<input
|
||||
@@ -216,6 +215,94 @@ function SessionCard({
|
||||
const [picks, setPicks] = useState<AdminProofPick[] | null>(null);
|
||||
const [copied, setCopied] = useState<'link' | 'lightroom' | 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}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -277,11 +364,13 @@ function SessionCard({
|
||||
};
|
||||
|
||||
const downloadsText =
|
||||
session.download === 'none'
|
||||
? DOWNLOAD_LABEL.none
|
||||
: `${DOWNLOAD_LABEL[session.download]} · ${session.downloadsUsed}${
|
||||
session.downloadLimit !== null ? ` / ${session.downloadLimit}` : ''
|
||||
} used`;
|
||||
session.download === 'none'
|
||||
? 'Downloads disabled'
|
||||
: `Downloads allowed · ${session.downloadsUsed}${
|
||||
session.downloadLimit !== null
|
||||
? ` / ${session.downloadLimit}`
|
||||
: ''
|
||||
} used`;
|
||||
|
||||
return (
|
||||
<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' ? 'Copied' : 'Copy link'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
onClick={() => void beginEdit()}
|
||||
>
|
||||
<Icons.IconLink size={14} /> Edit link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-ghost admin-btn-sm"
|
||||
@@ -359,6 +455,131 @@ function SessionCard({
|
||||
|
||||
{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 && (
|
||||
<div className="proofing-card-body">
|
||||
<div className="proofing-card-actions">
|
||||
|
||||
@@ -39,8 +39,19 @@ export const PATCH = withAdmin(async (request: NextRequest, { params }: Context)
|
||||
const album = await immich.getProofingAlbum(session.albumId).catch(() => null);
|
||||
return NextResponse.json({ session: toAdminSession(session, album) });
|
||||
} catch (err) {
|
||||
if (err instanceof ProofError)
|
||||
return NextResponse.json({ error: err.message }, { status: 404 });
|
||||
if (err instanceof ProofError) {
|
||||
return NextResponse.json(
|
||||
{ error: err.message },
|
||||
{
|
||||
status:
|
||||
err.code === 'alias-taken'
|
||||
? 409
|
||||
: err.code === 'not-found'
|
||||
? 404
|
||||
: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
'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 { useProofing } from './ProofingContext';
|
||||
import { useDictionary } from './I18nProvider';
|
||||
@@ -22,8 +14,8 @@ const barStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: '12px',
|
||||
padding: '8px 16px',
|
||||
gap: '10px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
background: 'var(--bg-card, #1e1e1e)',
|
||||
color: 'var(--text-primary, #ffffff)',
|
||||
@@ -33,6 +25,32 @@ const barStyle: React.CSSProperties = {
|
||||
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',
|
||||
@@ -41,37 +59,39 @@ const primaryButton: React.CSSProperties = {
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
background: 'var(--accent, #e60012)',
|
||||
// Black or white, whichever the layout found to carry on this accent.
|
||||
color: 'var(--on-accent, #fff)',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
// The same for the <button>s and the download <a>s, which would otherwise
|
||||
// take the browser's differing defaults.
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.9rem',
|
||||
lineHeight: 1.3,
|
||||
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() {
|
||||
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 } = proofing;
|
||||
|
||||
const {
|
||||
session,
|
||||
isFilterActive,
|
||||
setIsFilterActive,
|
||||
getSelectedTokens,
|
||||
albumCount,
|
||||
toggleSelectAll,
|
||||
} = proofing;
|
||||
|
||||
const count = getSelectedTokens().length;
|
||||
const allSelected = albumCount > 0 && count === albumCount;
|
||||
|
||||
const p = t.proofSession;
|
||||
|
||||
const status =
|
||||
@@ -89,49 +109,135 @@ export function ProofSessionControls() {
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const downloadHref = (scope: 'selection' | 'album') => `${session.archiveUrl}?scope=${scope}`;
|
||||
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
|
||||
// autosave debounce counts, or the ZIP would hold the previous selection.
|
||||
|
||||
/*
|
||||
* "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';
|
||||
!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}>
|
||||
{(count > 0 || isFilterActive) && (
|
||||
<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={() => setIsFilterActive((prev) => !prev)}
|
||||
aria-pressed={isFilterActive}
|
||||
onClick={toggleSelectAll}
|
||||
disabled={albumCount === 0}
|
||||
style={{
|
||||
background: isFilterActive ? 'var(--accent, #e60012)' : 'var(--bg-card-hover)',
|
||||
color: isFilterActive ? 'var(--on-accent, #fff)' : 'var(--text-primary)',
|
||||
border: 'none',
|
||||
padding: '6px 14px',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
...barButton,
|
||||
opacity: albumCount === 0 ? 0.5 : 1,
|
||||
cursor: albumCount === 0 ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
{isFilterActive ? t.proofing.showAll : t.proofing.selected(count)}
|
||||
{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',
|
||||
// A failed save is an error, not a brand mark: the accent can be
|
||||
// white (minimal) or pale, and the dimming would halve it again.
|
||||
...(session.saveState === 'error' ? { color: 'var(--error)' } : { opacity: 0.75 }),
|
||||
...(session.saveState === 'error'
|
||||
? { color: 'var(--error)' }
|
||||
: { opacity: 0.75 }),
|
||||
}}
|
||||
>
|
||||
{session.submitted ? p.locked : status}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
@@ -145,7 +251,9 @@ export function ProofSessionControls() {
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
{session.submitted ? p.modalTitle(count) : p.review}
|
||||
{session.submitted
|
||||
? p.modalTitle(count)
|
||||
: p.review}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -176,7 +284,8 @@ export function ProofSessionControls() {
|
||||
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))',
|
||||
border:
|
||||
'1px solid var(--border-subtle, rgba(255,255,255,0.1))',
|
||||
padding: '1.5rem',
|
||||
maxWidth: '480px',
|
||||
width: '100%',
|
||||
@@ -193,10 +302,15 @@ export function ProofSessionControls() {
|
||||
>
|
||||
<h3
|
||||
id="proof-session-title"
|
||||
style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }}
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{p.modalTitle(count)}
|
||||
</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
@@ -215,11 +329,27 @@ export function ProofSessionControls() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.9rem', opacity: 0.85, marginBottom: '1rem' }}>
|
||||
{session.submitted ? p.submitted : count === 0 ? p.empty : p.confirmSubmit(count)}
|
||||
<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' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{!session.submitted && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -227,39 +357,46 @@ export function ProofSessionControls() {
|
||||
disabled={count === 0 || submitting}
|
||||
style={{
|
||||
...primaryButton,
|
||||
cursor: count === 0 || submitting ? 'not-allowed' : 'pointer',
|
||||
opacity: count === 0 || submitting ? 0.6 : 1,
|
||||
cursor:
|
||||
count === 0 || submitting
|
||||
? 'not-allowed'
|
||||
: 'pointer',
|
||||
opacity:
|
||||
count === 0 || submitting
|
||||
? 0.6
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
{submitting ? p.submitting : p.submit}
|
||||
{submitting
|
||||
? p.submitting
|
||||
: p.submit}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canDownload && count > 0 && selectionSaved && (
|
||||
<a
|
||||
href={downloadHref('selection')}
|
||||
onClick={session.countDownload}
|
||||
style={secondaryButton}
|
||||
>
|
||||
{p.downloadSelection}
|
||||
</a>
|
||||
)}
|
||||
{canDownload && session.download === 'album' && (
|
||||
<a
|
||||
href={downloadHref('album')}
|
||||
onClick={session.countDownload}
|
||||
style={secondaryButton}
|
||||
>
|
||||
{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.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
|
||||
role="alert"
|
||||
style={{
|
||||
fontSize: '0.85rem',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{p.saveFailed}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -59,6 +59,11 @@ export interface ProofingContextType {
|
||||
getFormattedList: () => string;
|
||||
/** Selected asset tokens in album order — the payload for a ZIP download. */
|
||||
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;
|
||||
/**
|
||||
* `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 = () => {
|
||||
if (albumTokens.length === 0 || favorites.size === 0) {
|
||||
return typeof window !== 'undefined' ? window.location.href : '';
|
||||
@@ -335,6 +354,8 @@ export function ProofingProvider({
|
||||
getProofingUrl,
|
||||
getFormattedList,
|
||||
getSelectedTokens,
|
||||
albumCount: albumTokens.length,
|
||||
toggleSelectAll,
|
||||
allowMailto,
|
||||
encodedMailto,
|
||||
downloadArchiveUrl,
|
||||
|
||||
@@ -134,7 +134,6 @@ export const sessionInputSchema = z.object({
|
||||
export type SessionInput = z.infer<typeof sessionInputSchema>;
|
||||
|
||||
export const sessionPatchSchema = sessionInputSchema
|
||||
.omit({ albumId: true })
|
||||
.partial()
|
||||
.extend({
|
||||
/** 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.download !== undefined) session.download = patch.download;
|
||||
// `null` clears, `undefined` leaves alone.
|
||||
|
||||
Reference in New Issue
Block a user