Files
GearBox/src/client/routes/threads/$threadId.tsx
Jean-Luc Makiola 7e06c8526b fix(11): wire handleDragEnd to Reorder.Group for active threads
onPointerUp was incorrectly placed on the resolved-thread div instead
of the active-thread Reorder.Group, causing drag reorder to not persist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 22:36:40 +01:00

266 lines
7.6 KiB
TypeScript

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 {
useReorderCandidates,
useUpdateCandidate,
} from "../../hooks/useCandidates";
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 updateCandidate = useUpdateCandidate(threadId);
const reorderMutation = useReorderCandidates(threadId);
const [tempItems, setTempItems] =
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
null,
);
// Clear tempItems when server data changes (biome-ignore: thread?.candidates is intentional dep)
// 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) },
{ onSettled: () => setTempItems(null) },
);
}
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"
>
&larr; 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">
{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>
</div>
)}
</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 === "list" ? (
isActive ? (
<Reorder.Group
axis="y"
values={displayItems}
onReorder={setTempItems}
onPointerUp={handleDragEnd}
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,
})
}
/>
))}
</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,
})
}
/>
))}
</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}
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}
/>
))}
</div>
)}
</div>
);
}