Files
GearBox/src/client/routes/__root.tsx
Jean-Luc Makiola 4c80e9aa3c
All checks were successful
CI / ci (push) Successful in 1m23s
CI / e2e (push) Has been skipped
CI / deploy (push) Successful in 15s
fix: allow unauthenticated access to /items/* with setup context
Items accessed via ?setup= or ?share= query params are now treated as
public routes, preventing the auth redirect to /login.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 20:34:13 +02:00

336 lines
9.8 KiB
TypeScript

import {
createRootRoute,
type ErrorComponentProps,
Outlet,
useLocation,
useMatchRoute,
useNavigate,
useRouter,
} from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Toaster } from "sonner";
import "../app.css";
import { AddToCollectionModal } from "../components/AddToCollectionModal";
import { AddToThreadModal } from "../components/AddToThreadModal";
import { AuthPromptModal } from "../components/AuthPromptModal";
import { BottomTabBar } from "../components/BottomTabBar";
import { CatalogSearchOverlay } from "../components/CatalogSearchOverlay";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { ExternalLinkDialog } from "../components/ExternalLinkDialog";
import { FabMenu } from "../components/FabMenu";
import { OnboardingFlow } from "../components/onboarding/OnboardingFlow";
import { TopNav } from "../components/TopNav";
import { useAuth } from "../hooks/useAuth";
import { useDeleteCandidate } from "../hooks/useCandidates";
import { useLanguage } from "../hooks/useLanguage";
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();
const { t } = useTranslation();
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="max-w-md mx-auto text-center px-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg
className="w-6 h-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h1 className="text-xl font-semibold text-gray-900 mb-2">
{t("errors.somethingWentWrong")}
</h1>
<p className="text-sm text-gray-500 mb-6">
{error instanceof Error ? error.message : t("errors.unexpectedError")}
</p>
<button
type="button"
onClick={() => {
reset();
router.invalidate();
}}
className="px-5 py-2.5 bg-gray-700 hover:bg-gray-800 text-white text-sm font-medium rounded-lg transition-colors"
>
{t("actions.tryAgain")}
</button>
</div>
</div>
);
}
function RootLayout() {
const navigate = useNavigate();
const location = useLocation();
const { data: auth, isLoading: authLoading } = useAuth();
const isAuthenticated = !!auth?.user;
const language = useLanguage();
const { i18n } = useTranslation();
// Sync i18n language with persisted setting
useEffect(() => {
if (language && i18n.language !== language) {
i18n.changeLanguage(language);
}
}, [language, i18n]);
// 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(isAuthenticated);
const [wizardDismissed, setWizardDismissed] = useState(false);
// Don't show onboarding wizard until user has created an account
const showWizard =
!onboardingLoading &&
onboardingComplete !== "true" &&
!wizardDismissed &&
isAuthenticated;
// 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;
// Allow public routes through without auth
const searchParams = new URLSearchParams(location.search);
const isPublicRoute =
location.pathname === "/" ||
location.pathname.startsWith("/users/") ||
location.pathname.startsWith("/global-items") ||
location.pathname === "/setups" ||
location.pathname.startsWith("/setups/") ||
location.pathname === "/login" ||
(location.pathname.startsWith("/items/") &&
(searchParams.has("setup") || searchParams.has("share")));
// FAB visibility: show on all authenticated, non-public routes
const isSetupsPage = !!matchRoute({ to: "/setups", fuzzy: true });
const showFab = isAuthenticated && !isPublicRoute;
if (!isAuthenticated && !isPublicRoute && !authLoading) {
navigate({ to: "/login" });
return null;
}
return (
<div className="min-h-screen bg-gray-50 pb-16 md:pb-0">
<TopNav />
<Outlet />
{/* 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 Action Button */}
{showFab && (
<div className="hidden md:block">
<FabMenu isSetupsPage={isSetupsPage} />
</div>
)}
{/* Bottom Tab Bar (mobile only) */}
<BottomTabBar />
{/* Catalog Search Overlay */}
<CatalogSearchOverlay />
{/* Add to Collection Modal */}
<AddToCollectionModal />
{/* Add to Thread Modal */}
<AddToThreadModal />
<Toaster position="bottom-right" richColors />
{/* Auth Prompt Modal */}
<AuthPromptModal />
{/* Onboarding Flow */}
{showWizard && (
<OnboardingFlow onComplete={() => setWizardDismissed(true)} />
)}
</div>
);
}
function CandidateDeleteDialog({
candidateId,
threadId,
onClose,
}: {
candidateId: number;
threadId: number;
onClose: () => void;
}) {
const { t } = useTranslation();
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">
{t("confirm.deleteCandidate")}
</h3>
<p className="text-sm text-gray-600 mb-6">
{t("confirm.deleteCandidateMessage", { name: candidateName })}
</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"
>
{t("actions.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
? t("actions.deleting")
: t("actions.delete")}
</button>
</div>
</div>
</div>
);
}
function ResolveDialog({
threadId,
candidateId,
onClose,
onResolved,
}: {
threadId: number;
candidateId: number;
onClose: () => void;
onResolved: () => void;
}) {
const { t } = useTranslation();
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">
{t("confirm.pickWinner")}
</h3>
<p className="text-sm text-gray-600 mb-6">
{t("confirm.pickWinnerMessage", { name: candidateName })}
</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"
>
{t("actions.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
? t("actions.saving")
: t("confirm.pickWinner")}
</button>
</div>
</div>
</div>
);
}