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:
299
src/client/routes/threads/$threadId/index.tsx
Normal file
299
src/client/routes/threads/$threadId/index.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
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 {
|
||||
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";
|
||||
|
||||
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 openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
||||
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 [tempItems, setTempItems] = useState<
|
||||
NonNullable<typeof thread>["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 (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-6 bg-gray-200 rounded w-48" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-40 bg-gray-200 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !thread) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 text-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
Thread not found
|
||||
</h2>
|
||||
<Link
|
||||
to="/"
|
||||
search={{ tab: "planning" }}
|
||||
className="text-sm text-gray-600 hover:text-gray-700"
|
||||
>
|
||||
Back to planning
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<Link
|
||||
to="/"
|
||||
search={{ tab: "planning" }}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
|
||||
>
|
||||
← Back to planning
|
||||
</Link>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-900">{thread.name}</h1>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
isActive
|
||||
? "bg-gray-100 text-gray-600"
|
||||
: "bg-gray-100 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{isActive ? "Active" : "Resolved"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution banner */}
|
||||
{!isActive && winningCandidate && (
|
||||
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-xl">
|
||||
<p className="text-sm text-amber-800">
|
||||
<span className="font-medium">{winningCandidate.name}</span> was
|
||||
picked as the winner and added to your collection.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar: Add candidate + view toggle */}
|
||||
<div className="mb-6 flex items-center gap-3 flex-wrap">
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCandidateAddPanel}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Add Candidate
|
||||
</button>
|
||||
)}
|
||||
{thread.candidates.length > 0 && (
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("list")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "list"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="List view"
|
||||
>
|
||||
<LucideIcon name="layout-list" size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("grid")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "grid"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="Grid view"
|
||||
>
|
||||
<LucideIcon name="layout-grid" size={16} />
|
||||
</button>
|
||||
{thread.candidates.length >= 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCandidateViewMode("compare")}
|
||||
className={`p-1.5 rounded-md transition-colors ${
|
||||
candidateViewMode === "compare"
|
||||
? "bg-gray-200 text-gray-900"
|
||||
: "text-gray-400 hover:text-gray-600"
|
||||
}`}
|
||||
title="Compare view"
|
||||
>
|
||||
<LucideIcon name="columns-3" size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SetupImpactSelector threadStatus={thread.status} />
|
||||
</div>
|
||||
|
||||
{/* Candidates */}
|
||||
{thread.candidates.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mb-3">
|
||||
<LucideIcon
|
||||
name="tag"
|
||||
size={48}
|
||||
className="text-gray-400 mx-auto"
|
||||
/>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">
|
||||
No candidates yet
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Add your first candidate to start comparing.
|
||||
</p>
|
||||
</div>
|
||||
) : candidateViewMode === "compare" ? (
|
||||
<ComparisonTable
|
||||
candidates={displayItems}
|
||||
resolvedCandidateId={thread.resolvedCandidateId}
|
||||
deltas={deltas}
|
||||
/>
|
||||
) : candidateViewMode === "list" ? (
|
||||
isActive ? (
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={displayItems}
|
||||
onReorder={setTempItems}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{displayItems.map((candidate, index) => (
|
||||
<CandidateListItem
|
||||
key={candidate.id}
|
||||
candidate={candidate}
|
||||
rank={index + 1}
|
||||
isActive={isActive}
|
||||
onStatusChange={(newStatus) =>
|
||||
updateCandidate.mutate({
|
||||
candidateId: candidate.id,
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
delta={deltas[candidate.id]}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{displayItems.map((candidate, index) => (
|
||||
<CandidateListItem
|
||||
key={candidate.id}
|
||||
candidate={candidate}
|
||||
rank={index + 1}
|
||||
isActive={isActive}
|
||||
onStatusChange={(newStatus) =>
|
||||
updateCandidate.mutate({
|
||||
candidateId: candidate.id,
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
delta={deltas[candidate.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{thread.candidates.map((candidate, index) => (
|
||||
<CandidateCard
|
||||
key={candidate.id}
|
||||
id={candidate.id}
|
||||
name={candidate.name}
|
||||
weightGrams={candidate.weightGrams}
|
||||
priceCents={candidate.priceCents}
|
||||
categoryName={candidate.categoryName}
|
||||
categoryIcon={candidate.categoryIcon}
|
||||
imageFilename={candidate.imageFilename}
|
||||
imageUrl={candidate.imageUrl}
|
||||
productUrl={candidate.productUrl}
|
||||
threadId={threadId}
|
||||
isActive={isActive}
|
||||
status={candidate.status}
|
||||
onStatusChange={(newStatus) =>
|
||||
updateCandidate.mutate({
|
||||
candidateId: candidate.id,
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
pros={candidate.pros}
|
||||
cons={candidate.cons}
|
||||
rank={index + 1}
|
||||
delta={deltas[candidate.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user