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 { 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,
|
||||
Reference in New Issue
Block a user