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:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -228,6 +228,7 @@ export function CollectionView() {
|
||||
categoryName={item.categoryName}
|
||||
categoryIcon={item.categoryIcon}
|
||||
imageFilename={item.imageFilename}
|
||||
imageUrl={item.imageUrl}
|
||||
productUrl={item.productUrl}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user