diff --git a/src/client/components/CandidateListItem.tsx b/src/client/components/CandidateListItem.tsx new file mode 100644 index 0000000..c6233ad --- /dev/null +++ b/src/client/components/CandidateListItem.tsx @@ -0,0 +1,211 @@ +import { Reorder, useDragControls } from "framer-motion"; +import { useCurrency } from "../hooks/useCurrency"; +import { useWeightUnit } from "../hooks/useWeightUnit"; +import { formatPrice, formatWeight } from "../lib/formatters"; +import { LucideIcon } from "../lib/iconData"; +import { useUIStore } from "../stores/uiStore"; +import { StatusBadge } from "./StatusBadge"; + +interface CandidateWithCategory { + id: number; + threadId: number; + name: string; + weightGrams: number | null; + priceCents: number | null; + categoryId: number; + notes: string | null; + productUrl: string | null; + imageFilename: string | null; + status: "researching" | "ordered" | "arrived"; + pros: string | null; + cons: string | null; + createdAt: string; + updatedAt: string; + categoryName: string; + categoryIcon: string; +} + +interface CandidateListItemProps { + candidate: CandidateWithCategory; + rank: number; + isActive: boolean; + onStatusChange: (status: "researching" | "ordered" | "arrived") => void; +} + +const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze + +export function RankBadge({ rank }: { rank: number }) { + if (rank > 3) return null; + return ( + + ); +} + +export function CandidateListItem({ + candidate, + rank, + isActive, + onStatusChange, +}: CandidateListItemProps) { + const controls = useDragControls(); + const unit = useWeightUnit(); + const currency = useCurrency(); + const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel); + const openConfirmDeleteCandidate = useUIStore( + (s) => s.openConfirmDeleteCandidate, + ); + const openResolveDialog = useUIStore((s) => s.openResolveDialog); + const openExternalLink = useUIStore((s) => s.openExternalLink); + + return ( + + {/* Drag handle */} + {isActive && ( + controls.start(e)} + className="cursor-grab active:cursor-grabbing text-gray-300 hover:text-gray-500 touch-none shrink-0" + title="Drag to reorder" + > + + + )} + + {/* Rank badge */} + + + {/* Image thumbnail */} + + {candidate.imageFilename ? ( + + ) : ( + + )} + + + {/* Name + badges */} + openCandidateEditPanel(candidate.id)} + className="flex-1 min-w-0 text-left" + > + + {candidate.name} + + + {candidate.weightGrams != null && ( + + {formatWeight(candidate.weightGrams, unit)} + + )} + {candidate.priceCents != null && ( + + {formatPrice(candidate.priceCents, currency)} + + )} + + + {candidate.categoryName} + + + {(candidate.pros || candidate.cons) && ( + + +/- Notes + + )} + + + + {/* Action buttons (hover-reveal) */} + + {isActive && ( + { + e.stopPropagation(); + openResolveDialog(candidate.threadId, candidate.id); + }} + className="px-2 py-0.5 flex items-center gap-1 rounded-full text-xs font-medium bg-amber-100/90 text-amber-700 hover:bg-amber-200 cursor-pointer" + title="Pick as winner" + > + + Winner + + )} + {candidate.productUrl && ( + { + e.stopPropagation(); + openExternalLink(candidate.productUrl as string); + }} + className="w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-gray-200 hover:text-gray-600 cursor-pointer" + title="Open product link" + > + + + + + )} + { + e.stopPropagation(); + openConfirmDeleteCandidate(candidate.id); + }} + className="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 cursor-pointer" + title="Delete candidate" + > + + + + + + + ); +} diff --git a/src/client/hooks/useCandidates.ts b/src/client/hooks/useCandidates.ts index ab9a5e0..22ef3a7 100644 --- a/src/client/hooks/useCandidates.ts +++ b/src/client/hooks/useCandidates.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { CreateCandidate, UpdateCandidate } from "../../shared/types"; -import { apiDelete, apiPost, apiPut } from "../lib/api"; +import { apiDelete, apiPatch, apiPost, apiPut } from "../lib/api"; interface CandidateResponse { id: number; @@ -62,3 +62,17 @@ export function useDeleteCandidate(threadId: number) { }, }); } + +export function useReorderCandidates(threadId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { orderedIds: number[] }) => + apiPatch<{ success: boolean }>( + `/api/threads/${threadId}/candidates/reorder`, + data, + ), + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["threads", threadId] }); + }, + }); +} diff --git a/src/client/hooks/useThreads.ts b/src/client/hooks/useThreads.ts index b1de598..befe6f8 100644 --- a/src/client/hooks/useThreads.ts +++ b/src/client/hooks/useThreads.ts @@ -27,6 +27,8 @@ interface CandidateWithCategory { productUrl: string | null; imageFilename: string | null; status: "researching" | "ordered" | "arrived"; + pros: string | null; + cons: string | null; createdAt: string; updatedAt: string; categoryName: string; diff --git a/src/client/lib/iconData.tsx b/src/client/lib/iconData.tsx index b4db8f6..f106cda 100644 --- a/src/client/lib/iconData.tsx +++ b/src/client/lib/iconData.tsx @@ -1,4 +1,5 @@ import { icons } from "lucide-react"; +import type React from "react"; // --- Emoji to Lucide icon mapping (for migration/fallback) --- @@ -232,18 +233,20 @@ interface LucideIconProps { name: string; size?: number; className?: string; + style?: React.CSSProperties; } export function LucideIcon({ name, size = 20, className = "", + style, }: LucideIconProps) { const pascalName = toPascalCase(name); const IconComponent = icons[pascalName as keyof typeof icons]; if (!IconComponent) { const FallbackIcon = icons.Package; - return ; + return ; } - return ; + return ; } diff --git a/src/client/stores/uiStore.ts b/src/client/stores/uiStore.ts index 97d1355..571d9be 100644 --- a/src/client/stores/uiStore.ts +++ b/src/client/stores/uiStore.ts @@ -48,6 +48,10 @@ interface UIState { externalLinkUrl: string | null; openExternalLink: (url: string) => void; closeExternalLink: () => void; + + // Candidate view mode + candidateViewMode: "list" | "grid"; + setCandidateViewMode: (mode: "list" | "grid") => void; } export const useUIStore = create((set) => ({ @@ -103,4 +107,8 @@ export const useUIStore = create((set) => ({ externalLinkUrl: null, openExternalLink: (url) => set({ externalLinkUrl: url }), closeExternalLink: () => set({ externalLinkUrl: null }), + + // Candidate view mode + candidateViewMode: "list", + setCandidateViewMode: (mode) => set({ candidateViewMode: mode }), }));
+ {candidate.name} +