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:
506
src/client/routes/threads/$threadId/candidates/$candidateId.tsx
Normal file
506
src/client/routes/threads/$threadId/candidates/$candidateId.tsx
Normal 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"
|
||||||
|
>
|
||||||
|
← 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"
|
||||||
|
>
|
||||||
|
← 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
import { Reorder } from "framer-motion";
|
import { Reorder } from "framer-motion";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { CandidateCard } from "../../components/CandidateCard";
|
import { CandidateCard } from "../../../components/CandidateCard";
|
||||||
import { CandidateListItem } from "../../components/CandidateListItem";
|
import { CandidateListItem } from "../../../components/CandidateListItem";
|
||||||
import { ComparisonTable } from "../../components/ComparisonTable";
|
import { ComparisonTable } from "../../../components/ComparisonTable";
|
||||||
import { SetupImpactSelector } from "../../components/SetupImpactSelector";
|
import { SetupImpactSelector } from "../../../components/SetupImpactSelector";
|
||||||
import {
|
import {
|
||||||
useReorderCandidates,
|
useReorderCandidates,
|
||||||
useUpdateCandidate,
|
useUpdateCandidate,
|
||||||
} from "../../hooks/useCandidates";
|
} from "../../../hooks/useCandidates";
|
||||||
import { useImpactDeltas } from "../../hooks/useImpactDeltas";
|
import { useImpactDeltas } from "../../../hooks/useImpactDeltas";
|
||||||
import { useSetup } from "../../hooks/useSetups";
|
import { useSetup } from "../../../hooks/useSetups";
|
||||||
import { useThread } from "../../hooks/useThreads";
|
import { useThread } from "../../../hooks/useThreads";
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../../lib/iconData";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../../stores/uiStore";
|
||||||
|
|
||||||
export const Route = createFileRoute("/threads/$threadId")({
|
export const Route = createFileRoute("/threads/$threadId")({
|
||||||
component: ThreadDetailPage,
|
component: ThreadDetailPage,
|
||||||
Reference in New Issue
Block a user