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;
|
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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user