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
This commit is contained in:
2026-04-06 15:00:25 +02:00
parent 2d71ce15af
commit cecaf78ead
2 changed files with 516 additions and 10 deletions

View File

@@ -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<FormData>({
name: "",
weightGrams: "",
priceDollars: "",
categoryId: 1,
notes: "",
productUrl: "",
imageFilename: null,
pros: "",
cons: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
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<string, string> = {};
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 (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="animate-pulse space-y-6">
<div className="h-4 bg-gray-100 rounded w-24" />
<div className="aspect-[16/9] bg-gray-100 rounded-xl" />
<div className="space-y-3">
<div className="h-6 bg-gray-100 rounded w-48" />
<div className="flex gap-2">
<div className="h-7 bg-gray-100 rounded-full w-20" />
<div className="h-7 bg-gray-100 rounded-full w-24" />
<div className="h-7 bg-gray-100 rounded-full w-28" />
</div>
<div className="h-4 bg-gray-100 rounded w-full" />
<div className="h-4 bg-gray-100 rounded w-3/4" />
</div>
</div>
</div>
);
}
// Error / not found
if (isError || !thread || !candidate) {
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<Link
to="/threads/$threadId"
params={{ threadId: String(threadId) }}
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
&larr; Back to thread
</Link>
<div className="text-center py-16">
<p className="text-sm text-gray-500">Candidate not found</p>
</div>
</div>
);
}
const imageUrl = candidate.imageFilename
? `/uploads/${candidate.imageFilename}`
: null;
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Back navigation */}
<div className="mb-6">
<Link
to="/threads/$threadId"
params={{ threadId: String(threadId) }}
className="text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
&larr; Back to thread
</Link>
</div>
{/* Image */}
{isEditing ? (
<div className="mb-6">
<ImageUpload
value={form.imageFilename}
imageUrl={imageUrl}
onChange={(filename) =>
setForm((f) => ({ ...f, imageFilename: filename }))
}
/>
</div>
) : imageUrl ? (
<div className="aspect-[16/9] bg-gray-50 rounded-xl overflow-hidden mb-6">
<img
src={imageUrl}
alt={candidate.name}
className="w-full h-full object-cover"
/>
</div>
) : null}
{/* Header */}
<div className="mb-6">
{isEditing ? (
<div className="space-y-1">
<input
type="text"
value={form.name}
onChange={(e) => 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 && (
<p className="text-xs text-red-500">{errors.name}</p>
)}
</div>
) : (
<div className="flex items-start justify-between gap-4">
<h1 className="text-2xl font-bold text-gray-900">
{candidate.name}
</h1>
{isActive && (
<button
type="button"
onClick={enterEditMode}
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
>
<LucideIcon name="pencil" size={14} />
Edit
</button>
)}
</div>
)}
</div>
{/* Badges */}
<div className="flex flex-wrap items-center gap-2 mb-6">
{isEditing ? (
<>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-500">
Weight (g)
</label>
<input
type="number"
min="0"
step="any"
value={form.weightGrams}
onChange={(e) =>
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 && (
<p className="text-xs text-red-500">{errors.weightGrams}</p>
)}
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-500">
Price ($)
</label>
<input
type="number"
min="0"
step="0.01"
value={form.priceDollars}
onChange={(e) =>
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 && (
<p className="text-xs text-red-500">{errors.priceDollars}</p>
)}
</div>
<div className="flex items-center gap-2">
<label className="text-xs font-medium text-gray-500">
Category
</label>
<CategoryPicker
value={form.categoryId}
onChange={(id) => setForm((f) => ({ ...f, categoryId: id }))}
/>
</div>
</>
) : (
<>
{candidate.weightGrams != null && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-50 text-blue-500">
{weight(candidate.weightGrams)}
</span>
)}
{candidate.priceCents != null && (
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-50 text-green-500">
{price(candidate.priceCents)}
</span>
)}
{candidate.categoryName && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-medium bg-gray-50 text-gray-600">
<LucideIcon
name={candidate.categoryIcon || "folder"}
size={14}
/>
{candidate.categoryName}
</span>
)}
<StatusBadge status={candidate.status} onStatusChange={() => {}} />
</>
)}
</div>
{/* Product Link */}
{isEditing ? (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
Product Link
</label>
<input
type="url"
value={form.productUrl}
onChange={(e) =>
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 && (
<p className="mt-1 text-xs text-red-500">{errors.productUrl}</p>
)}
</div>
) : candidate.productUrl ? (
<div className="mb-6">
<a
href={candidate.productUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
<LucideIcon name="external-link" size={14} />
View product page
</a>
</div>
) : null}
{/* Pros & Cons */}
{isEditing ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Pros
</label>
<textarea
value={form.pros}
onChange={(e) => setForm((f) => ({ ...f, pros: e.target.value }))}
rows={4}
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 resize-none"
placeholder="One pro per line..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cons
</label>
<textarea
value={form.cons}
onChange={(e) => setForm((f) => ({ ...f, cons: e.target.value }))}
rows={4}
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 resize-none"
placeholder="One con per line..."
/>
</div>
</div>
) : candidate.pros || candidate.cons ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
{candidate.pros && (
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Pros</h3>
<ul className="space-y-1">
{candidate.pros
.split("\n")
.filter((line) => line.trim())
.map((pro) => (
<li
key={pro}
className="flex items-start gap-2 text-sm text-gray-600"
>
<LucideIcon
name="plus"
size={14}
className="text-green-500 mt-0.5 shrink-0"
/>
{pro.trim()}
</li>
))}
</ul>
</div>
)}
{candidate.cons && (
<div>
<h3 className="text-sm font-medium text-gray-700 mb-2">Cons</h3>
<ul className="space-y-1">
{candidate.cons
.split("\n")
.filter((line) => line.trim())
.map((con) => (
<li
key={con}
className="flex items-start gap-2 text-sm text-gray-600"
>
<LucideIcon
name="minus"
size={14}
className="text-red-500 mt-0.5 shrink-0"
/>
{con.trim()}
</li>
))}
</ul>
</div>
)}
</div>
) : null}
{/* Notes */}
{isEditing ? (
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-1">
Notes
</label>
<textarea
value={form.notes}
onChange={(e) => setForm((f) => ({ ...f, notes: e.target.value }))}
rows={4}
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 resize-none"
placeholder="Any additional notes..."
/>
</div>
) : candidate.notes ? (
<div className="mb-6">
<h3 className="text-sm font-medium text-gray-700 mb-2">Notes</h3>
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">
{candidate.notes}
</p>
</div>
) : null}
{/* Edit mode actions */}
{isEditing && (
<div className="flex gap-3 mb-6">
<button
type="button"
onClick={handleSave}
disabled={updateCandidate.isPending}
className="px-4 py-2 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{updateCandidate.isPending ? "Saving..." : "Save Changes"}
</button>
<button
type="button"
onClick={cancelEdit}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
>
Cancel
</button>
</div>
)}
{/* Thread-specific actions */}
{!isEditing && isActive && (
<div className="flex gap-3 pt-4 border-t border-gray-100">
<button
type="button"
onClick={() => openResolveDialog(threadId, candidateId)}
className="inline-flex items-center gap-1.5 px-4 py-2 bg-amber-50 hover:bg-amber-100 text-amber-700 text-sm font-medium rounded-lg transition-colors"
>
<LucideIcon name="trophy" size={14} />
Pick as winner
</button>
<button
type="button"
onClick={() => openConfirmDeleteCandidate(candidateId)}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm text-red-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<LucideIcon name="trash-2" size={14} />
Delete
</button>
</div>
)}
</div>
);
}

View File

@@ -1,19 +1,19 @@
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 { ComparisonTable } from "../../components/ComparisonTable";
import { SetupImpactSelector } from "../../components/SetupImpactSelector";
import { CandidateCard } from "../../../components/CandidateCard";
import { CandidateListItem } from "../../../components/CandidateListItem";
import { ComparisonTable } from "../../../components/ComparisonTable";
import { SetupImpactSelector } from "../../../components/SetupImpactSelector";
import {
useReorderCandidates,
useUpdateCandidate,
} from "../../hooks/useCandidates";
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";
} from "../../../hooks/useCandidates";
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,