feat(05-02): add always-visible 4:3 image area with placeholders to ItemCard and CandidateCard

- Replace conditional image rendering with always-present 4:3 aspect ratio area
- Show category emoji centered on gray background when no image exists
- Ensures consistent card heights across grid layouts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 17:14:20 +01:00
parent 036d8ac183
commit acf34c33d9
2 changed files with 177 additions and 159 deletions

View File

@@ -1,91 +1,95 @@
import { formatWeight, formatPrice } from "../lib/formatters"; import { formatPrice, formatWeight } from "../lib/formatters";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
interface CandidateCardProps { interface CandidateCardProps {
id: number; id: number;
name: string; name: string;
weightGrams: number | null; weightGrams: number | null;
priceCents: number | null; priceCents: number | null;
categoryName: string; categoryName: string;
categoryEmoji: string; categoryEmoji: string;
imageFilename: string | null; imageFilename: string | null;
threadId: number; threadId: number;
isActive: boolean; isActive: boolean;
} }
export function CandidateCard({ export function CandidateCard({
id, id,
name, name,
weightGrams, weightGrams,
priceCents, priceCents,
categoryName, categoryName,
categoryEmoji, categoryEmoji,
imageFilename, imageFilename,
threadId, threadId,
isActive, isActive,
}: CandidateCardProps) { }: CandidateCardProps) {
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel); const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
const openConfirmDeleteCandidate = useUIStore( const openConfirmDeleteCandidate = useUIStore(
(s) => s.openConfirmDeleteCandidate, (s) => s.openConfirmDeleteCandidate,
); );
const openResolveDialog = useUIStore((s) => s.openResolveDialog); const openResolveDialog = useUIStore((s) => s.openResolveDialog);
return ( return (
<div className="bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden"> <div className="bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden">
{imageFilename && ( <div className="aspect-[4/3] bg-gray-50">
<div className="aspect-[4/3] bg-gray-50"> {imageFilename ? (
<img <img
src={`/uploads/${imageFilename}`} src={`/uploads/${imageFilename}`}
alt={name} alt={name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
</div> ) : (
)} <div className="w-full h-full flex flex-col items-center justify-center">
<div className="p-4"> <span className="text-3xl">{categoryEmoji}</span>
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate"> </div>
{name} )}
</h3> </div>
<div className="flex flex-wrap gap-1.5 mb-3"> <div className="p-4">
{weightGrams != null && ( <h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700"> {name}
{formatWeight(weightGrams)} </h3>
</span> <div className="flex flex-wrap gap-1.5 mb-3">
)} {weightGrams != null && (
{priceCents != null && ( <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700"> {formatWeight(weightGrams)}
{formatPrice(priceCents)} </span>
</span> )}
)} {priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
{categoryEmoji} {categoryName} {formatPrice(priceCents)}
</span> </span>
</div> )}
<div className="flex gap-2"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<button {categoryEmoji} {categoryName}
type="button" </span>
onClick={() => openCandidateEditPanel(id)} </div>
className="text-xs text-gray-500 hover:text-blue-600 transition-colors" <div className="flex gap-2">
> <button
Edit type="button"
</button> onClick={() => openCandidateEditPanel(id)}
<button className="text-xs text-gray-500 hover:text-blue-600 transition-colors"
type="button" >
onClick={() => openConfirmDeleteCandidate(id)} Edit
className="text-xs text-gray-500 hover:text-red-600 transition-colors" </button>
> <button
Delete type="button"
</button> onClick={() => openConfirmDeleteCandidate(id)}
{isActive && ( className="text-xs text-gray-500 hover:text-red-600 transition-colors"
<button >
type="button" Delete
onClick={() => openResolveDialog(threadId, id)} </button>
className="ml-auto text-xs font-medium text-amber-600 hover:text-amber-700 transition-colors" {isActive && (
> <button
Pick Winner type="button"
</button> onClick={() => openResolveDialog(threadId, id)}
)} className="ml-auto text-xs font-medium text-amber-600 hover:text-amber-700 transition-colors"
</div> >
</div> Pick Winner
</div> </button>
); )}
</div>
</div>
</div>
);
} }

View File

@@ -1,86 +1,100 @@
import { formatWeight, formatPrice } from "../lib/formatters"; import { formatPrice, formatWeight } from "../lib/formatters";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
interface ItemCardProps { interface ItemCardProps {
id: number; id: number;
name: string; name: string;
weightGrams: number | null; weightGrams: number | null;
priceCents: number | null; priceCents: number | null;
categoryName: string; categoryName: string;
categoryEmoji: string; categoryEmoji: string;
imageFilename: string | null; imageFilename: string | null;
onRemove?: () => void; onRemove?: () => void;
} }
export function ItemCard({ export function ItemCard({
id, id,
name, name,
weightGrams, weightGrams,
priceCents, priceCents,
categoryName, categoryName,
categoryEmoji, categoryEmoji,
imageFilename, imageFilename,
onRemove, onRemove,
}: ItemCardProps) { }: ItemCardProps) {
const openEditPanel = useUIStore((s) => s.openEditPanel); const openEditPanel = useUIStore((s) => s.openEditPanel);
return ( return (
<button <button
type="button" type="button"
onClick={() => openEditPanel(id)} onClick={() => openEditPanel(id)}
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group" className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
> >
{onRemove && ( {onRemove && (
<span <span
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(); onRemove();
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
e.stopPropagation(); e.stopPropagation();
onRemove(); onRemove();
} }
}} }}
className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer" className="absolute top-2 right-2 z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Remove from setup" title="Remove from setup"
> >
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> className="w-3.5 h-3.5"
</svg> fill="none"
</span> stroke="currentColor"
)} viewBox="0 0 24 24"
{imageFilename && ( >
<div className="aspect-[4/3] bg-gray-50"> <path
<img strokeLinecap="round"
src={`/uploads/${imageFilename}`} strokeLinejoin="round"
alt={name} strokeWidth={2}
className="w-full h-full object-cover" d="M6 18L18 6M6 6l12 12"
/> />
</div> </svg>
)} </span>
<div className="p-4"> )}
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate"> <div className="aspect-[4/3] bg-gray-50">
{name} {imageFilename ? (
</h3> <img
<div className="flex flex-wrap gap-1.5"> src={`/uploads/${imageFilename}`}
{weightGrams != null && ( alt={name}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700"> className="w-full h-full object-cover"
{formatWeight(weightGrams)} />
</span> ) : (
)} <div className="w-full h-full flex flex-col items-center justify-center">
{priceCents != null && ( <span className="text-3xl">{categoryEmoji}</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700"> </div>
{formatPrice(priceCents)} )}
</span> </div>
)} <div className="p-4">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> <h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
{categoryEmoji} {categoryName} {name}
</span> </h3>
</div> <div className="flex flex-wrap gap-1.5">
</div> {weightGrams != null && (
</button> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
); {formatWeight(weightGrams)}
</span>
)}
{priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
{formatPrice(priceCents)}
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
{categoryEmoji} {categoryName}
</span>
</div>
</div>
</button>
);
} }