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>
336 lines
9.8 KiB
TypeScript
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>
|
|
);
|
|
}
|