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:
Michilis
2026-08-05 05:41:12 +00:00
parent dafa3711f8
commit 498d7d8a7d
31 changed files with 2216 additions and 64 deletions
+27 -4
View File
@@ -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" />
+3
View File
@@ -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 {