All checks were successful
CI / ci (push) Successful in 15s
Run biome check --write --unsafe to fix tabs, import ordering, and non-null assertions across entire codebase. Disable a11y rules not applicable to this single-user app. Exclude auto-generated routeTree. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
329 lines
9.8 KiB
TypeScript
329 lines
9.8 KiB
TypeScript
import {
|
|
createRootRoute,
|
|
Outlet,
|
|
useMatchRoute,
|
|
useNavigate,
|
|
} from "@tanstack/react-router";
|
|
import { useState } from "react";
|
|
import "../app.css";
|
|
import { CandidateForm } from "../components/CandidateForm";
|
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
|
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
|
|
import { ItemForm } from "../components/ItemForm";
|
|
import { OnboardingWizard } from "../components/OnboardingWizard";
|
|
import { SlideOutPanel } from "../components/SlideOutPanel";
|
|
import { TotalsBar } from "../components/TotalsBar";
|
|
import { useDeleteCandidate } from "../hooks/useCandidates";
|
|
import { useOnboardingComplete } from "../hooks/useSettings";
|
|
import { useResolveThread, useThread } from "../hooks/useThreads";
|
|
import { useUIStore } from "../stores/uiStore";
|
|
|
|
export const Route = createRootRoute({
|
|
component: RootLayout,
|
|
});
|
|
|
|
function RootLayout() {
|
|
const navigate = useNavigate();
|
|
|
|
// Item panel state
|
|
const panelMode = useUIStore((s) => s.panelMode);
|
|
const editingItemId = useUIStore((s) => s.editingItemId);
|
|
const openAddPanel = useUIStore((s) => s.openAddPanel);
|
|
const closePanel = useUIStore((s) => s.closePanel);
|
|
|
|
// Candidate panel state
|
|
const candidatePanelMode = useUIStore((s) => s.candidatePanelMode);
|
|
const editingCandidateId = useUIStore((s) => s.editingCandidateId);
|
|
const closeCandidatePanel = useUIStore((s) => s.closeCandidatePanel);
|
|
|
|
// Candidate delete state
|
|
const confirmDeleteCandidateId = useUIStore(
|
|
(s) => s.confirmDeleteCandidateId,
|
|
);
|
|
const closeConfirmDeleteCandidate = useUIStore(
|
|
(s) => s.closeConfirmDeleteCandidate,
|
|
);
|
|
|
|
// Resolution dialog state
|
|
const resolveThreadId = useUIStore((s) => s.resolveThreadId);
|
|
const resolveCandidateId = useUIStore((s) => s.resolveCandidateId);
|
|
const closeResolveDialog = useUIStore((s) => s.closeResolveDialog);
|
|
|
|
// Onboarding
|
|
const { data: onboardingComplete, isLoading: onboardingLoading } =
|
|
useOnboardingComplete();
|
|
const [wizardDismissed, setWizardDismissed] = useState(false);
|
|
|
|
const showWizard =
|
|
!onboardingLoading && onboardingComplete !== "true" && !wizardDismissed;
|
|
|
|
const isItemPanelOpen = panelMode !== "closed";
|
|
const isCandidatePanelOpen = candidatePanelMode !== "closed";
|
|
|
|
// Route matching for contextual behavior
|
|
const matchRoute = useMatchRoute();
|
|
|
|
const threadMatch = matchRoute({
|
|
to: "/threads/$threadId",
|
|
fuzzy: true,
|
|
}) as { threadId?: string } | false;
|
|
const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null;
|
|
|
|
const isDashboard = !!matchRoute({ to: "/" });
|
|
const isCollection = !!matchRoute({ to: "/collection", fuzzy: true });
|
|
const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true });
|
|
|
|
// Determine TotalsBar props based on current route
|
|
const _totalsBarProps = isDashboard
|
|
? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link
|
|
: isSetupDetail
|
|
? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link
|
|
: { linkTo: "/" }; // All other pages: default stats + link to dashboard
|
|
|
|
// On dashboard, don't show the default global stats - pass empty stats
|
|
// On collection, let TotalsBar fetch its own global stats (default behavior)
|
|
const finalTotalsProps = isDashboard
|
|
? { stats: [] as Array<{ label: string; value: string }> }
|
|
: isCollection
|
|
? { linkTo: "/" }
|
|
: { linkTo: "/" };
|
|
|
|
// FAB visibility: only show on /collection route when gear tab is active
|
|
const collectionSearch = matchRoute({ to: "/collection" }) as
|
|
| { tab?: string }
|
|
| false;
|
|
const showFab =
|
|
isCollection &&
|
|
(!collectionSearch ||
|
|
(collectionSearch as Record<string, string>).tab !== "planning");
|
|
|
|
// Show a minimal loading state while checking onboarding status
|
|
if (onboardingLoading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<TotalsBar {...finalTotalsProps} />
|
|
<Outlet />
|
|
|
|
{/* Item Slide-out Panel */}
|
|
<SlideOutPanel
|
|
isOpen={isItemPanelOpen}
|
|
onClose={closePanel}
|
|
title={panelMode === "add" ? "Add Item" : "Edit Item"}
|
|
>
|
|
{panelMode === "add" && <ItemForm mode="add" />}
|
|
{panelMode === "edit" && (
|
|
<ItemForm mode="edit" itemId={editingItemId} />
|
|
)}
|
|
</SlideOutPanel>
|
|
|
|
{/* Candidate Slide-out Panel */}
|
|
{currentThreadId != null && (
|
|
<SlideOutPanel
|
|
isOpen={isCandidatePanelOpen}
|
|
onClose={closeCandidatePanel}
|
|
title={
|
|
candidatePanelMode === "add" ? "Add Candidate" : "Edit Candidate"
|
|
}
|
|
>
|
|
{candidatePanelMode === "add" && (
|
|
<CandidateForm mode="add" threadId={currentThreadId} />
|
|
)}
|
|
{candidatePanelMode === "edit" && (
|
|
<CandidateForm
|
|
mode="edit"
|
|
threadId={currentThreadId}
|
|
candidateId={editingCandidateId}
|
|
/>
|
|
)}
|
|
</SlideOutPanel>
|
|
)}
|
|
|
|
{/* Item Confirm Delete Dialog */}
|
|
<ConfirmDialog />
|
|
|
|
{/* External Link Confirmation Dialog */}
|
|
<ExternalLinkDialog />
|
|
|
|
{/* Candidate Delete Confirm Dialog */}
|
|
{confirmDeleteCandidateId != null && currentThreadId != null && (
|
|
<CandidateDeleteDialog
|
|
candidateId={confirmDeleteCandidateId}
|
|
threadId={currentThreadId}
|
|
onClose={closeConfirmDeleteCandidate}
|
|
/>
|
|
)}
|
|
|
|
{/* Resolution Confirm Dialog */}
|
|
{resolveThreadId != null && resolveCandidateId != null && (
|
|
<ResolveDialog
|
|
threadId={resolveThreadId}
|
|
candidateId={resolveCandidateId}
|
|
onClose={closeResolveDialog}
|
|
onResolved={() => {
|
|
closeResolveDialog();
|
|
navigate({ to: "/collection", search: { tab: "planning" } });
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Floating Add Button - only on collection gear tab */}
|
|
{showFab && (
|
|
<button
|
|
type="button"
|
|
onClick={openAddPanel}
|
|
className="fixed bottom-6 right-6 z-20 w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center"
|
|
title="Add new item"
|
|
>
|
|
<svg
|
|
className="w-6 h-6"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth={2}
|
|
d="M12 4v16m8-8H4"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{/* Onboarding Wizard */}
|
|
{showWizard && (
|
|
<OnboardingWizard onComplete={() => setWizardDismissed(true)} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CandidateDeleteDialog({
|
|
candidateId,
|
|
threadId,
|
|
onClose,
|
|
}: {
|
|
candidateId: number;
|
|
threadId: number;
|
|
onClose: () => void;
|
|
}) {
|
|
const deleteCandidate = useDeleteCandidate(threadId);
|
|
const { data: thread } = useThread(threadId);
|
|
const candidate = thread?.candidates.find((c) => c.id === candidateId);
|
|
const candidateName = candidate?.name ?? "this candidate";
|
|
|
|
function handleDelete() {
|
|
deleteCandidate.mutate(candidateId, {
|
|
onSuccess: () => onClose(),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-black/30"
|
|
onClick={onClose}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") onClose();
|
|
}}
|
|
/>
|
|
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
Delete Candidate
|
|
</h3>
|
|
<p className="text-sm text-gray-600 mb-6">
|
|
Are you sure you want to delete{" "}
|
|
<span className="font-medium">{candidateName}</span>? This action
|
|
cannot be undone.
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleDelete}
|
|
disabled={deleteCandidate.isPending}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
|
|
>
|
|
{deleteCandidate.isPending ? "Deleting..." : "Delete"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ResolveDialog({
|
|
threadId,
|
|
candidateId,
|
|
onClose,
|
|
onResolved,
|
|
}: {
|
|
threadId: number;
|
|
candidateId: number;
|
|
onClose: () => void;
|
|
onResolved: () => void;
|
|
}) {
|
|
const resolveThread = useResolveThread();
|
|
const { data: thread } = useThread(threadId);
|
|
const candidate = thread?.candidates.find((c) => c.id === candidateId);
|
|
const candidateName = candidate?.name ?? "this candidate";
|
|
|
|
function handleResolve() {
|
|
resolveThread.mutate(
|
|
{ threadId, candidateId },
|
|
{ onSuccess: () => onResolved() },
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div
|
|
className="absolute inset-0 bg-black/30"
|
|
onClick={onClose}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Escape") onClose();
|
|
}}
|
|
/>
|
|
<div className="relative bg-white rounded-xl shadow-lg p-6 max-w-sm mx-4 w-full">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
Pick Winner
|
|
</h3>
|
|
<p className="text-sm text-gray-600 mb-6">
|
|
Pick <span className="font-medium">{candidateName}</span> as the
|
|
winner? This will add it to your collection and archive the thread.
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={handleResolve}
|
|
disabled={resolveThread.isPending}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg transition-colors"
|
|
>
|
|
{resolveThread.isPending ? "Resolving..." : "Pick Winner"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|