First Commit

This commit is contained in:
cesnimda
2026-03-21 11:55:27 +01:00
commit 2e8a29b4d0
1757 changed files with 166084 additions and 0 deletions
+397
View File
@@ -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>
);
}