Add debug More/Clear/Upload tools for paintings and artist portraits.

Extends the debug panel on painting detail and artist bio with a 20-result search picker, local image upload, and clear-to-empty-frame workflow, plus API routes, artist checkup migration, and documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Danila Khodjaef
2026-06-21 13:14:06 +03:00
co-authored by Cursor
parent b4425445bb
commit 0972b5df99
56 changed files with 1980 additions and 92 deletions
+134 -3
View File
@@ -20,6 +20,12 @@ export function imageUrl(path: string | null | undefined): string {
return `/images/${path}`;
}
export function portraitUrl(path: string | null | undefined, revision?: number): string {
const base = imageUrl(path);
if (!revision || base.startsWith('/placeholder')) return base;
return `${base}${base.includes('?') ? '&' : '?'}v=${revision}`;
}
/** Image for 3D gallery — local cached files only (API fetch is too slow for realtime 3D) */
export function galleryImageUrl(painting: {
thumbnail_path?: string | null;
@@ -47,12 +53,47 @@ export function paintingImageUrl(painting: {
id: number;
image_path?: string | null;
thumbnail_path?: string | null;
}): string {
checkup_fixed?: boolean;
}): string | null {
if (painting.image_path) return `/images/${painting.image_path}`;
if (painting.thumbnail_path) return `/images/${painting.thumbnail_path}`;
if (painting.checkup_fixed) return null;
return `/api/paintings/${painting.id}/image?size=full`;
}
async function fileToBase64Payload(file: File): Promise<{ imageData: string; mimeType: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (typeof result !== 'string') {
reject(new Error('Could not read file'));
return;
}
const comma = result.indexOf(',');
resolve({
imageData: comma >= 0 ? result.slice(comma + 1) : result,
mimeType: file.type || 'image/jpeg',
});
};
reader.onerror = () => reject(new Error('Could not read file'));
reader.readAsDataURL(file);
});
}
async function postJsonImageAction<T>(url: string, payload: { imageData: string; mimeType: string }): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
export async function preloadArtistImages(artistId: number): Promise<{ fetched: number; total: number }> {
const res = await fetch(`${API}/artists/${artistId}/preload-images`, { method: 'POST' });
if (!res.ok) throw new Error('Preload failed');
@@ -60,8 +101,14 @@ export async function preloadArtistImages(artistId: number): Promise<{ fetched:
}
export interface FixPaintingImageResult {
imagePath: string;
thumbnailPath: string;
imagePath: string | null;
thumbnailPath: string | null;
fixed?: boolean;
checked?: boolean;
}
export interface FixArtistPortraitResult {
portraitPath: string | null;
fixed?: boolean;
checked?: boolean;
}
@@ -75,6 +122,20 @@ export interface DebugImageSearchResult {
thumbUrl?: string;
}
export interface DebugImageSearchResultItem {
imageUrl: string;
thumbUrl?: string;
source: string;
}
export interface DebugImageSearchManyResult {
query: string;
searchUrl: string;
source: string;
sourceLabel?: string;
results: DebugImageSearchResultItem[];
}
export interface PaintingCheckupRow {
id: number;
title: string;
@@ -120,6 +181,9 @@ export const api = {
getPaintingDebugImageSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/paintings/${id}/debug-image-search`),
getPaintingDebugImageSearchMore: (id: number, limit = 20) =>
fetchJson<DebugImageSearchManyResult>(`${API}/paintings/${id}/debug-image-search/more?limit=${limit}`),
fixPaintingImage: (
id: number,
imageUrl: string,
@@ -137,6 +201,20 @@ export const api = {
return res.json() as Promise<FixPaintingImageResult>;
}),
clearPaintingImage: (id: number) =>
fetch(`${API}/paintings/${id}/clear-image`, { method: 'POST' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Clear failed: ${res.status}`);
}
return res.json() as Promise<FixPaintingImageResult>;
}),
uploadPaintingImage: async (id: number, file: File) => {
const payload = await fileToBase64Payload(file);
return postJsonImageAction<FixPaintingImageResult>(`${API}/paintings/${id}/upload-image`, payload);
},
getPaintingCheckup: () => fetchJson<PaintingCheckupData>(`${API}/paintings/checkup`),
updatePaintingCheckupFlags: (
@@ -154,6 +232,59 @@ export const api = {
}
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
getArtistDebugPortraitSearch: (id: number) =>
fetchJson<DebugImageSearchResult>(`${API}/artists/${id}/debug-portrait-search`),
getArtistDebugPortraitSearchMore: (id: number, limit = 20) =>
fetchJson<DebugImageSearchManyResult>(`${API}/artists/${id}/debug-portrait-search/more?limit=${limit}`),
fixArtistPortrait: (
id: number,
imageUrl: string,
context?: { searchUrl?: string; source?: string; pageUrl?: string; thumbUrl?: string }
) =>
fetch(`${API}/artists/${id}/fix-portrait`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl, ...context }),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Fix failed: ${res.status}`);
}
return res.json() as Promise<FixArtistPortraitResult>;
}),
clearArtistPortrait: (id: number) =>
fetch(`${API}/artists/${id}/clear-portrait`, { method: 'POST' }).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Clear failed: ${res.status}`);
}
return res.json() as Promise<FixArtistPortraitResult>;
}),
uploadArtistPortrait: async (id: number, file: File) => {
const payload = await fileToBase64Payload(file);
return postJsonImageAction<FixArtistPortraitResult>(`${API}/artists/${id}/upload-portrait`, payload);
},
updateArtistCheckupFlags: (
id: number,
flags: { checked?: boolean; fixed?: boolean }
) =>
fetch(`${API}/artists/${id}/checkup-flags`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(flags),
}).then(async (res) => {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Update failed: ${res.status}`);
}
return res.json() as Promise<{ checked: boolean; fixed: boolean }>;
}),
};
export function debugImageProxyUrl(