468 lines
24 KiB
TypeScript
468 lines
24 KiB
TypeScript
import React, { Suspense, createContext, lazy, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
|
|
import { Box, Button, IconButton, Typography } from "@mui/material";
|
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
|
|
import DashboardIcon from "@mui/icons-material/Dashboard";
|
|
import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
|
import ViewKanbanIcon from "@mui/icons-material/ViewKanban";
|
|
import BusinessIcon from "@mui/icons-material/Business";
|
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
|
import SettingsIcon from "@mui/icons-material/Settings";
|
|
import AlarmIcon from "@mui/icons-material/Alarm";
|
|
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
|
import ShieldIcon from "@mui/icons-material/Shield";
|
|
import SearchIcon from "@mui/icons-material/Search";
|
|
import MemoryIcon from "@mui/icons-material/Memory";
|
|
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
|
|
|
import { Navigate, Route, Routes, useLocation, useNavigate, useParams, createBrowserRouter, RouterProvider } from "react-router-dom";
|
|
|
|
import { ToastProvider } from "./toast";
|
|
import { ConfirmProvider } from "./confirm";
|
|
import { PromptProvider } from "./prompt";
|
|
|
|
import JobTable from "./components/JobTable";
|
|
import NotificationsPopover from "./components/NotificationsPopover";
|
|
import type { JobTableColumns } from "./components/JobTable";
|
|
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
|
|
import LoginPage from "./views/LoginPage";
|
|
import LandingPage from "./views/LandingPage";
|
|
import ForgotPasswordPage from "./views/ForgotPasswordPage";
|
|
import ResetPasswordPage from "./views/ResetPasswordPage";
|
|
import VerifyEmailPage from "./views/VerifyEmailPage";
|
|
import MicrosoftLegacyRelinkPage from "./views/MicrosoftLegacyRelinkPage";
|
|
import RouteErrorPage from "./views/RouteErrorPage";
|
|
import { api } from "./api";
|
|
import { resolveCaptureUrl } from "./captureUrl";
|
|
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
|
import { AccountPlanProvider } from "./accountPlan";
|
|
import AppShell, { NavItem } from "./layout/AppShell";
|
|
import { getThemeModePref, setThemeModePref, subscribeToThemePreferenceChanges, ThemeModePref } from "./themePrefs";
|
|
import JobTrackerThemeProvider from "./ThemeProvider";
|
|
|
|
const AddJobModal = lazy(() => import("./components/AddJobModal"));
|
|
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
|
|
const DashboardView = lazy(() => import("./components/DashboardView"));
|
|
const JobDiscoveryPage = lazy(() => import("./views/JobDiscoveryPage"));
|
|
const CompaniesTable = lazy(() => import("./components/CompaniesTable"));
|
|
const SettingsView = lazy(() => import("./components/SettingsView"));
|
|
const RemindersView = lazy(() => import("./components/RemindersView"));
|
|
const QuickCommandDialog = lazy(() => import("./components/QuickCommandDialog"));
|
|
const ProfilePage = lazy(() => import("./views/ProfilePage"));
|
|
const CareerWorkspacePage = lazy(() => import("./views/CareerWorkspacePage"));
|
|
const CvBuilderPage = lazy(() => import("./views/CvBuilderPage"));
|
|
const ApplicationWorkspacePage = lazy(() => import("./views/ApplicationWorkspacePage"));
|
|
const CvBuilderEditor = lazy(() => import("./views/CvBuilderEditor"));
|
|
const PublicCvPage = lazy(() => import("./views/PublicCvPage"));
|
|
const ConnectedAccountsPage = lazy(() => import("./views/ConnectedAccountsPage"));
|
|
const AdminAuditPage = lazy(() => import("./views/AdminAuditPage"));
|
|
const AdminUsersPage = lazy(() => import("./views/AdminUsersPage"));
|
|
const AdminSystemPage = lazy(() => import("./views/AdminSystemPage"));
|
|
const CorrespondenceInboxPage = lazy(() => import("./views/CorrespondenceInboxPage"));
|
|
const OperationsPage = lazy(() => import("./views/OperationsPage"));
|
|
const NotFoundPage = lazy(() => import("./views/NotFoundPage"));
|
|
|
|
type AuthConfig = { requireAuth: boolean };
|
|
type ThemeControls = { themeMode: ThemeModePref; onThemeModeChange: (value: ThemeModePref) => void };
|
|
const ThemeControlsContext = createContext<ThemeControls | null>(null);
|
|
type MeResponse = {
|
|
provider?: "local" | "google" | "external";
|
|
id?: string;
|
|
email?: string;
|
|
userName?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
displayName?: string;
|
|
avatarImageDataUrl?: string;
|
|
roles?: string[];
|
|
plan?: "free" | "pro";
|
|
entitlements?: { ai?: boolean; proThemes?: boolean };
|
|
appVersion?: string;
|
|
appCommitSha?: string;
|
|
};
|
|
|
|
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
|
if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")];
|
|
if (path.startsWith("/discover")) return [t("home"), "Discover jobs"];
|
|
if (/^\/jobs\/\d+/.test(path)) return [t("home"), t("jobApplications"), "Job details"];
|
|
if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")];
|
|
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
|
|
if (path.startsWith("/operations")) return [t("home"), "Operations"];
|
|
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
|
|
if (path.startsWith("/companies")) return [t("home"), t("companies")];
|
|
if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"];
|
|
if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"];
|
|
if (path.startsWith("/trash")) return [t("home"), t("trash")];
|
|
if (path.startsWith("/settings")) return [t("home"), t("settings")];
|
|
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
|
|
if (path.startsWith("/career/builder")) return [t("home"), "Career Workspace", "CV Builder"];
|
|
if (path.startsWith("/career")) return [t("home"), "Career Workspace"];
|
|
if (path.startsWith("/settings/connected-accounts")) return [t("home"), t("settings"), "Connected accounts"];
|
|
if (path.startsWith("/admin/audit")) return [t("home"), t("admin"), t("auditLog")];
|
|
if (path.startsWith("/admin/users")) return [t("home"), t("admin"), t("users")];
|
|
if (path.startsWith("/admin/system")) return [t("home"), t("admin"), t("system")];
|
|
return [t("home")];
|
|
}
|
|
|
|
function titleFor(path: string, t: (k: any) => string): string {
|
|
if (path === "/dashboard") return t("dashboard");
|
|
if (path.startsWith("/reminders")) return t("reminders");
|
|
if (path.startsWith("/operations")) return "Operations";
|
|
if (path.startsWith("/discover")) return "Discover jobs";
|
|
if (/^\/jobs\/\d+/.test(path)) return "Job details";
|
|
if (path.startsWith("/jobs")) return t("jobApplications");
|
|
if (path.startsWith("/kanban")) return t("kanbanBoard");
|
|
if (path.startsWith("/companies")) return t("companies");
|
|
if (path.startsWith("/correspondence/review")) return "Gmail review queue";
|
|
if (path.startsWith("/correspondence")) return "Correspondence inbox";
|
|
if (path.startsWith("/trash")) return t("trash");
|
|
if (path.startsWith("/settings")) return t("settings");
|
|
if (path.startsWith("/profile")) return t("profile");
|
|
if (path.startsWith("/career/builder")) return "CV Builder";
|
|
if (path.startsWith("/career")) return "Career Workspace";
|
|
if (path.startsWith("/settings/connected-accounts")) return "Connected accounts";
|
|
if (path.startsWith("/admin/audit")) return t("auditLog");
|
|
if (path.startsWith("/admin/users")) return t("users");
|
|
if (path.startsWith("/admin/system")) return t("systemStatus");
|
|
return t("appTitle");
|
|
}
|
|
|
|
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
|
|
if (path === "/dashboard") return t("dashboardPageSubtitle");
|
|
if (path.startsWith("/discover")) return "Search official job-board feeds and save opportunities to your tracker.";
|
|
if (/^\/jobs\/\d+/.test(path)) return "Manage this application, its documents, timeline, and correspondence.";
|
|
if (path.startsWith("/jobs")) return t("jobsPageSubtitle");
|
|
if (path.startsWith("/kanban")) return t("kanbanPageSubtitle");
|
|
if (path.startsWith("/reminders")) return t("remindersPageSubtitle");
|
|
if (path.startsWith("/correspondence/review")) return t("gmailReviewPageSubtitle");
|
|
if (path.startsWith("/correspondence")) return t("correspondencePageSubtitle");
|
|
return undefined;
|
|
}
|
|
|
|
function PageLoader() {
|
|
return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
|
}
|
|
|
|
function LegacyApplicationRedirect() {
|
|
const { id } = useParams();
|
|
const location = useLocation();
|
|
return <Navigate to={`/jobs/${id ?? ""}${location.search}`} replace state={location.state} />;
|
|
}
|
|
|
|
function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMode, onThemeModeChange }: { jobPageSize: 15 | 20 | 25; setJobPageSize: (n: 15 | 20 | 25) => void; jobColumns: JobTableColumns; setJobColumns: (c: JobTableColumns) => void; themeMode: ThemeModePref; onThemeModeChange: (v: ThemeModePref) => void; }) {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { t } = useI18n();
|
|
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
|
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [captureUrl, setCaptureUrl] = useState<string | undefined>(undefined);
|
|
const [quickOpen, setQuickOpen] = useState(false);
|
|
const [refreshToken, setRefreshToken] = useState(0);
|
|
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
|
|
const [authResolved, setAuthResolved] = useState(false);
|
|
const [isAdmin, setIsAdmin] = useState(false);
|
|
const [me, setMe] = useState<MeResponse | null>(null);
|
|
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
|
const [reminderCount, setReminderCount] = useState(0);
|
|
const [notificationCount, setNotificationCount] = useState(0);
|
|
const [notificationAnchor, setNotificationAnchor] = useState<HTMLElement | null>(null);
|
|
|
|
const path = location.pathname;
|
|
const isJobs = path.startsWith("/jobs");
|
|
const shortcutHint = useMemo(() => (
|
|
typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform) ? "⌘K" : "Ctrl+K"
|
|
), []);
|
|
|
|
useEffect(() => {
|
|
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
|
|
}, []);
|
|
|
|
// Quick-capture target: bookmarklet (/?add=<url>) or PWA share (url in `add`, or a link
|
|
// embedded in shared `addtext`). Opens Add Job pre-filled and strips the params.
|
|
useEffect(() => {
|
|
const url = resolveCaptureUrl(location.search);
|
|
if (!url) return;
|
|
setCaptureUrl(url);
|
|
setAddOpen(true);
|
|
const params = new URLSearchParams(location.search);
|
|
params.delete("add");
|
|
params.delete("addtext");
|
|
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true });
|
|
}, [location.search, location.pathname, navigate]);
|
|
useEffect(() => {
|
|
let active = true;
|
|
api.get<MeResponse>("/auth/me")
|
|
.then((r) => {
|
|
if (!active) return;
|
|
setMe(r.data);
|
|
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
|
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
|
|
})
|
|
.catch(() => {
|
|
if (!active) return;
|
|
setMe(null);
|
|
setIsAdmin(false);
|
|
clearAuthClientState(false);
|
|
})
|
|
.finally(() => {
|
|
if (active) setAuthResolved(true);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
const load = () => {
|
|
api.get<any[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } }).then((r) => setReminderCount(Array.isArray(r.data) ? r.data.length : 0)).catch(() => setReminderCount(0));
|
|
};
|
|
load();
|
|
const id = window.setInterval(load, 60000);
|
|
return () => window.clearInterval(id);
|
|
}, []);
|
|
useEffect(() => {
|
|
const load = () => {
|
|
api.get<{ count: number }>("/notifications/unread-count").then((r) => setNotificationCount(Math.max(0, Number(r.data?.count) || 0))).catch(() => setNotificationCount(0));
|
|
};
|
|
load();
|
|
const id = window.setInterval(load, 60000);
|
|
window.addEventListener("notifications-changed", load);
|
|
return () => {
|
|
window.clearInterval(id);
|
|
window.removeEventListener("notifications-changed", load);
|
|
};
|
|
}, []);
|
|
useEffect(() => {
|
|
const onAuthChanged = () => {
|
|
setAuthResolved(false);
|
|
api.get<MeResponse>("/auth/me")
|
|
.then((r) => {
|
|
setMe(r.data);
|
|
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
|
setAuthUserKey(r.data?.id || r.data?.email || r.data?.userName || null, false);
|
|
})
|
|
.catch(() => {
|
|
setMe(null);
|
|
setIsAdmin(false);
|
|
clearAuthClientState(false);
|
|
})
|
|
.finally(() => setAuthResolved(true));
|
|
};
|
|
|
|
window.addEventListener("auth-changed", onAuthChanged);
|
|
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") {
|
|
e.preventDefault();
|
|
setQuickOpen(true);
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, []);
|
|
|
|
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
|
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
|
|
|
|
const pageTitle = titleFor(path, t);
|
|
const pageSubtitle = subtitleFor(path, t);
|
|
const breadcrumbs = breadcrumbsFor(path, t);
|
|
const setAndPersistPageSize = (n: 15 | 20 | 25) => { setJobPageSize(n); window.localStorage.setItem("jobPageSize", String(n)); };
|
|
const setAndPersistColumns = (next: JobTableColumns) => { setJobColumns(next); window.localStorage.setItem("jobColumns", JSON.stringify(next)); };
|
|
const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" ");
|
|
|
|
const nav: NavItem[] = [
|
|
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: t("manage") },
|
|
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: t("manage") },
|
|
{ to: "/discover", label: "Discover jobs", icon: <SearchIcon fontSize="small" />, section: t("manage") },
|
|
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: reminderCount, section: t("manage") },
|
|
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
|
|
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
|
|
{ to: "/career", label: "Career Workspace", icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
|
|
{ to: "/career/builder", label: "CV Builder", icon: <DescriptionOutlinedIcon fontSize="small" />, section: t("manage") },
|
|
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
|
|
];
|
|
|
|
const navBottom: NavItem[] = [
|
|
{ to: "/admin/audit", label: t("auditLog"), icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
|
{ to: "/admin/users", label: t("users"), icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
|
{ to: "/admin/system", label: t("system"), icon: <MemoryIcon fontSize="small" />, hidden: !isAdmin, section: t("admin") },
|
|
{ to: "/profile", label: t("profile"), icon: <AccountCircleIcon fontSize="small" />, section: t("account") },
|
|
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: t("account") },
|
|
];
|
|
|
|
const rightActions = (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 1,
|
|
flexWrap: "nowrap",
|
|
justifyContent: "flex-end",
|
|
width: { xs: "100%", sm: "auto" },
|
|
flex: { xs: 1, sm: "0 0 auto" },
|
|
}}
|
|
>
|
|
{compactHeaderActions ? (
|
|
<IconButton
|
|
color="secondary"
|
|
size="small"
|
|
title={`${t("quickSearch")} (${shortcutHint})`}
|
|
onClick={() => setQuickOpen(true)}
|
|
sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2.5, width: 42, height: 42, flex: "0 0 auto" }}
|
|
>
|
|
<SearchIcon fontSize="small" />
|
|
</IconButton>
|
|
) : (
|
|
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => setQuickOpen(true)} sx={{ gap: 0.5 }}>
|
|
{t("quickSearch")}
|
|
<Box component="span" sx={{ ml: 0.75, px: 0.75, py: 0.125, borderRadius: 1, border: "1px solid", borderColor: "divider", fontSize: 11, fontWeight: 700, color: "text.secondary", lineHeight: 1.6 }}>
|
|
{shortcutHint}
|
|
</Box>
|
|
</Button>
|
|
)}
|
|
{isJobs ? (
|
|
<Button variant="contained" onClick={() => setAddOpen(true)} sx={{ flex: { xs: 1, sm: "0 0 auto" }, minHeight: 42 }}>
|
|
{t("addJob")}
|
|
</Button>
|
|
) : null}
|
|
</Box>
|
|
);
|
|
|
|
return (
|
|
<AccountPlanProvider value={{
|
|
plan: me?.plan === "pro" ? "pro" : "free",
|
|
canUseAi: Boolean(me?.entitlements?.ai),
|
|
canUseProThemes: Boolean(me?.entitlements?.proThemes),
|
|
}}>
|
|
<AppShell
|
|
pageTitle={pageTitle}
|
|
pageSubtitle={pageSubtitle}
|
|
breadcrumbs={breadcrumbs}
|
|
pathname={path}
|
|
nav={nav}
|
|
navBottom={navBottom}
|
|
drawerOpen={mobileDrawerOpen}
|
|
onToggleDrawer={setMobileDrawerOpen}
|
|
onNavigate={(to) => { setMobileDrawerOpen(false); navigate(to); }}
|
|
user={{ email: me?.email, userName: me?.userName, displayName: me?.displayName || fullName || undefined, avatarImageDataUrl: me?.avatarImageDataUrl, roleLabel: isAdmin ? t("superAdmin") : t("user") }}
|
|
buildMetadata={isAdmin && me?.appVersion ? { version: me.appVersion, commitSha: me.appCommitSha } : undefined}
|
|
notificationsCount={notificationCount}
|
|
onOpenNotifications={(anchor) => setNotificationAnchor(anchor)}
|
|
onOpenSettings={() => navigate("/settings")}
|
|
onOpenProfile={() => navigate("/profile")}
|
|
onSignOut={() => { void api.post("/auth/logout").catch(() => undefined).finally(() => { clearAuthClientState(); navigate("/login"); }); }}
|
|
rightActions={rightActions}
|
|
>
|
|
<Suspense fallback={<PageLoader />}>
|
|
<Routes>
|
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
|
<Route path="/dashboard" element={<DashboardView />} />
|
|
<Route path="/discover" element={<JobDiscoveryPage />} />
|
|
<Route path="/jobs" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="jobs" />} />
|
|
<Route path="/jobs/:id" element={<ApplicationWorkspacePage />} />
|
|
<Route path="/reminders" element={<RemindersView />} />
|
|
<Route path="/operations" element={<OperationsPage />} />
|
|
<Route path="/kanban" element={<KanbanBoard />} />
|
|
<Route path="/companies" element={<CompaniesTable />} />
|
|
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
|
<Route path="/correspondence/review" element={<Navigate to="/correspondence?view=review" replace />} />
|
|
<Route path="/profile" element={<ProfilePage />} />
|
|
<Route path="/applications/:id" element={<LegacyApplicationRedirect />} />
|
|
<Route path="/career" element={<CareerWorkspacePage />} />
|
|
<Route path="/career/builder" element={<CvBuilderPage />} />
|
|
<Route path="/career/builder/:id" element={<CvBuilderEditor />} />
|
|
<Route path="/admin/audit" element={<AdminAuditPage />} />
|
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
|
<Route path="/admin/system" element={<AdminSystemPage />} />
|
|
<Route path="/trash" element={<JobTable refreshToken={refreshToken} pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} mode="trash" />} />
|
|
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />} />
|
|
<Route path="/settings/connected-accounts" element={<ConnectedAccountsPage />} />
|
|
<Route path="*" element={<NotFoundPage />} />
|
|
</Routes>
|
|
</Suspense>
|
|
</AppShell>
|
|
|
|
<NotificationsPopover
|
|
anchorEl={notificationAnchor}
|
|
onClose={() => setNotificationAnchor(null)}
|
|
onNavigate={(to) => navigate(to)}
|
|
onChanged={() => window.dispatchEvent(new Event("notifications-changed"))}
|
|
/>
|
|
|
|
<Suspense fallback={null}>
|
|
<AddJobModal open={addOpen} initialUrl={captureUrl} onClose={() => { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
|
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
|
|
</Suspense>
|
|
</AccountPlanProvider>
|
|
);
|
|
}
|
|
|
|
function ThemeAwareShell(props: Omit<React.ComponentProps<typeof Shell>, "themeMode" | "onThemeModeChange">) {
|
|
const controls = useContext(ThemeControlsContext);
|
|
if (!controls) throw new Error("Theme controls are unavailable.");
|
|
return <Shell {...props} {...controls} />;
|
|
}
|
|
|
|
export default function App() {
|
|
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
|
|
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
|
|
|
|
useEffect(() => {
|
|
const sync = () => { setThemeMode(getThemeModePref()); };
|
|
return subscribeToThemePreferenceChanges(sync);
|
|
}, []);
|
|
|
|
const onThemeModeChange = useCallback((v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); }, []);
|
|
const themeControls = useMemo(() => ({ themeMode, onThemeModeChange }), [onThemeModeChange, themeMode]);
|
|
|
|
const [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
|
|
const raw = window.localStorage.getItem("jobPageSize");
|
|
const n = raw ? Number(raw) : 15;
|
|
return (n === 20 || n === 25 ? n : 15) as 15 | 20 | 25;
|
|
});
|
|
const [jobColumns, setJobColumns] = useState<JobTableColumns>(() => {
|
|
const raw = window.localStorage.getItem("jobColumns");
|
|
if (!raw) return { status: true, dateApplied: true, daysSince: true, jobUrl: false };
|
|
try {
|
|
const p = JSON.parse(raw) as Partial<JobTableColumns>;
|
|
return { status: p.status ?? true, dateApplied: p.dateApplied ?? true, daysSince: p.daysSince ?? true, jobUrl: p.jobUrl ?? false };
|
|
} catch {
|
|
return { status: true, dateApplied: true, daysSince: true, jobUrl: false };
|
|
}
|
|
});
|
|
|
|
const router = useMemo(() => createBrowserRouter([
|
|
{ path: "/", element: <LandingPage />, errorElement: <RouteErrorPage /> },
|
|
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
|
|
{ path: "/register", element: <LoginPage initialMode="register" />, errorElement: <RouteErrorPage /> },
|
|
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
|
|
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
|
|
{ path: "/verify-email", element: <VerifyEmailPage />, errorElement: <RouteErrorPage /> },
|
|
{ path: "/confirm-email-change", element: <VerifyEmailPage emailChange />, errorElement: <RouteErrorPage /> },
|
|
{ path: "/microsoft-legacy-relink", element: <MicrosoftLegacyRelinkPage />, errorElement: <RouteErrorPage /> },
|
|
{ path: "/cv/:slug", element: <PublicCvPage />, errorElement: <RouteErrorPage /> },
|
|
{ path: "/*", element: <ThemeAwareShell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} />, errorElement: <RouteErrorPage /> },
|
|
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize]);
|
|
|
|
return (
|
|
<ToastProvider>
|
|
<ConfirmProvider>
|
|
<PromptProvider>
|
|
<JobTrackerThemeProvider preference={themeMode} systemPrefersDark={systemPrefersDark}>
|
|
<I18nProvider>
|
|
<ThemeControlsContext.Provider value={themeControls}>
|
|
<RouterProvider router={router} />
|
|
</ThemeControlsContext.Provider>
|
|
</I18nProvider>
|
|
</JobTrackerThemeProvider>
|
|
</PromptProvider>
|
|
</ConfirmProvider>
|
|
</ToastProvider>
|
|
);
|
|
}
|