- CategoryPicker shows LucideIcon prefix and uses IconPicker for inline create - CategoryHeader displays LucideIcon in view mode and IconPicker in edit mode - OnboardingWizard uses IconPicker for category creation step - CreateThreadModal drops emoji from category select options - Fixed categoryEmoji -> categoryIcon in routes and useCategories hook Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
148 lines
4.7 KiB
TypeScript
148 lines
4.7 KiB
TypeScript
import { createFileRoute, Link } from "@tanstack/react-router";
|
||
import { useThread } from "../../hooks/useThreads";
|
||
import { CandidateCard } from "../../components/CandidateCard";
|
||
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);
|
||
|
||
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-blue-600 hover:text-blue-700"
|
||
>
|
||
Back to planning
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const isActive = thread.status === "active";
|
||
const winningCandidate = thread.resolvedCandidateId
|
||
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
|
||
: 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"
|
||
>
|
||
← 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-blue-50 text-blue-700"
|
||
: "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>
|
||
)}
|
||
|
||
{/* Add candidate button */}
|
||
{isActive && (
|
||
<div className="mb-6">
|
||
<button
|
||
type="button"
|
||
onClick={openCandidateAddPanel}
|
||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 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>
|
||
</div>
|
||
)}
|
||
|
||
{/* Candidate grid */}
|
||
{thread.candidates.length === 0 ? (
|
||
<div className="py-12 text-center">
|
||
<div className="text-4xl mb-3">🏷️</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>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{thread.candidates.map((candidate) => (
|
||
<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}
|
||
threadId={threadId}
|
||
isActive={isActive}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|