Add full editing for client proofing links
This commit is contained in:
@@ -208,6 +208,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(() => {
|
||||||
@@ -307,6 +395,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"
|
||||||
@@ -353,6 +448,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">
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user