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
+1
View File
@@ -1,4 +1,5 @@
.artist-bio {
position: relative;
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
color: #e8d5b5;
+53 -37
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { Artist } from '../types';
import {
api,
@@ -9,7 +9,10 @@ import {
type FixArtistPortraitResult,
} from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import '../components/PaintingDetail.css';
import '../components/GalleryLoadingMarker.css';
import './ArtistBio.css';
interface Props {
@@ -51,11 +54,13 @@ export default function ArtistBio({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const uploadInputRef = useRef<HTMLInputElement>(null);
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const portraitCleared = !artist.portrait_path && !!artist.checkup_fixed;
const showPortrait = !!artist.portrait_path || !artist.checkup_fixed;
const portraitSrc = portraitUrl(artist.portrait_path, portraitRevision || undefined);
const showPortrait = !uploading && (!!artist.portrait_path || !artist.checkup_fixed);
const portraitSrc = uploading
? null
: portraitUrl(artist.portrait_path, portraitRevision || undefined, artist);
const lifespan =
artist.birth_year && artist.death_year
@@ -71,6 +76,9 @@ export default function ArtistBio({
setMoreOpen(false);
return;
}
if (uploading) {
return;
}
let cancelled = false;
setDebugLoading(true);
@@ -91,7 +99,7 @@ export default function ArtistBio({
return () => {
cancelled = true;
};
}, [debugMode, artist.id, artist.name]);
}, [debugMode, uploading, artist.id, artist.name]);
const applyPortraitUpdate = async (fixResult: FixArtistPortraitResult) => {
if (onArtistPortraitFixed) {
@@ -197,20 +205,27 @@ export default function ArtistBio({
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
const handleUploadPress = () => {
setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
const handleUploadFile = async (file: File) => {
if (uploading || fixing || clearing) return;
handleUploadPress();
setUploading(true);
setDebugError(null);
setUploadStatus('Reading file…');
try {
setUploadStatus('Uploading…');
const result = await api.uploadArtistPortrait(artist.id, file);
await applyPortraitUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload portrait.');
} finally {
setUploading(false);
@@ -219,6 +234,13 @@ export default function ArtistBio({
return (
<div className="artist-bio">
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="bio-header">
<button className="back-btn" onClick={onBack}> Back</button>
<h1>{artist.name}</h1>
@@ -227,9 +249,9 @@ export default function ArtistBio({
<div className="bio-content">
<div
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared ? ' bio-portrait-empty' : ''}`}
className={`bio-portrait${artist.checkup_checked ? ' bio-portrait-checked' : ''}${portraitCleared && !uploading ? ' bio-portrait-empty' : ''}${uploading ? ' bio-portrait-uploading' : ''}`}
>
{showPortrait ? (
{showPortrait && portraitSrc ? (
<img
src={portraitSrc}
alt={artist.name}
@@ -273,14 +295,17 @@ export default function ArtistBio({
</div>
{debugMode && (
<aside className="debug-image-panel" aria-label="Debug portrait search">
<aside
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
aria-label="Debug portrait search"
>
<h4>{debugSearch?.sourceLabel ?? 'Portrait image search'}</h4>
<p className="debug-image-query">
{debugSearch?.query ?? `${artist.name} portrait`}
</p>
{debugLoading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
{debugLoading && !uploading && <p className="debug-image-status">Searching</p>}
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!uploading && !debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, {
@@ -293,7 +318,7 @@ export default function ArtistBio({
}}
/>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No portrait image result found.</p>
)}
<div className="debug-action-buttons">
@@ -301,7 +326,7 @@ export default function ArtistBio({
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!artist.checkup_checked || markingChecked}
disabled={!!artist.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
@@ -309,7 +334,7 @@ export default function ArtistBio({
type="button"
className="debug-fix-btn"
onClick={handleFixPortrait}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
@@ -317,7 +342,7 @@ export default function ArtistBio({
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
disabled={debugLoading || moreLoading || uploading}
>
{moreLoading ? '…' : 'More'}
</button>
@@ -331,27 +356,18 @@ export default function ArtistBio({
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
<DebugUploadButton
uploading={uploading}
disabled={fixing || clearing}
onUploadPress={handleUploadPress}
onFileSelected={handleUploadFile}
/>
</div>
</aside>
)}
<DebugSearchResultsModal
open={moreOpen}
open={moreOpen && !uploading}
title="Choose portrait"
data={moreResults}
loading={moreLoading}
@@ -0,0 +1,57 @@
import { useRef, type ChangeEvent } from 'react';
interface Props {
uploading: boolean;
disabled?: boolean;
onUploadPress?: () => void;
onFileSelected: (file: File) => void | Promise<void>;
}
export default function DebugUploadButton({
uploading,
disabled = false,
onUploadPress,
onFileSelected,
}: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const inactive = disabled || uploading;
const handleChange = async (e: ChangeEvent<HTMLInputElement>) => {
const input = e.currentTarget;
const file = input.files?.[0];
if (!file || inactive) return;
try {
await onFileSelected(file);
} finally {
input.value = '';
}
};
return (
<label
className={`debug-upload-btn${inactive ? ' debug-upload-btn-disabled' : ''}`}
aria-busy={uploading}
onClick={() => {
if (!inactive) onUploadPress?.();
}}
>
{uploading ? (
<span className="debug-upload-btn-loading">
<span className="debug-upload-btn-spinner" aria-hidden />
Uploading
</span>
) : (
'Upload'
)}
<input
ref={inputRef}
className="debug-upload-input"
type="file"
accept="image/jpeg,image/png,image/webp,image/gif,image/avif,.jpg,.jpeg,.png,.webp,.gif,.avif"
disabled={inactive}
onChange={handleChange}
/>
</label>
);
}
@@ -54,6 +54,22 @@
border-width: 2px;
}
.gallery-loading-marker-compact {
flex-direction: row;
justify-content: flex-start;
gap: 8px;
margin: 6px 0 8px;
font-size: 11px;
color: rgba(232, 213, 181, 0.85);
}
.gallery-loading-marker-compact .gallery-loading-marker-spinner {
width: 14px;
height: 14px;
border-width: 2px;
flex-shrink: 0;
}
@keyframes gallery-loading-spin {
to {
transform: rotate(360deg);
@@ -6,6 +6,8 @@ interface Props {
overlay?: boolean;
/** Compact strip along the bottom — does not block interaction. */
banner?: boolean;
/** Inline row for small panels (debug upload, etc.). */
compact?: boolean;
className?: string;
}
@@ -13,13 +15,16 @@ export default function GalleryLoadingMarker({
message = 'Loading…',
overlay = false,
banner = false,
compact = false,
className = '',
}: Props) {
const modeClass = overlay
? ' gallery-loading-marker-overlay'
: banner
? ' gallery-loading-marker-banner'
: '';
: compact
? ' gallery-loading-marker-compact'
: '';
return (
<div
+43 -2
View File
@@ -552,6 +552,48 @@
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-align: center;
}
.debug-upload-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.debug-upload-btn-disabled {
opacity: 0.6;
cursor: wait;
pointer-events: none;
}
.debug-upload-btn-loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.debug-upload-page-overlay.gallery-loading-marker-overlay {
position: fixed;
inset: 0;
z-index: 300;
}
.painting-frame-uploading,
.bio-portrait-uploading {
min-height: 240px;
}
.debug-image-panel.debug-image-panel-blocked {
opacity: 0.55;
pointer-events: none;
}
.debug-clear-btn {
@@ -576,8 +618,7 @@
border-color: #8cbe8c;
}
.debug-clear-btn:disabled,
.debug-upload-btn:disabled {
.debug-clear-btn:disabled {
opacity: 0.6;
cursor: wait;
}
+63 -50
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useRef, useState, type ChangeEvent, type SyntheticEvent } from 'react';
import { useCallback, useEffect, useState, type SyntheticEvent } from 'react';
import type { InfluenceLink, Painting, PaintingDetail } from '../types';
import { api, debugImageProxyUrl, imageUrl, paintingImageUrl, type DebugImageSearchResult, type DebugImageSearchResultItem, type FixPaintingImageResult } from '../api/client';
import DebugSearchResultsModal from './DebugSearchResultsModal';
import DebugUploadButton from './DebugUploadButton';
import GalleryLoadingMarker from './GalleryLoadingMarker';
import PaintingAnnotationsPanel, { PaintingAnnotationMarkers } from './PaintingAnnotations';
import PaintingLightbox from './PaintingLightbox';
import './PaintingDetail.css';
import './GalleryLoadingMarker.css';
interface Props {
data: PaintingDetail;
@@ -214,15 +217,13 @@ export default function PaintingDetailView({
const [applyingUrl, setApplyingUrl] = useState<string | null>(null);
const [clearing, setClearing] = useState(false);
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<string | null>(null);
const [removing, setRemoving] = useState(false);
const [activeAnnotationId, setActiveAnnotationId] = useState<number | null>(null);
const uploadInputRef = useRef<HTMLInputElement>(null);
const baseImageUrl = paintingImageUrl(painting);
const imageSrc = baseImageUrl
? `${baseImageUrl}${baseImageUrl.includes('?') ? '&' : '?'}v=${imageVersion}`
: null;
const imageCleared = !baseImageUrl && !!painting.checkup_fixed;
const imageSrc = paintingImageUrl(painting, imageVersion || undefined);
const displayImageSrc = uploading ? null : imageSrc;
const imageCleared = !painting.image_path && !painting.thumbnail_path && !!painting.checkup_fixed;
const catalogIndex = artistPaintings.findIndex((p) => p.id === painting.id);
const previousPainting = catalogIndex > 0 ? artistPaintings[catalogIndex - 1] : null;
@@ -243,6 +244,7 @@ export default function PaintingDetailView({
setFixing(false);
setClearing(false);
setUploading(false);
setUploadStatus(null);
setMarkingChecked(false);
setApplyingUrl(null);
setMoreLoading(false);
@@ -255,6 +257,9 @@ export default function PaintingDetailView({
setMoreOpen(false);
return;
}
if (uploading) {
return;
}
let cancelled = false;
setDebugLoading(true);
@@ -275,7 +280,7 @@ export default function PaintingDetailView({
return () => {
cancelled = true;
};
}, [debugMode, painting.id, painting.title, painting.artist_name]);
}, [debugMode, uploading, painting.id, painting.title, painting.artist_name]);
const applyImageUpdate = async (fixResult: FixPaintingImageResult) => {
setImageVersion((v) => v + 1);
@@ -382,20 +387,27 @@ export default function PaintingDetailView({
}
};
const handleUploadClick = () => {
uploadInputRef.current?.click();
const handleUploadPress = () => {
setDebugSearch(null);
setDebugLoading(false);
setMoreOpen(false);
setMoreResults(null);
setMoreError(null);
setDebugError(null);
};
const handleUploadFile = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || uploading || fixing || clearing) return;
const handleUploadFile = async (file: File) => {
if (uploading || fixing || clearing) return;
handleUploadPress();
setUploading(true);
setDebugError(null);
setUploadStatus('Reading file…');
try {
setUploadStatus('Uploading…');
const result = await api.uploadPaintingImage(painting.id, file);
await applyImageUpdate(result);
setUploadStatus('Upload complete.');
} catch (err) {
setUploadStatus(null);
setDebugError(err instanceof Error ? err.message : 'Could not upload image.');
} finally {
setUploading(false);
@@ -437,6 +449,13 @@ export default function PaintingDetailView({
return (
<div className={`painting-detail${fullscreen ? ' painting-detail-fullscreen-active' : ''}`}>
{uploading && (
<GalleryLoadingMarker
overlay
className="debug-upload-page-overlay"
message={uploadStatus ?? 'Loading…'}
/>
)}
<header className="painting-header">
<button className="back-btn" onClick={onBack}> Back to Gallery</button>
<div className="painting-title-block">
@@ -494,12 +513,12 @@ export default function PaintingDetailView({
)}
<div
className={`painting-frame-large${imageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared ? ' painting-frame-cleared' : ''}`}
role={imageSrc ? 'button' : undefined}
tabIndex={imageSrc ? 0 : undefined}
onClick={imageSrc ? () => setFullscreen(true) : undefined}
className={`painting-frame-large${displayImageSrc ? ' painting-frame-clickable' : ' painting-frame-empty'}${imageCleared && !uploading ? ' painting-frame-cleared' : ''}${uploading ? ' painting-frame-uploading' : ''}`}
role={displayImageSrc ? 'button' : undefined}
tabIndex={displayImageSrc ? 0 : undefined}
onClick={displayImageSrc ? () => setFullscreen(true) : undefined}
onKeyDown={
imageSrc
displayImageSrc
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
@@ -508,14 +527,14 @@ export default function PaintingDetailView({
}
: undefined
}
title={imageSrc ? 'View full screen' : undefined}
aria-label={imageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
title={displayImageSrc ? 'View full screen' : undefined}
aria-label={displayImageSrc ? `View ${painting.title} full screen` : `${painting.title} (no image)`}
>
<div className="painting-frame-image-wrap">
{imageSrc ? (
<img src={imageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
{displayImageSrc ? (
<img src={displayImageSrc} alt={painting.title} onError={handleImageError} draggable={false} />
) : null}
{imageSrc && annotations.length > 0 && (
{displayImageSrc && annotations.length > 0 && (
<PaintingAnnotationMarkers
annotations={annotations}
activeId={activeAnnotationId}
@@ -573,14 +592,17 @@ export default function PaintingDetailView({
</div>
{debugMode && (
<aside className="debug-image-panel" aria-label="Debug image search">
<aside
className={`debug-image-panel${uploading ? ' debug-image-panel-blocked' : ''}`}
aria-label="Debug image search"
>
<h4>{debugSearch?.sourceLabel ?? 'Google image search'}</h4>
<p className="debug-image-query">
{debugSearch?.query ?? `${painting.artist_name} ${painting.title} painting`}
</p>
{debugLoading && <p className="debug-image-status">Searching</p>}
{debugError && <p className="debug-image-error">{debugError}</p>}
{!debugLoading && debugSearch?.imageUrl && (
{debugLoading && !uploading && <p className="debug-image-status">Searching</p>}
{debugError && !uploading && <p className="debug-image-error">{debugError}</p>}
{!uploading && !debugLoading && debugSearch?.imageUrl && (
<img
className="debug-image-preview"
src={debugImageProxyUrl(debugSearch.imageUrl, {
@@ -593,7 +615,7 @@ export default function PaintingDetailView({
}}
/>
)}
{!debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
{!uploading && !debugLoading && debugSearch && !debugSearch.imageUrl && !debugError && (
<p className="debug-image-status">No Google image result found.</p>
)}
<div className="debug-action-buttons">
@@ -601,7 +623,7 @@ export default function PaintingDetailView({
type="button"
className="debug-checked-btn"
onClick={handleMarkChecked}
disabled={!!painting.checkup_checked || markingChecked}
disabled={!!painting.checkup_checked || markingChecked || uploading}
>
{markingChecked ? '…' : 'Checked'}
</button>
@@ -609,7 +631,7 @@ export default function PaintingDetailView({
type="button"
className="debug-fix-btn"
onClick={handleFixImage}
disabled={fixing || debugLoading || !debugSearch?.imageUrl}
disabled={fixing || debugLoading || uploading || !debugSearch?.imageUrl}
>
{fixing ? '…' : 'Fix it'}
</button>
@@ -617,7 +639,7 @@ export default function PaintingDetailView({
type="button"
className="debug-more-btn"
onClick={handleOpenMore}
disabled={debugLoading || moreLoading}
disabled={debugLoading || moreLoading || uploading}
>
{moreLoading ? '…' : 'More'}
</button>
@@ -631,20 +653,11 @@ export default function PaintingDetailView({
>
{clearing ? '…' : 'Clear'}
</button>
<button
type="button"
className="debug-upload-btn"
onClick={handleUploadClick}
disabled={uploading || fixing || clearing}
>
{uploading ? '…' : 'Upload'}
</button>
<input
ref={uploadInputRef}
type="file"
accept="image/*"
hidden
onChange={handleUploadFile}
<DebugUploadButton
uploading={uploading}
disabled={fixing || clearing}
onUploadPress={handleUploadPress}
onFileSelected={handleUploadFile}
/>
</div>
<div className="debug-action-buttons debug-action-buttons-danger">
@@ -661,7 +674,7 @@ export default function PaintingDetailView({
)}
<DebugSearchResultsModal
open={moreOpen}
open={moreOpen && !uploading}
title="Choose painting image"
data={moreResults}
loading={moreLoading}
@@ -671,9 +684,9 @@ export default function PaintingDetailView({
onSelect={handleSelectMoreResult}
/>
{fullscreen && imageSrc && (
{fullscreen && displayImageSrc && (
<PaintingLightbox
src={imageSrc}
src={displayImageSrc}
alt={painting.title}
title={painting.title}
subtitle={[painting.artist_name, painting.year ? String(painting.year) : '']