import { createFileRoute, Link } from "@tanstack/react-router"; import { Reorder } from "framer-motion"; import { useEffect, useState } from "react"; import { CandidateCard } from "../../../components/CandidateCard"; import { CandidateListItem } from "../../../components/CandidateListItem"; import { CategoryPicker } from "../../../components/CategoryPicker"; import { ComparisonTable } from "../../../components/ComparisonTable"; import { ImageUpload } from "../../../components/ImageUpload"; import { SetupImpactSelector } from "../../../components/SetupImpactSelector"; import { useCreateCandidate, useReorderCandidates, useUpdateCandidate, } from "../../../hooks/useCandidates"; import { useCurrency } from "../../../hooks/useCurrency"; import { useImpactDeltas } from "../../../hooks/useImpactDeltas"; import { useSetup } from "../../../hooks/useSetups"; import { useThread } from "../../../hooks/useThreads"; import { LucideIcon } from "../../../lib/iconData"; import { useUIStore } from "../../../stores/uiStore"; export const Route = createFileRoute("/threads/$threadId/")({ component: ThreadDetailPage, }); function ThreadDetailPage() { const { threadId: threadIdParam } = Route.useParams(); const threadId = Number(threadIdParam); const { data: thread, isLoading, isError } = useThread(threadId); const candidateViewMode = useUIStore((s) => s.candidateViewMode); const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode); const selectedSetupId = useUIStore((s) => s.selectedSetupId); const updateCandidate = useUpdateCandidate(threadId); const reorderMutation = useReorderCandidates(threadId); const { data: setupData } = useSetup(selectedSetupId); const { deltas } = useImpactDeltas( thread?.candidates ?? [], setupData?.items, thread?.categoryId ?? 0, ); const [addCandidateOpen, setAddCandidateOpen] = useState(false); const [tempItems, setTempItems] = useState< NonNullable["candidates"] | null >(null); // Clear tempItems when server data changes // biome-ignore lint/correctness/useExhaustiveDependencies: thread?.candidates is the intended trigger useEffect(() => { setTempItems(null); }, [thread?.candidates]); if (isLoading) { return (
{[1, 2, 3].map((i) => (
))}
); } if (isError || !thread) { return (

Thread not found

Back to planning
); } const isActive = thread.status === "active"; const winningCandidate = thread.resolvedCandidateId ? thread.candidates.find((c) => c.id === thread.resolvedCandidateId) : null; const displayItems = tempItems ?? thread.candidates; function handleDragEnd() { if (!tempItems) return; reorderMutation.mutate({ orderedIds: tempItems.map((c) => c.id), }); } return (
{/* Header */}
← Back to planning

{thread.name}

{isActive ? "Active" : "Resolved"}
{/* Resolution banner */} {!isActive && winningCandidate && (

{winningCandidate.name} was picked as the winner and added to your collection.

)} {/* Toolbar: Add candidate + view toggle */}
{isActive && ( )} {thread.candidates.length > 0 && (
{thread.candidates.length >= 2 && ( )}
)}
{/* Candidates */} {thread.candidates.length === 0 ? (

No candidates yet

Add your first candidate to start comparing.

) : candidateViewMode === "compare" ? ( ) : candidateViewMode === "list" ? ( isActive ? ( {displayItems.map((candidate, index) => ( updateCandidate.mutate({ candidateId: candidate.id, status: newStatus, }) } delta={deltas[candidate.id]} onDragEnd={handleDragEnd} /> ))} ) : (
{displayItems.map((candidate, index) => ( updateCandidate.mutate({ candidateId: candidate.id, status: newStatus, }) } delta={deltas[candidate.id]} /> ))}
) ) : (
{thread.candidates.map((candidate, index) => ( updateCandidate.mutate({ candidateId: candidate.id, status: newStatus, }) } pros={candidate.pros} cons={candidate.cons} rank={index + 1} delta={deltas[candidate.id]} /> ))}
)} {addCandidateOpen && ( setAddCandidateOpen(false)} /> )}
); } interface AddCandidateModalProps { threadId: number; onClose: () => void; } interface ModalFormData { name: string; weightGrams: string; priceDollars: string; categoryId: number; notes: string; productUrl: string; imageFilename: string | null; pros: string; cons: string; } const INITIAL_MODAL_FORM: ModalFormData = { name: "", weightGrams: "", priceDollars: "", categoryId: 1, notes: "", productUrl: "", imageFilename: null, pros: "", cons: "", }; function AddCandidateModal({ threadId, onClose }: AddCandidateModalProps) { const createCandidate = useCreateCandidate(threadId); const { currency } = useCurrency(); const [form, setForm] = useState(INITIAL_MODAL_FORM); const [errors, setErrors] = useState>({}); function validate(): boolean { const newErrors: Record = {}; if (!form.name.trim()) { newErrors.name = "Name is required"; } if ( form.weightGrams && (Number.isNaN(Number(form.weightGrams)) || Number(form.weightGrams) < 0) ) { newErrors.weightGrams = "Must be a positive number"; } if ( form.priceDollars && (Number.isNaN(Number(form.priceDollars)) || Number(form.priceDollars) < 0) ) { newErrors.priceDollars = "Must be a positive number"; } if ( form.productUrl && form.productUrl.trim() !== "" && !form.productUrl.match(/^https?:\/\//) ) { newErrors.productUrl = "Must be a valid URL (https://...)"; } setErrors(newErrors); return Object.keys(newErrors).length === 0; } function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!validate()) return; createCandidate.mutate( { name: form.name.trim(), weightGrams: form.weightGrams ? Number(form.weightGrams) : undefined, priceCents: form.priceDollars ? Math.round(Number(form.priceDollars) * 100) : undefined, categoryId: form.categoryId, notes: form.notes.trim() || undefined, productUrl: form.productUrl.trim() || undefined, imageFilename: form.imageFilename ?? undefined, pros: form.pros.trim() || undefined, cons: form.cons.trim() || undefined, }, { onSuccess: () => { setForm(INITIAL_MODAL_FORM); onClose(); }, }, ); } return (
e.key === "Escape" && onClose()} > {/* Backdrop */}
{/* Modal */}
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} >

Add Candidate

{/* Image */} setForm((f) => ({ ...f, imageFilename: filename })) } /> {/* Name */}
setForm((f) => ({ ...f, name: e.target.value }))} className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" placeholder="e.g. Osprey Talon 22" autoFocus /> {errors.name && (

{errors.name}

)}
{/* Weight & Price row */}
setForm((f) => ({ ...f, weightGrams: e.target.value, })) } className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" placeholder="e.g. 680" /> {errors.weightGrams && (

{errors.weightGrams}

)}
setForm((f) => ({ ...f, priceDollars: e.target.value, })) } className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" placeholder="e.g. 129.99" /> {errors.priceDollars && (

{errors.priceDollars}

)}
{/* Category */}
setForm((f) => ({ ...f, categoryId: id }))} />
{/* Notes */}