First Commit
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Box, Button, CssBaseline, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
|
||||
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
|
||||
import { CssVarsProvider } from "@mui/material/styles";
|
||||
|
||||
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 { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
|
||||
import { getTheme } from "./theme";
|
||||
import { ToastProvider } from "./toast";
|
||||
|
||||
import JobTable, { JobTableColumns } from "./components/JobTable";
|
||||
import AddJobModal from "./components/AddJobModal";
|
||||
import KanbanBoard from "./components/KanbanBoard";
|
||||
import DashboardView from "./components/DashboardView";
|
||||
import CompaniesTable from "./components/CompaniesTable";
|
||||
import SettingsView from "./components/SettingsView";
|
||||
import RemindersView from "./components/RemindersView";
|
||||
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import ProfilePage from "./pages/ProfilePage";
|
||||
import AdminAuditPage from "./pages/AdminAuditPage";
|
||||
import AdminUsersPage from "./pages/AdminUsersPage";
|
||||
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
||||
import { api } from "./api";
|
||||
import { clearAuthToken, getAuthToken } from "./auth";
|
||||
import AppShell, { NavItem } from "./layout/AppShell";
|
||||
import {
|
||||
clearAccentColor,
|
||||
getAccentColor,
|
||||
getThemeModePref,
|
||||
setAccentColor,
|
||||
setThemeModePref,
|
||||
ThemeModePref,
|
||||
} from "./themePrefs";
|
||||
|
||||
type AuthConfig = { requireAuth: boolean };
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "external";
|
||||
id?: string;
|
||||
email?: string;
|
||||
userName?: string;
|
||||
roles?: string[];
|
||||
};
|
||||
|
||||
function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
||||
if (path.startsWith("/dashboard")) return ["Home", "Analytics", "Overview"];
|
||||
if (path.startsWith("/jobs")) return ["Home", t("jobApplications")];
|
||||
if (path.startsWith("/reminders")) return ["Home", t("reminders")];
|
||||
if (path.startsWith("/kanban")) return ["Home", t("kanbanBoard")];
|
||||
if (path.startsWith("/companies")) return ["Home", t("companies")];
|
||||
if (path.startsWith("/trash")) return ["Home", t("trash")];
|
||||
if (path.startsWith("/settings")) return ["Home", t("settings")];
|
||||
if (path.startsWith("/profile")) return ["Home", "Account", "Profile"];
|
||||
if (path.startsWith("/admin/audit")) return ["Home", "Admin", "Audit"];
|
||||
if (path.startsWith("/admin/users")) return ["Home", "Admin", "Users"];
|
||||
return ["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("/jobs")) return t("jobApplications");
|
||||
if (path.startsWith("/kanban")) return t("kanbanBoard");
|
||||
if (path.startsWith("/companies")) return t("companies");
|
||||
if (path.startsWith("/trash")) return t("trash");
|
||||
if (path.startsWith("/settings")) return t("settings");
|
||||
if (path.startsWith("/profile")) return "Profile";
|
||||
if (path.startsWith("/admin/audit")) return "Audit log"; if (path.startsWith("/admin/users")) return "Users";
|
||||
return t("appTitle");
|
||||
}
|
||||
|
||||
function Shell({
|
||||
jobPageSize,
|
||||
setJobPageSize,
|
||||
jobColumns,
|
||||
setJobColumns,
|
||||
themeMode,
|
||||
onThemeModeChange,
|
||||
accentColor,
|
||||
onAccentColorChange,
|
||||
onResetAccentColor,
|
||||
}: {
|
||||
jobPageSize: 15 | 20 | 25;
|
||||
setJobPageSize: (n: 15 | 20 | 25) => void;
|
||||
jobColumns: JobTableColumns;
|
||||
setJobColumns: (c: JobTableColumns) => void;
|
||||
themeMode: ThemeModePref;
|
||||
onThemeModeChange: (v: ThemeModePref) => void;
|
||||
accentColor: string;
|
||||
onAccentColorChange: (v: string) => void;
|
||||
onResetAccentColor: () => void;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false);
|
||||
const [notifCount, setNotifCount] = useState(0);
|
||||
|
||||
const path = location.pathname;
|
||||
const isJobs = path.startsWith("/jobs");
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<AuthConfig>("/auth/config")
|
||||
.then((r) => setRequireAuth(Boolean(r.data?.requireAuth)))
|
||||
.catch(() => setRequireAuth(false));
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<MeResponse>("/auth/me")
|
||||
.then((r) => {
|
||||
setMe(r.data);
|
||||
setIsAdmin(Boolean(r.data?.roles?.includes("Admin")));
|
||||
})
|
||||
.catch(() => {
|
||||
setMe(null);
|
||||
setIsAdmin(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api
|
||||
.get<any[]>("/jobapplications/reminders", { params: { upcomingDays: 14 } })
|
||||
.then((r) => setNotifCount(Array.isArray(r.data) ? r.data.length : 0))
|
||||
.catch(() => setNotifCount(0));
|
||||
};
|
||||
|
||||
load();
|
||||
const id = window.setInterval(load, 60000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const token = getAuthToken();
|
||||
if (requireAuth === null) {
|
||||
return (
|
||||
<Box sx={{ p: 4 }}>
|
||||
<Typography variant="h6">Loading...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAuth && !token) {
|
||||
return <Navigate to="/login" replace state={{ from: path }} />;
|
||||
}
|
||||
|
||||
const pageTitle = titleFor(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 nav: NavItem[] = [
|
||||
{ to: "/dashboard", label: t("dashboard"), icon: <DashboardIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/jobs", label: t("jobApplications"), icon: <WorkOutlineIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: "Manage" },
|
||||
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: "Manage" },
|
||||
];
|
||||
|
||||
const navBottom: NavItem[] = [
|
||||
{ to: "/admin/audit", label: "Audit log", icon: <ShieldIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" }, { to: "/admin/users", label: "Users", icon: <AccountCircleIcon fontSize="small" />, hidden: !isAdmin, section: "Admin" },
|
||||
{ to: "/profile", label: "Profile", icon: <AccountCircleIcon fontSize="small" />, section: "Account" },
|
||||
{ to: "/settings", label: t("settings"), icon: <SettingsIcon fontSize="small" />, section: "Account" },
|
||||
];
|
||||
|
||||
const rightActions = (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<ToggleButtonGroup size="small" exclusive value={language} onChange={(_, v) => v && setLanguage(v)}>
|
||||
<ToggleButton value="en">EN</ToggleButton>
|
||||
<ToggleButton value="no">NO</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{isJobs ? (
|
||||
<Button variant="contained" onClick={() => setAddOpen(true)}>
|
||||
{t("addJob")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppShell
|
||||
pageTitle={pageTitle}
|
||||
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,
|
||||
roleLabel: isAdmin ? "Super Admin" : "User",
|
||||
}}
|
||||
notificationsCount={notifCount} onOpenNotifications={() => navigate("/reminders")} onOpenSettings={() => navigate("/settings")} onOpenProfile={() => navigate("/profile")} onSignOut={() => { clearAuthToken(); navigate("/login"); }} rightActions={rightActions}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardView />} />
|
||||
<Route
|
||||
path="/jobs"
|
||||
element={
|
||||
<JobTable
|
||||
refreshToken={refreshToken}
|
||||
pageSize={jobPageSize}
|
||||
onPageSizeChange={setAndPersistPageSize}
|
||||
columns={jobColumns}
|
||||
onColumnsChange={setAndPersistColumns}
|
||||
mode="jobs"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/reminders" element={<RemindersView />} />
|
||||
<Route path="/kanban" element={<KanbanBoard />} />
|
||||
<Route path="/companies" element={<CompaniesTable />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/admin/audit" element={<AdminAuditPage />} /> <Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
<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}
|
||||
accentColor={accentColor}
|
||||
onAccentColorChange={onAccentColorChange}
|
||||
onResetAccentColor={onResetAccentColor}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/jobs" replace />} />
|
||||
</Routes>
|
||||
</AppShell>
|
||||
|
||||
<AddJobModal
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onCreated={() => {
|
||||
setRefreshToken((t) => t + 1);
|
||||
setAddOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
|
||||
|
||||
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
|
||||
const [accentColor, setAccentColorState] = useState<string>(() => getAccentColor());
|
||||
|
||||
const effectiveMode: "light" | "dark" =
|
||||
themeMode === "light" ? "light" : themeMode === "dark" ? "dark" : systemPrefersDark ? "dark" : "light";
|
||||
|
||||
const theme = useMemo(() => getTheme(effectiveMode, accentColor), [effectiveMode, accentColor]);
|
||||
useEffect(() => {
|
||||
const sync = () => {
|
||||
setThemeMode(getThemeModePref());
|
||||
setAccentColorState(getAccentColor());
|
||||
};
|
||||
|
||||
const onAuthChanged = () => sync();
|
||||
window.addEventListener("auth-changed", onAuthChanged);
|
||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||
}, []);
|
||||
|
||||
const onThemeModeChange = (v: ThemeModePref) => {
|
||||
setThemeModePref(v);
|
||||
setThemeMode(v);
|
||||
};
|
||||
|
||||
const onAccentColorChange = (v: string) => {
|
||||
setAccentColor(v);
|
||||
setAccentColorState(getAccentColor());
|
||||
};
|
||||
|
||||
const onResetAccentColor = () => {
|
||||
clearAccentColor();
|
||||
setAccentColorState(getAccentColor());
|
||||
};
|
||||
|
||||
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: "/login", element: <LoginPage /> },
|
||||
{ path: "/reset-password", element: <ResetPasswordPage /> },
|
||||
{
|
||||
path: "/*",
|
||||
element: (
|
||||
<Shell
|
||||
jobPageSize={jobPageSize}
|
||||
setJobPageSize={setJobPageSize}
|
||||
jobColumns={jobColumns}
|
||||
setJobColumns={setJobColumns}
|
||||
themeMode={themeMode}
|
||||
onThemeModeChange={onThemeModeChange}
|
||||
accentColor={accentColor}
|
||||
onAccentColorChange={onAccentColorChange}
|
||||
onResetAccentColor={onResetAccentColor}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
{
|
||||
future: {
|
||||
v7_relativeSplatPath: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
[jobColumns, jobPageSize, themeMode, accentColor],
|
||||
);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
||||
<CssBaseline enableColorScheme />
|
||||
<I18nProvider>
|
||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||
</I18nProvider>
|
||||
</CssVarsProvider>
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user