feat(11-02): add view toggle, Reorder.Group drag-to-reorder, and rank badges in grid view
- Thread detail page: list/grid view toggle with LayoutList/LayoutGrid icons - List view (active threads): Reorder.Group with CandidateListItem for drag-to-reorder - List view (resolved threads): static CandidateListItem with rank badges, no drag handles - Grid view: CandidateCard components with rank badges (gold/silver/bronze) - tempItems pattern prevents React Query flicker during drag - handleDragEnd fires PATCH /candidates/reorder after drag completes - View toggle defaults to list view via uiStore candidateViewMode
This commit is contained in:
@@ -3,6 +3,7 @@ import { useWeightUnit } from "../hooks/useWeightUnit";
|
|||||||
import { formatPrice, formatWeight } from "../lib/formatters";
|
import { formatPrice, formatWeight } from "../lib/formatters";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { RankBadge } from "./CandidateListItem";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
interface CandidateCardProps {
|
interface CandidateCardProps {
|
||||||
@@ -20,6 +21,7 @@ interface CandidateCardProps {
|
|||||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||||
pros?: string | null;
|
pros?: string | null;
|
||||||
cons?: string | null;
|
cons?: string | null;
|
||||||
|
rank?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CandidateCard({
|
export function CandidateCard({
|
||||||
@@ -37,6 +39,7 @@ export function CandidateCard({
|
|||||||
onStatusChange,
|
onStatusChange,
|
||||||
pros,
|
pros,
|
||||||
cons,
|
cons,
|
||||||
|
rank,
|
||||||
}: CandidateCardProps) {
|
}: CandidateCardProps) {
|
||||||
const unit = useWeightUnit();
|
const unit = useWeightUnit();
|
||||||
const currency = useCurrency();
|
const currency = useCurrency();
|
||||||
@@ -159,6 +162,7 @@ export function CandidateCard({
|
|||||||
{name}
|
{name}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{rank != null && <RankBadge rank={rank} />}
|
||||||
{weightGrams != null && (
|
{weightGrams != null && (
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
|
||||||
{formatWeight(weightGrams, unit)}
|
{formatWeight(weightGrams, unit)}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { Reorder } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { CandidateCard } from "../../components/CandidateCard";
|
import { CandidateCard } from "../../components/CandidateCard";
|
||||||
import { useUpdateCandidate } from "../../hooks/useCandidates";
|
import { CandidateListItem } from "../../components/CandidateListItem";
|
||||||
|
import {
|
||||||
|
useReorderCandidates,
|
||||||
|
useUpdateCandidate,
|
||||||
|
} from "../../hooks/useCandidates";
|
||||||
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";
|
||||||
@@ -14,7 +20,21 @@ function ThreadDetailPage() {
|
|||||||
const threadId = Number(threadIdParam);
|
const threadId = Number(threadIdParam);
|
||||||
const { data: thread, isLoading, isError } = useThread(threadId);
|
const { data: thread, isLoading, isError } = useThread(threadId);
|
||||||
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
||||||
|
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
||||||
|
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
||||||
const updateCandidate = useUpdateCandidate(threadId);
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -53,6 +73,16 @@ function ThreadDetailPage() {
|
|||||||
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
|
? thread.candidates.find((c) => c.id === thread.resolvedCandidateId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
const displayItems = tempItems ?? thread.candidates;
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
if (!tempItems) return;
|
||||||
|
reorderMutation.mutate(
|
||||||
|
{ orderedIds: tempItems.map((c) => c.id) },
|
||||||
|
{ onSettled: () => setTempItems(null) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -88,9 +118,9 @@ function ThreadDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add candidate button */}
|
{/* Toolbar: Add candidate + view toggle */}
|
||||||
{isActive && (
|
<div className="mb-6 flex items-center gap-3">
|
||||||
<div className="mb-6">
|
{isActive && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCandidateAddPanel}
|
onClick={openCandidateAddPanel}
|
||||||
@@ -111,10 +141,38 @@ function ThreadDetailPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
Add Candidate
|
Add Candidate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
{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>
|
||||||
|
|
||||||
{/* Candidate grid */}
|
{/* Candidates */}
|
||||||
{thread.candidates.length === 0 ? (
|
{thread.candidates.length === 0 ? (
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -131,9 +189,50 @@ function ThreadDetailPage() {
|
|||||||
Add your first candidate to start comparing.
|
Add your first candidate to start comparing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : candidateViewMode === "list" ? (
|
||||||
|
isActive ? (
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={displayItems}
|
||||||
|
onReorder={setTempItems}
|
||||||
|
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" onPointerUp={handleDragEnd}>
|
||||||
|
{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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{thread.candidates.map((candidate) => (
|
{thread.candidates.map((candidate, index) => (
|
||||||
<CandidateCard
|
<CandidateCard
|
||||||
key={candidate.id}
|
key={candidate.id}
|
||||||
id={candidate.id}
|
id={candidate.id}
|
||||||
@@ -155,6 +254,7 @@ function ThreadDetailPage() {
|
|||||||
}
|
}
|
||||||
pros={candidate.pros}
|
pros={candidate.pros}
|
||||||
cons={candidate.cons}
|
cons={candidate.cons}
|
||||||
|
rank={index + 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user