Add photo content-hash dedup and local↔S3 library sync.
Uploads skip per-gallery duplicates, checksums can be backfilled, and STORAGE_BACKEND plus sync tooling make switching storage backends safe.
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
LinkIcon,
|
||||
@@ -34,7 +35,9 @@ interface UploadItem {
|
||||
name: string;
|
||||
sizeBytes: number;
|
||||
progress: number; // 0..1 while uploading
|
||||
status: 'queued' | 'uploading' | 'processing' | 'error';
|
||||
// 'duplicate' is terminal: the gallery already held these bytes, so nothing
|
||||
// was stored and no new tile appears in the grid.
|
||||
status: 'queued' | 'uploading' | 'processing' | 'error' | 'duplicate';
|
||||
error?: string;
|
||||
photoId?: string;
|
||||
}
|
||||
@@ -117,8 +120,19 @@ export default function AdminGalleryDetailPage() {
|
||||
const { photos: added } = await photosApi.uploadPhotoWithProgress(id, file, (fraction) =>
|
||||
patchUpload(key, { progress: fraction })
|
||||
);
|
||||
setPhotos((prev) => [...prev, ...added]);
|
||||
patchUpload(key, { status: 'processing', progress: 1, photoId: added[0]?.id });
|
||||
// A duplicate echoes back a photo already in the grid, so merge by id
|
||||
// instead of appending (`duplicate` is an upload outcome, not photo
|
||||
// state, so it is dropped here).
|
||||
setPhotos((prev) => {
|
||||
const byId = new Map(prev.map((p) => [p.id, p]));
|
||||
added.forEach(({ duplicate: _duplicate, ...photo }) => byId.set(photo.id, photo));
|
||||
return Array.from(byId.values());
|
||||
});
|
||||
patchUpload(key, {
|
||||
status: added[0]?.duplicate ? 'duplicate' : 'processing',
|
||||
progress: 1,
|
||||
photoId: added[0]?.id,
|
||||
});
|
||||
} catch (err) {
|
||||
patchUpload(key, {
|
||||
status: 'error',
|
||||
@@ -148,7 +162,8 @@ export default function AdminGalleryDetailPage() {
|
||||
};
|
||||
|
||||
// A row is "done" once its photo finished processing; the panel derives
|
||||
// this from the photos list instead of tracking it separately.
|
||||
// this from the photos list instead of tracking it separately. 'duplicate'
|
||||
// is terminal and never reconciled — its photo was already there.
|
||||
const displayStatus = (u: UploadItem): { state: string; error?: string } => {
|
||||
if (u.status === 'processing' && u.photoId) {
|
||||
const photo = photos.find((p) => p.id === u.photoId);
|
||||
@@ -579,6 +594,11 @@ export default function AdminGalleryDetailPage() {
|
||||
{ds.state === 'queued' && (es ? 'En cola' : 'Queued')}
|
||||
{ds.state === 'processing' && (es ? 'Procesando…' : 'Processing…')}
|
||||
{ds.state === 'done' && formatBytes(u.sizeBytes)}
|
||||
{ds.state === 'duplicate' && (
|
||||
<span className="text-amber-600">
|
||||
{es ? 'Ya está en esta galería' : 'Already in this gallery'}
|
||||
</span>
|
||||
)}
|
||||
{ds.state === 'error' && (
|
||||
<span className="text-red-600" title={ds.error}>
|
||||
{ds.error}
|
||||
@@ -588,6 +608,9 @@ export default function AdminGalleryDetailPage() {
|
||||
</div>
|
||||
<span className="shrink-0">
|
||||
{ds.state === 'done' && <CheckCircleIcon className="w-6 h-6 text-green-600" />}
|
||||
{ds.state === 'duplicate' && (
|
||||
<DocumentDuplicateIcon className="w-6 h-6 text-amber-600" />
|
||||
)}
|
||||
{ds.state === 'error' && <ExclamationCircleIcon className="w-6 h-6 text-red-600" />}
|
||||
{ds.state === 'processing' && (
|
||||
<div className="animate-spin w-5 h-5 border-2 border-primary-yellow border-t-transparent rounded-full" />
|
||||
|
||||
@@ -52,6 +52,9 @@ export interface Photo {
|
||||
preview?: string;
|
||||
original: string;
|
||||
};
|
||||
// Upload responses only: these bytes were already in the gallery, so nothing
|
||||
// was stored and the rest of this object describes the existing photo.
|
||||
duplicate?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateGalleryInput {
|
||||
|
||||
Reference in New Issue
Block a user