2026-09-29 22:50:30 +02:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Admin → Proofing: client links, their selections and the export.
|
|
|
|
|
*
|
|
|
|
|
* One card per link. Creating a link picks a client name and an album — any
|
|
|
|
|
* Immich album, published or not — plus optional expiry and download rights.
|
|
|
|
|
* A card expands into the selected photos and the export buttons.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
|
import PageHeader from './PageHeader';
|
|
|
|
|
import * as Icons from './Icons';
|
|
|
|
|
import { reportIfSessionExpired } from './sessionExpiry';
|
|
|
|
|
import type { AdminProofPick, AdminProofSession } from '@/lib/admin/proofing-view';
|
|
|
|
|
|
|
|
|
|
interface AlbumOption {
|
|
|
|
|
id: string;
|
|
|
|
|
albumName: string;
|
|
|
|
|
assetCount: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const STATE_LABEL: Record<AdminProofSession['state'], string> = {
|
|
|
|
|
open: 'Open',
|
|
|
|
|
submitted: 'Submitted',
|
|
|
|
|
expired: 'Expired',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function formatDate(iso: string | null): string {
|
|
|
|
|
if (!iso) return '—';
|
|
|
|
|
const date = iso.length === 10 ? new Date(`${iso}T12:00:00`) : new Date(iso);
|
|
|
|
|
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function api<T>(url: string, init?: RequestInit): Promise<T> {
|
|
|
|
|
const res = await fetch(url, init);
|
|
|
|
|
if (reportIfSessionExpired(res)) throw new Error('Session expired');
|
|
|
|
|
const data = await res.json().catch(() => ({}));
|
|
|
|
|
if (!res.ok) throw new Error((data as { error?: string }).error || `HTTP ${res.status}`);
|
|
|
|
|
return data as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function CreateForm({ onCreated }: { onCreated: () => void }) {
|
|
|
|
|
const [albums, setAlbums] = useState<AlbumOption[] | null>(null);
|
|
|
|
|
const [clientName, setClientName] = useState('');
|
|
|
|
|
const [albumId, setAlbumId] = useState('');
|
2026-10-04 15:49:06 +03:00
|
|
|
const [alias, setAlias] = useState('');
|
2026-09-29 22:50:30 +02:00
|
|
|
const [expiresOn, setExpiresOn] = useState('');
|
2026-10-04 17:56:45 +03:00
|
|
|
const [downloadsAllowed, setDownloadsAllowed] = useState(false);
|
2026-09-29 22:50:30 +02:00
|
|
|
const [limit, setLimit] = useState('');
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
api<{ albums: AlbumOption[] }>('/api/admin/albums')
|
|
|
|
|
.then((data) =>
|
|
|
|
|
setAlbums([...data.albums].sort((a, b) => a.albumName.localeCompare(b.albumName))),
|
|
|
|
|
)
|
|
|
|
|
.catch((err: Error) => setError(err.message));
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setBusy(true);
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
await api('/api/admin/proofing', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
clientName,
|
2026-10-04 15:49:06 +03:00
|
|
|
alias: alias.trim() || null,
|
2026-09-29 22:50:30 +02:00
|
|
|
albumId,
|
|
|
|
|
expiresOn: expiresOn || null,
|
2026-10-04 17:56:45 +03:00
|
|
|
download: downloadsAllowed ? 'selection' : 'none',
|
|
|
|
|
downloadLimit: downloadsAllowed && limit ? Number(limit) : null,
|
2026-09-29 22:50:30 +02:00
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
setClientName('');
|
2026-10-04 15:49:06 +03:00
|
|
|
setAlias('');
|
2026-09-29 22:50:30 +02:00
|
|
|
setAlbumId('');
|
|
|
|
|
setExpiresOn('');
|
2026-10-04 17:56:45 +03:00
|
|
|
setDownloadsAllowed(false);
|
2026-09-29 22:50:30 +02:00
|
|
|
setLimit('');
|
|
|
|
|
onCreated();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<form className="analytics-panel proofing-create" onSubmit={submit}>
|
|
|
|
|
<div className="analytics-section-title">
|
|
|
|
|
<h3>
|
|
|
|
|
<Icons.IconPlus size={16} /> New client link
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="proofing-create-grid">
|
|
|
|
|
<div className="admin-field">
|
|
|
|
|
<label htmlFor="proof-client">Client</label>
|
|
|
|
|
<input
|
|
|
|
|
id="proof-client"
|
|
|
|
|
value={clientName}
|
|
|
|
|
onChange={(e) => setClientName(e.target.value)}
|
|
|
|
|
placeholder="Anna & Ben"
|
|
|
|
|
maxLength={120}
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-10-04 18:32:37 +03:00
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
2026-09-29 22:50:30 +02:00
|
|
|
<div className="admin-field">
|
|
|
|
|
<label htmlFor="proof-album">Album</label>
|
|
|
|
|
<select
|
|
|
|
|
id="proof-album"
|
|
|
|
|
value={albumId}
|
|
|
|
|
onChange={(e) => setAlbumId(e.target.value)}
|
|
|
|
|
required
|
|
|
|
|
disabled={!albums}
|
|
|
|
|
>
|
2026-10-04 18:32:37 +03:00
|
|
|
<option value="">
|
|
|
|
|
{albums ? 'Choose an Immich album…' : 'Loading albums…'}
|
|
|
|
|
</option>
|
|
|
|
|
|
2026-09-29 22:50:30 +02:00
|
|
|
{albums?.map((album) => (
|
|
|
|
|
<option key={album.id} value={album.id}>
|
|
|
|
|
{album.albumName} ({album.assetCount})
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
2026-10-04 18:32:37 +03:00
|
|
|
|
2026-09-29 22:50:30 +02:00
|
|
|
<div className="admin-field">
|
|
|
|
|
<label htmlFor="proof-expires">Valid until (optional)</label>
|
|
|
|
|
<input
|
|
|
|
|
id="proof-expires"
|
|
|
|
|
type="date"
|
|
|
|
|
value={expiresOn}
|
|
|
|
|
onChange={(e) => setExpiresOn(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-10-04 18:32:37 +03:00
|
|
|
|
|
|
|
|
<div className="admin-field">
|
|
|
|
|
<label htmlFor="proof-download">Downloads</label>
|
|
|
|
|
<select
|
|
|
|
|
id="proof-download"
|
|
|
|
|
value={downloadsAllowed ? 'allowed' : 'none'}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
setDownloadsAllowed(e.target.value === 'allowed')
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<option value="none">Downloads disabled</option>
|
|
|
|
|
<option value="allowed">Downloads allowed</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-10-04 17:56:45 +03:00
|
|
|
{downloadsAllowed && (
|
2026-09-29 22:50:30 +02:00
|
|
|
<div className="admin-field">
|
|
|
|
|
<label htmlFor="proof-limit">Download limit (optional)</label>
|
|
|
|
|
<input
|
|
|
|
|
id="proof-limit"
|
|
|
|
|
type="number"
|
|
|
|
|
min={1}
|
|
|
|
|
max={1000}
|
|
|
|
|
value={limit}
|
|
|
|
|
onChange={(e) => setLimit(e.target.value)}
|
|
|
|
|
placeholder="Unlimited"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{error && <p className="admin-error">{error}</p>}
|
|
|
|
|
<div>
|
|
|
|
|
<button
|
|
|
|
|
type="submit"
|
|
|
|
|
className="admin-btn admin-btn-primary"
|
|
|
|
|
disabled={busy || !clientName.trim() || !albumId}
|
|
|
|
|
>
|
|
|
|
|
<Icons.IconLink size={14} /> {busy ? 'Creating…' : 'Create link'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SessionCard({
|
|
|
|
|
session,
|
|
|
|
|
expanded,
|
|
|
|
|
onToggle,
|
|
|
|
|
onChanged,
|
|
|
|
|
}: {
|
|
|
|
|
session: AdminProofSession;
|
|
|
|
|
expanded: boolean;
|
|
|
|
|
onToggle: () => void;
|
|
|
|
|
onChanged: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [picks, setPicks] = useState<AdminProofPick[] | null>(null);
|
|
|
|
|
const [copied, setCopied] = useState<'link' | 'lightroom' | null>(null);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-10-04 18:16:03 +03:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-29 22:50:30 +02:00
|
|
|
const linkUrl = typeof window !== 'undefined' ? `${window.location.origin}${session.link}` : '';
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!expanded) return;
|
|
|
|
|
api<{ picks: AdminProofPick[] }>(`/api/admin/proofing/${session.id}`)
|
|
|
|
|
.then((data) => setPicks(data.picks))
|
|
|
|
|
.catch((err: Error) => setError(err.message));
|
|
|
|
|
}, [expanded, session.id, session.selected, session.submittedAt]);
|
|
|
|
|
|
|
|
|
|
const copy = (kind: 'link' | 'lightroom', text: string) => {
|
|
|
|
|
if (!navigator.clipboard) {
|
|
|
|
|
window.prompt('Copy:', text);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
navigator.clipboard.writeText(text).then(
|
|
|
|
|
() => {
|
|
|
|
|
setCopied(kind);
|
|
|
|
|
setTimeout(() => setCopied(null), 2000);
|
|
|
|
|
},
|
|
|
|
|
() => window.prompt('Copy:', text),
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const patch = async (body: Record<string, unknown>) => {
|
|
|
|
|
setError(null);
|
|
|
|
|
try {
|
|
|
|
|
await api(`/api/admin/proofing/${session.id}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
});
|
|
|
|
|
onChanged();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const remove = async () => {
|
|
|
|
|
if (!window.confirm(`Delete the link for ${session.clientName}? It stops working at once.`)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await api(`/api/admin/proofing/${session.id}`, { method: 'DELETE' });
|
|
|
|
|
onChanged();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const copyLightroom = async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/admin/proofing/${session.id}/export?format=lightroom`);
|
|
|
|
|
if (reportIfSessionExpired(res)) return;
|
|
|
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
|
|
|
copy('lightroom', await res.text());
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const downloadsText =
|
2026-10-04 17:56:45 +03:00
|
|
|
session.download === 'none'
|
|
|
|
|
? 'Downloads disabled'
|
|
|
|
|
: `Downloads allowed · ${session.downloadsUsed}${
|
|
|
|
|
session.downloadLimit !== null
|
|
|
|
|
? ` / ${session.downloadLimit}`
|
|
|
|
|
: ''
|
|
|
|
|
} used`;
|
2026-09-29 22:50:30 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={`analytics-panel proofing-card proofing-card--${session.state}`}>
|
|
|
|
|
<div className="proofing-card-head">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="proofing-card-title">
|
|
|
|
|
{session.clientName}
|
|
|
|
|
<span className={`proofing-badge proofing-badge--${session.state}`}>
|
|
|
|
|
{STATE_LABEL[session.state]}
|
|
|
|
|
</span>
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="analytics-subtitle">
|
|
|
|
|
{session.albumName ?? 'Album not found in Immich'} · {session.selected}
|
|
|
|
|
{session.albumAssetCount !== null ? ` of ${session.albumAssetCount}` : ''} selected ·
|
|
|
|
|
created {formatDate(session.createdAt)}
|
|
|
|
|
{session.expiresOn ? ` · valid until ${formatDate(session.expiresOn)}` : ''}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="analytics-subtitle">
|
|
|
|
|
{downloadsText}
|
|
|
|
|
{session.submittedAt ? ` · submitted ${formatDate(session.submittedAt)}` : ''}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="proofing-card-actions">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
|
|
|
|
onClick={() => copy('link', linkUrl)}
|
|
|
|
|
>
|
|
|
|
|
{copied === 'link' ? <Icons.IconCheck size={14} /> : <Icons.IconCopy size={14} />}{' '}
|
|
|
|
|
{copied === 'link' ? 'Copied' : 'Copy link'}
|
|
|
|
|
</button>
|
2026-10-04 18:16:03 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
|
|
|
|
onClick={() => void beginEdit()}
|
|
|
|
|
>
|
|
|
|
|
<Icons.IconLink size={14} /> Edit link
|
|
|
|
|
</button>
|
2026-10-04 15:49:06 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const next = window.prompt(
|
|
|
|
|
'Client URL alias (leave blank to remove):',
|
|
|
|
|
session.alias ?? '',
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (next === null) return;
|
|
|
|
|
|
|
|
|
|
const cleaned = next
|
|
|
|
|
.trim()
|
|
|
|
|
.replace(/\s+/g, '-')
|
|
|
|
|
.replace(/[^A-Za-z0-9-]/g, '');
|
|
|
|
|
|
|
|
|
|
void patch({
|
|
|
|
|
alias: cleaned || null,
|
|
|
|
|
});
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<Icons.IconLink size={14} />
|
|
|
|
|
{session.alias ? ' Change alias' : ' Set alias'}
|
|
|
|
|
</button>
|
2026-09-29 22:50:30 +02:00
|
|
|
<a
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
|
|
|
|
href={session.link}
|
|
|
|
|
target="_blank"
|
|
|
|
|
rel="noreferrer"
|
|
|
|
|
>
|
|
|
|
|
<Icons.IconEye size={14} /> Open
|
|
|
|
|
</a>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
|
|
|
|
onClick={onToggle}
|
|
|
|
|
aria-expanded={expanded}
|
|
|
|
|
>
|
|
|
|
|
{expanded ? <Icons.IconChevronUp size={14} /> : <Icons.IconChevronDown size={14} />}{' '}
|
|
|
|
|
Selection
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && <p className="admin-error">{error}</p>}
|
|
|
|
|
|
2026-10-04 18:16:03 +03:00
|
|
|
{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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-09-29 22:50:30 +02:00
|
|
|
{expanded && (
|
|
|
|
|
<div className="proofing-card-body">
|
|
|
|
|
<div className="proofing-card-actions">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="admin-btn admin-btn-primary admin-btn-sm"
|
|
|
|
|
onClick={copyLightroom}
|
|
|
|
|
disabled={session.selected === 0}
|
|
|
|
|
title="File names without extension, comma-separated — paste into Lightroom's or Capture One's filename filter to find the RAW files too"
|
|
|
|
|
>
|
|
|
|
|
{copied === 'lightroom' ? (
|
|
|
|
|
<Icons.IconCheck size={14} />
|
|
|
|
|
) : (
|
|
|
|
|
<Icons.IconCopy size={14} />
|
|
|
|
|
)}{' '}
|
|
|
|
|
{copied === 'lightroom' ? 'Copied' : 'Copy file names (Lightroom)'}
|
|
|
|
|
</button>
|
|
|
|
|
<a
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
|
|
|
|
href={`/api/admin/proofing/${session.id}/export?format=csv`}
|
|
|
|
|
>
|
|
|
|
|
<Icons.IconFileText size={14} /> CSV
|
|
|
|
|
</a>
|
|
|
|
|
<a
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
|
|
|
|
href={`/api/admin/proofing/${session.id}/export?format=txt`}
|
|
|
|
|
>
|
|
|
|
|
<Icons.IconFileText size={14} /> TXT
|
|
|
|
|
</a>
|
|
|
|
|
{session.submittedAt && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
|
|
|
|
onClick={() => patch({ reopen: true })}
|
|
|
|
|
>
|
|
|
|
|
<Icons.IconRefresh size={14} /> Reopen for changes
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{session.download !== 'none' && session.downloadsUsed > 0 && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm"
|
|
|
|
|
onClick={() => patch({ resetDownloads: true })}
|
|
|
|
|
>
|
|
|
|
|
<Icons.IconRefresh size={14} /> Reset downloads
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="admin-btn admin-btn-ghost admin-btn-sm admin-btn-icon-danger"
|
|
|
|
|
onClick={remove}
|
|
|
|
|
>
|
|
|
|
|
<Icons.IconTrash size={14} /> Delete link
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{picks === null ? (
|
|
|
|
|
<div className="admin-spinner" />
|
|
|
|
|
) : picks.length === 0 ? (
|
|
|
|
|
<p className="analytics-empty">No photos selected yet.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="proofing-picks">
|
|
|
|
|
{picks.map((pick) => (
|
|
|
|
|
<li key={pick.assetId} className="proofing-pick">
|
|
|
|
|
{/* eslint-disable-next-line @next/next/no-img-element -- admin thumbnails, served by our own proxy */}
|
|
|
|
|
<img src={pick.thumbUrl} alt="" loading="lazy" />
|
|
|
|
|
<span className="proofing-pick-name" title={pick.fileName}>
|
|
|
|
|
#{pick.position} · {pick.fileName}
|
|
|
|
|
</span>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ProofingView() {
|
|
|
|
|
const [sessions, setSessions] = useState<AdminProofSession[] | null>(null);
|
|
|
|
|
const [webhookConfigured, setWebhookConfigured] = useState(true);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [expanded, setExpanded] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
const load = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const data = await api<{ sessions: AdminProofSession[]; webhookConfigured: boolean }>(
|
|
|
|
|
'/api/admin/proofing',
|
|
|
|
|
);
|
|
|
|
|
setSessions(data.sessions);
|
|
|
|
|
setWebhookConfigured(data.webhookConfigured);
|
|
|
|
|
setError(null);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError((err as Error).message);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
// The webhook's admin link lands here with ?session=<id>: open that card.
|
|
|
|
|
const wanted = new URLSearchParams(window.location.search).get('session');
|
|
|
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- one-time read of the URL on mount
|
|
|
|
|
if (wanted) setExpanded(wanted);
|
|
|
|
|
void load();
|
|
|
|
|
}, [load]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="analytics-view proofing-view">
|
|
|
|
|
<PageHeader
|
|
|
|
|
kicker="Visitors"
|
|
|
|
|
title="Client proofing"
|
|
|
|
|
description="A private link per client. Picks are saved as they go; you see them here once they submit."
|
|
|
|
|
actions={
|
|
|
|
|
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={() => void load()}>
|
|
|
|
|
<Icons.IconRefresh size={14} /> Refresh
|
|
|
|
|
</button>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{!webhookConfigured && (
|
|
|
|
|
<p className="analytics-subtitle">
|
|
|
|
|
Tip: set <code>PROOFING_WEBHOOK_URL</code> to be notified (Discord, Slack, Gotify, n8n, …)
|
|
|
|
|
when a client submits.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<CreateForm onCreated={() => void load()} />
|
|
|
|
|
|
|
|
|
|
{error && <p className="admin-error">{error}</p>}
|
|
|
|
|
|
|
|
|
|
{sessions === null && !error ? (
|
|
|
|
|
<div className="admin-loading-container">
|
|
|
|
|
<div className="admin-spinner" />
|
|
|
|
|
</div>
|
|
|
|
|
) : sessions && sessions.length === 0 ? (
|
|
|
|
|
<p className="analytics-empty">No client links yet.</p>
|
|
|
|
|
) : (
|
|
|
|
|
sessions?.map((session) => (
|
|
|
|
|
<SessionCard
|
|
|
|
|
key={session.id}
|
|
|
|
|
session={session}
|
|
|
|
|
expanded={expanded === session.id}
|
|
|
|
|
onToggle={() => setExpanded((cur) => (cur === session.id ? null : session.id))}
|
|
|
|
|
onChanged={() => void load()}
|
|
|
|
|
/>
|
|
|
|
|
))
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|