Add full editing for client proofing links

This commit is contained in:
2026-10-04 18:16:03 +03:00
parent fe4c716aa8
commit 2e6f027b71
3 changed files with 245 additions and 3 deletions

View File

@@ -208,6 +208,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(() => {
@@ -307,6 +395,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"
@@ -353,6 +448,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">

View File

@@ -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;
}
});

View File

@@ -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.