feat(17-03): update client components to use imageUrl from API responses

- Replace all /uploads/ path construction with imageUrl presigned URLs
- Add imageUrl prop to ItemCard, CandidateCard, CandidateListItem, ComparisonTable
- Update ImageUpload to use presigned URLs + local preview for new uploads
- Pass imageUrl through from parent components (CollectionView, forms, routes)
This commit is contained in:
2026-04-05 12:27:34 +02:00
parent 2d31680072
commit 8c64bf9fbf
10 changed files with 42 additions and 11 deletions

View File

@@ -14,6 +14,7 @@ interface CandidateCardProps {
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
imageFilename: string | null; imageFilename: string | null;
imageUrl?: string | null;
productUrl?: string | null; productUrl?: string | null;
threadId: number; threadId: number;
isActive: boolean; isActive: boolean;
@@ -33,6 +34,7 @@ export function CandidateCard({
categoryName, categoryName,
categoryIcon, categoryIcon,
imageFilename, imageFilename,
imageUrl,
productUrl, productUrl,
threadId, threadId,
isActive, isActive,
@@ -142,9 +144,9 @@ export function CandidateCard({
</span> </span>
)} )}
<div className="aspect-[4/3] bg-gray-50"> <div className="aspect-[4/3] bg-gray-50">
{imageFilename ? ( {imageUrl ? (
<img <img
src={`/uploads/${imageFilename}`} src={imageUrl}
alt={name} alt={name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

View File

@@ -142,6 +142,11 @@ export function CandidateForm({
{/* Image */} {/* Image */}
<ImageUpload <ImageUpload
value={form.imageFilename} value={form.imageFilename}
imageUrl={
mode === "edit" && candidateId != null
? thread?.candidates?.find((c) => c.id === candidateId)?.imageUrl
: null
}
onChange={(filename) => onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename })) setForm((f) => ({ ...f, imageFilename: filename }))
} }

View File

@@ -17,6 +17,7 @@ interface CandidateWithCategory {
notes: string | null; notes: string | null;
productUrl: string | null; productUrl: string | null;
imageFilename: string | null; imageFilename: string | null;
imageUrl?: string | null;
status: "researching" | "ordered" | "arrived"; status: "researching" | "ordered" | "arrived";
pros: string | null; pros: string | null;
cons: string | null; cons: string | null;
@@ -83,9 +84,9 @@ export function CandidateListItem({
{/* Image thumbnail */} {/* Image thumbnail */}
<div className="w-12 h-12 rounded-lg overflow-hidden shrink-0 bg-gray-50 flex items-center justify-center"> <div className="w-12 h-12 rounded-lg overflow-hidden shrink-0 bg-gray-50 flex items-center justify-center">
{candidate.imageFilename ? ( {candidate.imageUrl ? (
<img <img
src={`/uploads/${candidate.imageFilename}`} src={candidate.imageUrl}
alt={candidate.name} alt={candidate.name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

View File

@@ -228,6 +228,7 @@ export function CollectionView() {
categoryName={item.categoryName} categoryName={item.categoryName}
categoryIcon={item.categoryIcon} categoryIcon={item.categoryIcon}
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
imageUrl={item.imageUrl}
productUrl={item.productUrl} productUrl={item.productUrl}
/> />
))} ))}

View File

@@ -16,6 +16,7 @@ interface CandidateWithCategory {
notes: string | null; notes: string | null;
productUrl: string | null; productUrl: string | null;
imageFilename: string | null; imageFilename: string | null;
imageUrl?: string | null;
status: "researching" | "ordered" | "arrived"; status: "researching" | "ordered" | "arrived";
pros: string | null; pros: string | null;
cons: string | null; cons: string | null;
@@ -114,9 +115,9 @@ export function ComparisonTable({
label: "Image", label: "Image",
render: (c) => ( render: (c) => (
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-50 flex items-center justify-center"> <div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-50 flex items-center justify-center">
{c.imageFilename ? ( {c.imageUrl ? (
<img <img
src={`/uploads/${c.imageFilename}`} src={c.imageUrl}
alt={c.name} alt={c.name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

View File

@@ -3,15 +3,17 @@ import { apiUpload } from "../lib/api";
interface ImageUploadProps { interface ImageUploadProps {
value: string | null; value: string | null;
imageUrl?: string | null;
onChange: (filename: string | null) => void; onChange: (filename: string | null) => void;
} }
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"]; const ACCEPTED_TYPES = ["image/jpeg", "image/png", "image/webp"];
export function ImageUpload({ value, onChange }: ImageUploadProps) { export function ImageUpload({ value, imageUrl, onChange }: ImageUploadProps) {
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [localPreview, setLocalPreview] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -30,12 +32,17 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
return; return;
} }
// Create local preview for immediate display
const previewUrl = URL.createObjectURL(file);
setLocalPreview(previewUrl);
setUploading(true); setUploading(true);
try { try {
const result = await apiUpload<{ filename: string }>("/api/images", file); const result = await apiUpload<{ filename: string }>("/api/images", file);
onChange(result.filename); onChange(result.filename);
} catch { } catch {
setError("Upload failed. Please try again."); setError("Upload failed. Please try again.");
setLocalPreview(null);
} finally { } finally {
setUploading(false); setUploading(false);
// Reset input so the same file can be re-selected // Reset input so the same file can be re-selected
@@ -45,9 +52,14 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
function handleRemove(e: React.MouseEvent) { function handleRemove(e: React.MouseEvent) {
e.stopPropagation(); e.stopPropagation();
setLocalPreview(null);
onChange(null); onChange(null);
} }
// Determine the display URL: local preview takes priority (just-uploaded),
// then presigned URL from API, then nothing
const displayUrl = localPreview || imageUrl || null;
return ( return (
<div> <div>
{/* Hero image area */} {/* Hero image area */}
@@ -55,10 +67,10 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group" className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group"
> >
{value ? ( {displayUrl ? (
<> <>
<img <img
src={`/uploads/${value}`} src={displayUrl}
alt="Item" alt="Item"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

View File

@@ -13,6 +13,7 @@ interface ItemCardProps {
categoryName: string; categoryName: string;
categoryIcon: string; categoryIcon: string;
imageFilename: string | null; imageFilename: string | null;
imageUrl?: string | null;
productUrl?: string | null; productUrl?: string | null;
onRemove?: () => void; onRemove?: () => void;
classification?: string; classification?: string;
@@ -28,6 +29,7 @@ export function ItemCard({
categoryName, categoryName,
categoryIcon, categoryIcon,
imageFilename, imageFilename,
imageUrl,
productUrl, productUrl,
onRemove, onRemove,
classification, classification,
@@ -149,9 +151,9 @@ export function ItemCard({
</span> </span>
)} )}
<div className="aspect-[4/3] bg-gray-50"> <div className="aspect-[4/3] bg-gray-50">
{imageFilename ? ( {imageUrl ? (
<img <img
src={`/uploads/${imageFilename}`} src={imageUrl}
alt={name} alt={name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />

View File

@@ -130,6 +130,11 @@ export function ItemForm({ mode, itemId }: ItemFormProps) {
{/* Image */} {/* Image */}
<ImageUpload <ImageUpload
value={form.imageFilename} value={form.imageFilename}
imageUrl={
mode === "edit" && itemId != null
? items?.find((i) => i.id === itemId)?.imageUrl
: null
}
onChange={(filename) => onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename })) setForm((f) => ({ ...f, imageFilename: filename }))
} }

View File

@@ -238,6 +238,7 @@ function SetupDetailPage() {
categoryName={categoryName} categoryName={categoryName}
categoryIcon={categoryIcon} categoryIcon={categoryIcon}
imageFilename={item.imageFilename} imageFilename={item.imageFilename}
imageUrl={item.imageUrl}
productUrl={item.productUrl} productUrl={item.productUrl}
onRemove={() => removeItem.mutate(item.id)} onRemove={() => removeItem.mutate(item.id)}
classification={item.classification} classification={item.classification}

View File

@@ -275,6 +275,7 @@ function ThreadDetailPage() {
categoryName={candidate.categoryName} categoryName={candidate.categoryName}
categoryIcon={candidate.categoryIcon} categoryIcon={candidate.categoryIcon}
imageFilename={candidate.imageFilename} imageFilename={candidate.imageFilename}
imageUrl={candidate.imageUrl}
productUrl={candidate.productUrl} productUrl={candidate.productUrl}
threadId={threadId} threadId={threadId}
isActive={isActive} isActive={isActive}