Fix debug upload persistence and UX; exclude prod audit log from DB restore

Uploads and fixes now bust browser cache via file-mtime keys in API
responses. Debug upload shows a centered loading overlay and blocks search
while uploading. Prod DB restore skips curator_audit_log.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-07-09 18:30:19 +03:00
co-authored by Cursor
parent 62096e8210
commit f78c14f307
18 changed files with 506 additions and 154 deletions
+102 -29
View File
@@ -52,36 +52,63 @@ export async function logoutCurator(): Promise<void> {
if (!res.ok) throw new Error(`Logout failed: ${res.status}`);
}
export function imageUrl(path: string | null | undefined): string {
if (!path) return '/placeholder-art.svg';
return `/images/${path}`;
}
export function portraitUrl(path: string | null | undefined, revision?: number): string {
const base = imageUrl(path);
export function imageUrl(path: string | null | undefined, revision?: number | null): string {
const base = !path ? '/placeholder-art.svg' : `/images/${path}`;
if (!revision || base.startsWith('/placeholder')) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
}
/** Small portrait for timeline / movement flow (~256px). Falls back to full portrait. */
export function portraitUrl(
path: string | null | undefined,
revision?: number | null,
options?: { portrait_cache_key?: number | null; portrait_thumb_cache_key?: number | null }
): string {
const cacheRevision =
revision ?? options?.portrait_cache_key ?? options?.portrait_thumb_cache_key ?? undefined;
return imageUrl(path, cacheRevision);
}
export function portraitThumbUrl(
artist: {
portrait_thumb_path?: string | null;
portrait_path?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
},
revision?: number
revision?: number | null
): string {
const path = artist.portrait_thumb_path || artist.portrait_path;
return portraitUrl(path, revision);
const cacheRevision =
revision ?? artist.portrait_thumb_cache_key ?? artist.portrait_cache_key ?? undefined;
return portraitUrl(path, cacheRevision);
}
export function paintingImageRevision(
painting: {
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): number | undefined {
const apiRevision = painting.image_cache_key ?? painting.thumbnail_cache_key;
if (apiRevision != null) return apiRevision;
if (sessionRevision != null) return sessionRevision;
return undefined;
}
/** Image for 3D gallery — prefer thumbnail for faster texture loads */
export function galleryImageUrl(painting: {
thumbnail_path?: string | null;
image_path?: string | null;
}): string | null {
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
if (painting.image_path) return `/images/${painting.image_path}`;
export function galleryImageUrl(
painting: {
thumbnail_path?: string | null;
image_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): string | null {
const revision = paintingImageRevision(painting, sessionRevision);
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
if (painting.image_path) return imageUrl(painting.image_path, revision);
return null;
}
@@ -90,27 +117,35 @@ export function galleryImageUrlWithRevision(
id?: number;
thumbnail_path?: string | null;
image_path?: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
revision?: number
sessionRevision?: number | null
): string | null {
const base = galleryImageUrl(painting);
if (!base || !revision) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
return galleryImageUrl(painting, sessionRevision);
}
export function paintingImageUrl(painting: {
id: number;
image_path?: string | null;
thumbnail_path?: string | null;
checkup_fixed?: boolean;
}): string | null {
if (painting.image_path) return `/images/${painting.image_path}`;
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
export function paintingImageUrl(
painting: {
id: number;
image_path?: string | null;
thumbnail_path?: string | null;
checkup_fixed?: boolean;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
},
sessionRevision?: number | null
): string | null {
const revision = paintingImageRevision(painting, sessionRevision);
if (painting.image_path) return imageUrl(painting.image_path, revision);
if (painting.thumbnail_path) return imageUrl(painting.thumbnail_path, revision);
if (painting.checkup_fixed) return null;
return `/api/paintings/${painting.id}/image?size=full`;
}
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
validateDebugUploadFile(file);
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
@@ -122,7 +157,7 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
const comma = result.indexOf(',');
resolve({
imageData: comma >= 0 ? result.slice(comma + 1) : result,
mimeType: file.type || 'image/jpeg',
mimeType: file.type || mimeTypeFromFilename(file.name) || 'image/jpeg',
});
};
reader.onerror = () => reject(new Error('Could not read file'));
@@ -130,6 +165,39 @@ async function fileToBase64Payload(file: File): Promise<{ imageData: string; mim
});
}
function mimeTypeFromFilename(filename: string): string | null {
const ext = filename.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'png':
return 'image/png';
case 'webp':
return 'image/webp';
case 'gif':
return 'image/gif';
case 'avif':
return 'image/avif';
default:
return null;
}
}
export function validateDebugUploadFile(file: File): void {
const maxBytes = 15 * 1024 * 1024;
if (file.size <= 0) {
throw new Error('Selected file is empty.');
}
if (file.size > maxBytes) {
throw new Error('Image too large (max 15 MB).');
}
const nameOk = /\.(jpe?g|png|webp|gif|avif)$/i.test(file.name);
if (!file.type.startsWith('image/') && !nameOk) {
throw new Error('Please choose an image file (JPEG, PNG, WebP, GIF).');
}
}
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
const res = await fetch(url, {
...fetchCredentials,
@@ -156,12 +224,17 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
export interface FixPaintingImageResult {
imagePath: string | null;
thumbnailPath: string | null;
image_cache_key?: number | null;
thumbnail_cache_key?: number | null;
fixed?: boolean;
checked?: boolean;
}
export interface FixArtistPortraitResult {
portraitPath: string | null;
portraitThumbPath?: string | null;
portrait_cache_key?: number | null;
portrait_thumb_cache_key?: number | null;
fixed?: boolean;
checked?: boolean;
}