From cecaf78ead1b89428b326b4f6ac52d21687d3866 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 6 Apr 2026 15:00:25 +0200 Subject: [PATCH 1/3] feat(21-02): restructure thread route and create candidate detail page - Move $threadId.tsx to $threadId/index.tsx for nested route support - Create candidate detail page at /threads/:threadId/candidates/:candidateId - Edit mode toggle with form fields for all candidate properties - Back navigation, pick-as-winner, and delete actions --- .../$threadId/candidates/$candidateId.tsx | 506 ++++++++++++++++++ .../{$threadId.tsx => $threadId/index.tsx} | 20 +- 2 files changed, 516 insertions(+), 10 deletions(-) create mode 100644 src/client/routes/threads/$threadId/candidates/$candidateId.tsx rename src/client/routes/threads/{$threadId.tsx => $threadId/index.tsx} (93%) diff --git a/src/client/routes/threads/$threadId/candidates/$candidateId.tsx b/src/client/routes/threads/$threadId/candidates/$candidateId.tsx new file mode 100644 index 0000000..6228b2a --- /dev/null +++ b/src/client/routes/threads/$threadId/candidates/$candidateId.tsx @@ -0,0 +1,506 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { CategoryPicker } from "../../../../components/CategoryPicker"; +import { ImageUpload } from "../../../../components/ImageUpload"; +import { StatusBadge } from "../../../../components/StatusBadge"; +import { useUpdateCandidate } from "../../../../hooks/useCandidates"; +import { useFormatters } from "../../../../hooks/useFormatters"; +import { useThread } from "../../../../hooks/useThreads"; +import { LucideIcon } from "../../../../lib/iconData"; +import { useUIStore } from "../../../../stores/uiStore"; + +export const Route = createFileRoute( + "/threads/$threadId/candidates/$candidateId", +)({ + component: CandidateDetailPage, +}); + +interface FormData { + name: string; + weightGrams: string; + priceDollars: string; + categoryId: number; + notes: string; + productUrl: string; + imageFilename: string | null; + pros: string; + cons: string; +} + +function CandidateDetailPage() { + const { threadId: threadIdParam, candidateId: candidateIdParam } = + Route.useParams(); + const threadId = Number(threadIdParam); + const candidateId = Number(candidateIdParam); + const { data: thread, isLoading, isError } = useThread(threadId); + const updateCandidate = useUpdateCandidate(threadId); + const { weight, price } = useFormatters(); + const openResolveDialog = useUIStore((s) => s.openResolveDialog); + const openConfirmDeleteCandidate = useUIStore( + (s) => s.openConfirmDeleteCandidate, + ); + + const [isEditing, setIsEditing] = useState(false); + const [form, setForm] = useState({ + name: "", + weightGrams: "", + priceDollars: "", + categoryId: 1, + notes: "", + productUrl: "", + imageFilename: null, + pros: "", + cons: "", + }); + const [errors, setErrors] = useState>({}); + + const candidate = thread?.candidates.find((c) => c.id === candidateId); + const isActive = thread?.status === "active"; + + function enterEditMode() { + if (!candidate) return; + setForm({ + name: candidate.name, + weightGrams: + candidate.weightGrams != null ? String(candidate.weightGrams) : "", + priceDollars: + candidate.priceCents != null + ? (candidate.priceCents / 100).toFixed(2) + : "", + categoryId: candidate.categoryId, + notes: candidate.notes ?? "", + productUrl: candidate.productUrl ?? "", + imageFilename: candidate.imageFilename ?? null, + pros: candidate.pros ?? "", + cons: candidate.cons ?? "", + }); + setErrors({}); + setIsEditing(true); + } + + function cancelEdit() { + setIsEditing(false); + setErrors({}); + } + + 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 handleSave() { + if (!validate()) return; + + updateCandidate.mutate( + { + candidateId, + 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: () => setIsEditing(false), + }, + ); + } + + // Loading state + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } + + // Error / not found + if (isError || !thread || !candidate) { + return ( +
+ + ← Back to thread + +
+

Candidate not found

+
+
+ ); + } + + const imageUrl = candidate.imageFilename + ? `/uploads/${candidate.imageFilename}` + : null; + + return ( +
+ {/* Back navigation */} +
+ + ← Back to thread + +
+ + {/* Image */} + {isEditing ? ( +
+ + setForm((f) => ({ ...f, imageFilename: filename })) + } + /> +
+ ) : imageUrl ? ( +
+ {candidate.name} +
+ ) : null} + + {/* Header */} +
+ {isEditing ? ( +
+ setForm((f) => ({ ...f, name: e.target.value }))} + className="w-full text-2xl font-bold text-gray-900 px-3 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + /> + {errors.name && ( +

{errors.name}

+ )} +
+ ) : ( +
+

+ {candidate.name} +

+ {isActive && ( + + )} +
+ )} +
+ + {/* Badges */} +
+ {isEditing ? ( + <> +
+ + + setForm((f) => ({ ...f, weightGrams: e.target.value })) + } + className="w-24 px-2 py-1 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-400 focus:border-transparent" + placeholder="g" + /> + {errors.weightGrams && ( +

{errors.weightGrams}

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

{errors.priceDollars}

+ )} +
+
+ + setForm((f) => ({ ...f, categoryId: id }))} + /> +
+ + ) : ( + <> + {candidate.weightGrams != null && ( + + {weight(candidate.weightGrams)} + + )} + {candidate.priceCents != null && ( + + {price(candidate.priceCents)} + + )} + {candidate.categoryName && ( + + + {candidate.categoryName} + + )} + {}} /> + + )} +
+ + {/* Product Link */} + {isEditing ? ( +
+ + + setForm((f) => ({ ...f, productUrl: 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="https://..." + /> + {errors.productUrl && ( +

{errors.productUrl}

+ )} +
+ ) : candidate.productUrl ? ( + + ) : null} + + {/* Pros & Cons */} + {isEditing ? ( +
+
+ +