import {
createRootRoute,
type ErrorComponentProps,
Outlet,
useLocation,
useMatchRoute,
useNavigate,
useRouter,
} 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 { useAuth } from "../hooks/useAuth";
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,
errorComponent: RootErrorBoundary,
});
function RootErrorBoundary({ error, reset }: ErrorComponentProps) {
const router = useRouter();
return (
Something went wrong
{error instanceof Error
? error.message
: "An unexpected error occurred"}
);
}
function RootLayout() {
const navigate = useNavigate();
const location = useLocation();
const { data: auth, isLoading: authLoading } = useAuth();
const isAuthenticated = !!auth?.user;
// 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 — only check when authenticated (endpoint requires auth)
const { data: onboardingComplete, isLoading: onboardingLoading } =
useOnboardingComplete();
const [wizardDismissed, setWizardDismissed] = useState(false);
// Don't show onboarding wizard until user has created an account
const showWizard =
!onboardingLoading &&
onboardingComplete !== "true" &&
!wizardDismissed &&
isAuthenticated;
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).tab ||
(collectionSearch as Record).tab === "gear");
// Show loading while checking auth
if (authLoading) {
return (
);
}
// Redirect unauthenticated users to login (server-side OIDC route)
// Allow public routes through without auth
const isPublicRoute =
location.pathname.startsWith("/users/") || location.pathname === "/login";
if (!isAuthenticated && !isPublicRoute) {
window.location.href = "/login";
return (
);
}
// Show loading while checking onboarding status
if (onboardingLoading) {
return (
);
}
return (
{/* Item Slide-out Panel */}
{panelMode === "add" && }
{panelMode === "edit" && (
)}
{/* Candidate Slide-out Panel */}
{currentThreadId != null && (
{candidatePanelMode === "add" && (
)}
{candidatePanelMode === "edit" && (
)}
)}
{/* Item Confirm Delete Dialog */}
{/* External Link Confirmation Dialog */}
{/* Candidate Delete Confirm Dialog */}
{confirmDeleteCandidateId != null && currentThreadId != null && (
)}
{/* Resolution Confirm Dialog */}
{resolveThreadId != null && resolveCandidateId != null && (
{
closeResolveDialog();
navigate({ to: "/collection", search: { tab: "planning" } });
}}
/>
)}
{/* Floating Add Button - only on collection gear tab */}
{showFab && isAuthenticated && (
)}
{/* Onboarding Wizard */}
{showWizard && (
setWizardDismissed(true)} />
)}
);
}
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 (
{
if (e.key === "Escape") onClose();
}}
/>
Delete Candidate
Are you sure you want to delete{" "}
{candidateName}? This action
cannot be undone.
);
}
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 (
{
if (e.key === "Escape") onClose();
}}
/>
Pick Winner
Pick {candidateName} as the
winner? This will add it to your collection and archive the thread.
);
}