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

View File

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

View File

@@ -17,6 +17,7 @@ interface CandidateWithCategory {
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
imageUrl?: string | null;
status: "researching" | "ordered" | "arrived";
pros: string | null;
cons: string | null;
@@ -83,9 +84,9 @@ export function CandidateListItem({
{/* Image thumbnail */}
<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
src={`/uploads/${candidate.imageFilename}`}
src={candidate.imageUrl}
alt={candidate.name}
className="w-full h-full object-cover"
/>

View File

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

View File

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

View File

@@ -3,15 +3,17 @@ import { apiUpload } from "../lib/api";
interface ImageUploadProps {
value: string | null;
imageUrl?: string | null;
onChange: (filename: string | null) => void;
}
const MAX_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
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 [error, setError] = useState<string | null>(null);
const [localPreview, setLocalPreview] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@@ -30,12 +32,17 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
return;
}
// Create local preview for immediate display
const previewUrl = URL.createObjectURL(file);
setLocalPreview(previewUrl);
setUploading(true);
try {
const result = await apiUpload<{ filename: string }>("/api/images", file);
onChange(result.filename);
} catch {
setError("Upload failed. Please try again.");
setLocalPreview(null);
} finally {
setUploading(false);
// 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) {
e.stopPropagation();
setLocalPreview(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 (
<div>
{/* Hero image area */}
@@ -55,10 +67,10 @@ export function ImageUpload({ value, onChange }: ImageUploadProps) {
onClick={() => inputRef.current?.click()}
className="relative w-full aspect-[4/3] rounded-xl overflow-hidden cursor-pointer group"
>
{value ? (
{displayUrl ? (
<>
<img
src={`/uploads/${value}`}
src={displayUrl}
alt="Item"
className="w-full h-full object-cover"
/>

View File

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

View File

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

View File

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

View File

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