Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5d82cb528 | |||
| 9615ee3f41 | |||
| 58868fc2b6 | |||
| 7dadf8dde4 | |||
| 33d899c243 | |||
| b2e176940c | |||
| 86cdafb3ef | |||
| 0e5845a95a |
@@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
|
||||
AUTH_ADMIN_EMAIL=admin@example.com
|
||||
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
|
||||
# Optional: enables the "Continue with Microsoft" sign-in tab (separate from the
|
||||
# MICROSOFT_CLIENT_ID below, which is for Outlook mail linking, not sign-in).
|
||||
AUTH_MICROSOFT_CLIENT_ID=
|
||||
GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
|
||||
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
|
||||
GOOGLE_GMAIL_REDIRECT_URI=
|
||||
|
||||
@@ -19,14 +19,14 @@
|
||||
},
|
||||
"Auth": {
|
||||
"Require": true,
|
||||
"AllowRegistration": false,
|
||||
"AllowRegistration": true,
|
||||
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
|
||||
"JwtIssuer": "JobTrackerApi",
|
||||
"JwtAudience": "job-tracker-ui",
|
||||
"JwtExpiresMinutes": 720,
|
||||
"AdminEmail": "admin@example.com",
|
||||
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
|
||||
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID",
|
||||
"GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com",
|
||||
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
|
||||
},
|
||||
"App": {
|
||||
|
||||
+3
-1
@@ -19,8 +19,9 @@ services:
|
||||
- Auth__JwtKey=${AUTH_JWT_KEY}
|
||||
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
|
||||
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
|
||||
# Optional: allow Google ID-token bearer auth
|
||||
# Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access)
|
||||
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
|
||||
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
|
||||
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
|
||||
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
|
||||
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
|
||||
@@ -64,6 +65,7 @@ services:
|
||||
shm_size: '1gb'
|
||||
args:
|
||||
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
||||
- NEXT_PUBLIC_MICROSOFT_CLIENT_ID=${AUTH_MICROSOFT_CLIENT_ID}
|
||||
# Optional override; default in production is `/api`
|
||||
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
|
||||
ports:
|
||||
|
||||
@@ -3,12 +3,14 @@ FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_MICROSOFT_CLIENT_ID
|
||||
ARG NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_MICROSOFT_CLIENT_ID=$NEXT_PUBLIC_MICROSOFT_CLIENT_ID
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
|
||||
COPY package*.json ./
|
||||
COPY package*.json .npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
+31
-14
@@ -36,7 +36,7 @@ import { api } from "./api";
|
||||
import { resolveCaptureUrl } from "./captureUrl";
|
||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||
import AppShell, { NavItem } from "./layout/AppShell";
|
||||
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
|
||||
import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs";
|
||||
|
||||
const AddJobModal = lazy(() => import("./components/AddJobModal"));
|
||||
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
|
||||
@@ -100,11 +100,21 @@ function titleFor(path: string, t: (k: any) => string): string {
|
||||
return t("appTitle");
|
||||
}
|
||||
|
||||
function subtitleFor(path: string, t: (k: any) => string): string | undefined {
|
||||
if (path === "/dashboard") return t("dashboardPageSubtitle");
|
||||
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 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; }) {
|
||||
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();
|
||||
@@ -123,6 +133,9 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
|
||||
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));
|
||||
@@ -206,6 +219,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
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)); };
|
||||
@@ -246,14 +260,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<IconButton
|
||||
color="secondary"
|
||||
size="small"
|
||||
title={t("quickSearch")}
|
||||
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)}>{t("quickSearch")}</Button>
|
||||
<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 }}>
|
||||
@@ -267,6 +286,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<>
|
||||
<AppShell
|
||||
pageTitle={pageTitle}
|
||||
pageSubtitle={pageSubtitle}
|
||||
breadcrumbs={breadcrumbs}
|
||||
pathname={path}
|
||||
nav={nav}
|
||||
@@ -284,7 +304,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" 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 />} />
|
||||
@@ -297,7 +317,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<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} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />} />
|
||||
<Route path="/settings" element={<SettingsView pageSize={jobPageSize} onPageSizeChange={setAndPersistPageSize} columns={jobColumns} onColumnsChange={setAndPersistColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -314,19 +334,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
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]);
|
||||
const theme = useMemo(() => getTheme(effectiveMode), [effectiveMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => { setThemeMode(getThemeModePref()); setAccentColorState(getAccentColor()); };
|
||||
const sync = () => { setThemeMode(getThemeModePref()); };
|
||||
window.addEventListener("auth-changed", sync);
|
||||
return () => window.removeEventListener("auth-changed", sync);
|
||||
}, []);
|
||||
|
||||
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");
|
||||
@@ -349,14 +366,14 @@ export default function App() {
|
||||
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
|
||||
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} accentColor={accentColor} onAccentColorChange={onAccentColorChange} onResetAccentColor={onResetAccentColor} />, errorElement: <RouteErrorPage /> },
|
||||
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
|
||||
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
|
||||
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
||||
<CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
||||
<CssBaseline enableColorScheme />
|
||||
<I18nProvider>
|
||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||
|
||||
@@ -23,6 +23,7 @@ import AutoGraphIcon from "@mui/icons-material/AutoGraph";
|
||||
|
||||
import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import OnboardingChecklist from "./OnboardingChecklist";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { statusLabel } from "../pipeline";
|
||||
@@ -287,6 +288,7 @@ export default function DashboardView() {
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<OnboardingChecklist hasJobs={(stats?.total ?? 0) > 0} />
|
||||
<SectionCard
|
||||
sx={{
|
||||
backgroundColor: "background.paper",
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
const { t } = useI18n();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [allowRegistration, setAllowRegistration] = useState(false);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim();
|
||||
@@ -72,6 +73,9 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMe();
|
||||
api.get<{ allowRegistration: boolean }>("/auth/config").then((res) => {
|
||||
setAllowRegistration(Boolean(res.data?.allowRegistration));
|
||||
}).catch(() => setAllowRegistration(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -156,7 +160,7 @@ export default function GoogleAuthCard({ onSignedIn }: { onSignedIn?: () => void
|
||||
|
||||
{!signedIn ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("googleSignInHint")}
|
||||
{allowRegistration ? t("googleSignInHintSelfServe") : t("googleSignInHint")}
|
||||
</Typography>
|
||||
) : me?.provider === "local" ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
|
||||
@@ -1132,6 +1132,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
<MatchScoreCard score={matchScore} loading={loadingMatchScore} />
|
||||
{loadingCandidateFit ? <Box sx={{ py: 4, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : candidateFit ? (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2.5 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: -1 }}>{t("jobDetailsAiFitHint")}</Typography>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box><Typography variant="overline">{t("jobDetailsHowYouMatch")}</Typography><Typography sx={{ whiteSpace: "pre-wrap" }}>{candidateFit.matchSummary}</Typography></Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
@@ -1230,13 +1231,14 @@ function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading:
|
||||
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
|
||||
{score.hasEnoughSignal ? (
|
||||
<Box sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
||||
<CircularProgress variant="determinate" value={100} size={92} thickness={4} sx={{ color: "divider", position: "absolute" }} />
|
||||
<Box role="img" aria-label={`${t("matchScoreTitle")}: ${score.score}%`} sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
||||
<CircularProgress variant="determinate" value={100} size={92} thickness={4} aria-hidden="true" sx={{ color: "divider", position: "absolute" }} />
|
||||
<CircularProgress
|
||||
variant="determinate"
|
||||
value={score.score}
|
||||
size={92}
|
||||
thickness={4}
|
||||
aria-hidden="true"
|
||||
color={color === "inherit" ? "primary" : color}
|
||||
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
|
||||
/>
|
||||
|
||||
@@ -110,6 +110,19 @@ function parseTags(raw?: string | null): string[] {
|
||||
}
|
||||
|
||||
|
||||
function EmptyJobsState({ firstTime, onOpenSettings, t }: { firstTime: boolean; onOpenSettings: () => void; t: (key: any) => string }) {
|
||||
if (!firstTime) {
|
||||
return <Typography sx={{ py: 2, textAlign: "center", color: "text.secondary" }}>{t("jobTableNoJobsFound")}</Typography>;
|
||||
}
|
||||
return (
|
||||
<Box sx={{ py: 4, textAlign: "center" }}>
|
||||
<Typography sx={{ fontWeight: 800, mb: 0.5 }}>{t("jobTableEmptyFirstTimeTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 1.5, maxWidth: 440, mx: "auto" }}>{t("jobTableEmptyFirstTimeBody")}</Typography>
|
||||
<Button variant="text" onClick={onOpenSettings}>{t("jobTableEmptyFirstTimeBookmarklet")}</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function generateOverview(job: JobApplication): string {
|
||||
if (job.fullSummary) return job.fullSummary;
|
||||
if (job.shortSummary) return job.shortSummary;
|
||||
@@ -220,6 +233,12 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
return jobs.filter((job) => needsWorkflowWork(job));
|
||||
}, [jobs, readinessFilter]);
|
||||
|
||||
// Distinguishes "you have zero jobs, period" from "no results match your filters" so the
|
||||
// empty state can actually help a first-time user instead of just saying "nothing here".
|
||||
const noFiltersActive = !debouncedSearch.trim() && statusFilter === "All" && companyFilterId === "All"
|
||||
&& !debouncedLocation.trim() && !needsFollowUpOnly && readinessFilter === "all";
|
||||
const isFirstTimeEmpty = mode === "jobs" && total === 0 && noFiltersActive;
|
||||
|
||||
const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const selectedAllOnPage = filteredJobs.length > 0 && filteredJobs.every((job) => selectedIdSet.has(job.id));
|
||||
@@ -629,7 +648,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography> : null}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
|
||||
<EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} />
|
||||
) : null}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box sx={{ overflowX: "auto" }}>
|
||||
@@ -721,7 +742,9 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? <TableRow><TableCell colSpan={visibleDesktopColumns}><Typography sx={{ py: 2, textAlign: "center" }}>{t("jobTableNoJobsFound")}</Typography></TableCell></TableRow> : null}
|
||||
{filteredJobs.length === 0 && !jobsResource.loading && !jobsResource.error ? (
|
||||
<TableRow><TableCell colSpan={visibleDesktopColumns}><EmptyJobsState firstTime={isFirstTimeEmpty} onOpenSettings={() => navigate("/settings")} t={t} /></TableCell></TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
@@ -101,7 +101,18 @@ export default function KanbanBoard() {
|
||||
/>
|
||||
|
||||
{!jobsResource.loading && !jobsResource.error ? (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" }, gap: 2, alignItems: "start" }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: "flex", md: "grid" },
|
||||
gridTemplateColumns: { md: "repeat(3, 1fr)", xl: "repeat(6, 1fr)" },
|
||||
gap: 2,
|
||||
alignItems: "start",
|
||||
overflowX: { xs: "auto", md: "visible" },
|
||||
scrollSnapType: { xs: "x mandatory", md: "none" },
|
||||
pb: { xs: 1, md: 0 },
|
||||
"-webkit-overflow-scrolling": "touch",
|
||||
}}
|
||||
>
|
||||
{STATUSES.map((status) => {
|
||||
const c = toneColor(theme, status);
|
||||
const list = groups.get(status) ?? [];
|
||||
@@ -114,6 +125,8 @@ export default function KanbanBoard() {
|
||||
p: 1.5,
|
||||
borderRadius: 3,
|
||||
minHeight: 220,
|
||||
flex: { xs: "0 0 85vw", md: "none" },
|
||||
scrollSnapAlign: { xs: "start", md: "none" },
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
background: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.02) : alpha(theme.palette.text.primary, 0.015),
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Box, Button, IconButton, Paper, Stack, Typography } from "@mui/material";
|
||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
|
||||
import RadioButtonUncheckedIcon from "@mui/icons-material/RadioButtonUnchecked";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { alpha, useTheme } from "@mui/material/styles";
|
||||
|
||||
import { api } from "../api";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
function dismissKey() {
|
||||
return `onboardingChecklistDismissed:${getUserKeyFromToken()}`;
|
||||
}
|
||||
|
||||
type MeResponse = { profileCvText?: string | null };
|
||||
|
||||
export default function OnboardingChecklist({ hasJobs }: { hasJobs: boolean }) {
|
||||
const theme = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useI18n();
|
||||
const [hasCv, setHasCv] = useState<boolean | null>(null);
|
||||
const [dismissed, setDismissed] = useState(() => window.localStorage.getItem(dismissKey()) === "1");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<MeResponse>("/auth/me")
|
||||
.then((r) => { if (active) setHasCv(Boolean(r.data?.profileCvText?.trim())); })
|
||||
.catch(() => { if (active) setHasCv(false); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const allDone = hasCv === true && hasJobs;
|
||||
if (dismissed || allDone || hasCv === null) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
window.localStorage.setItem(dismissKey(), "1");
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ done: hasCv, label: t("onboardingStepCv"), action: () => navigate("/profile"), actionLabel: t("onboardingStepCvAction") },
|
||||
{ done: hasJobs, label: t("onboardingStepJob"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepJobAction") },
|
||||
{ done: hasCv === true && hasJobs, label: t("onboardingStepMatch"), action: () => navigate("/jobs"), actionLabel: t("onboardingStepMatchAction") },
|
||||
];
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2.25,
|
||||
mb: 2,
|
||||
borderRadius: 4,
|
||||
border: "1px solid",
|
||||
borderColor: alpha(theme.palette.primary.main, 0.25),
|
||||
background: alpha(theme.palette.primary.main, 0.04),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" onClick={dismiss} aria-label={t("onboardingDismiss")} sx={{ position: "absolute", top: 8, right: 8 }}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Typography sx={{ fontWeight: 900, mb: 0.25 }}>{t("onboardingTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("onboardingBody")}</Typography>
|
||||
<Stack spacing={1}>
|
||||
{steps.map((step) => (
|
||||
<Box key={step.label} sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{step.done ? <CheckCircleIcon fontSize="small" color="success" /> : <RadioButtonUncheckedIcon fontSize="small" sx={{ color: "text.secondary" }} />}
|
||||
<Typography variant="body2" sx={{ fontWeight: step.done ? 400 : 700, color: step.done ? "text.secondary" : "text.primary", textDecoration: step.done ? "line-through" : "none" }}>
|
||||
{step.label}
|
||||
</Typography>
|
||||
</Box>
|
||||
{!step.done ? <Button size="small" variant="text" onClick={step.action}>{step.actionLabel}</Button> : null}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
@@ -9,11 +9,9 @@ import {
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Popover,
|
||||
Select,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
@@ -21,12 +19,9 @@ import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { JobTableColumns } from "./JobTable";
|
||||
import ImportExportJobs from "./ImportExportJobs";
|
||||
import GoogleAuthCard from "./GoogleAuthCard";
|
||||
import EmailProviderConnections from "./EmailProviderConnections";
|
||||
import RulesSettingsCard from "./RulesSettingsCard";
|
||||
import BackupCard from "./BackupCard";
|
||||
import QuickCaptureCard from "./QuickCaptureCard";
|
||||
import AuthStatusCard from "./AuthStatusCard";
|
||||
import { ThemeModePref } from "../themePrefs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -37,17 +32,23 @@ interface Props {
|
||||
onColumnsChange: (next: JobTableColumns) => void;
|
||||
themeMode: ThemeModePref;
|
||||
onThemeModeChange: (v: ThemeModePref) => void;
|
||||
accentColor: string;
|
||||
onAccentColorChange: (v: string) => void;
|
||||
onResetAccentColor: () => void;
|
||||
}
|
||||
|
||||
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
|
||||
if (value !== index) return null;
|
||||
return <Box sx={{ mt: 2 }}>{children}</Box>;
|
||||
return <Box sx={{ mt: 2.5 }}>{children}</Box>;
|
||||
}
|
||||
|
||||
function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Paper sx={{ p: 2.5 }}>
|
||||
<Typography variant="overline" sx={{ color: "text.secondary", fontWeight: 800 }}>{title}</Typography>
|
||||
{subtitle ? <Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, mb: 1.5 }}>{subtitle}</Typography> : <Box sx={{ mb: 1.5 }} />}
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
const ACCENTS = ["#6366f1", "#22d3ee", "#2563eb", "#8b5cf6", "#15803d", "#16a34a", "#0f766e", "#f97316"];
|
||||
const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs";
|
||||
|
||||
type NotificationPrefs = {
|
||||
@@ -88,43 +89,19 @@ export default function SettingsView({
|
||||
onColumnsChange,
|
||||
themeMode,
|
||||
onThemeModeChange,
|
||||
accentColor,
|
||||
onAccentColorChange,
|
||||
onResetAccentColor,
|
||||
}: Props) {
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState(0);
|
||||
const { language, setLanguage, t } = useI18n();
|
||||
const [accentAnchor, setAccentAnchor] = useState<HTMLElement | null>(null);
|
||||
const [accentDraft, setAccentDraft] = useState(accentColor);
|
||||
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs>(() => loadNotificationPrefs());
|
||||
|
||||
const accentOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentColor), [accentColor]);
|
||||
const accentDraftOk = useMemo(() => /^#[0-9a-fA-F]{6}$/.test(accentDraft), [accentDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
setAccentDraft(accentOk ? accentColor : "#15803d");
|
||||
}, [accentColor, accentOk]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs));
|
||||
}, [notificationPrefs]);
|
||||
|
||||
const applyAccent = () => {
|
||||
if (!accentDraftOk) return;
|
||||
onAccentColorChange(accentDraft);
|
||||
setAccentAnchor(null);
|
||||
};
|
||||
|
||||
const resetAccent = () => {
|
||||
onResetAccentColor();
|
||||
setAccentDraft("#15803d");
|
||||
setAccentAnchor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Typography variant="h5" sx={{ mb: 1, fontWeight: 900 }}>
|
||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||
<Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
|
||||
{t("settingsTitle")}
|
||||
</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||
@@ -135,130 +112,48 @@ export default function SettingsView({
|
||||
<Tab label={t("settingsTabGeneral")} />
|
||||
<Tab label={t("settingsTabFollowUps")} />
|
||||
<Tab label={t("settingsTabNotifications")} />
|
||||
<Tab label={t("settingsTabAccount")} />
|
||||
<Tab label={t("settingsTabBackup")} />
|
||||
</Tabs>
|
||||
|
||||
<TabPanel value={tab} index={0}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsAppearance")}</Typography>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
|
||||
<Select
|
||||
labelId="theme-mode-label"
|
||||
value={themeMode}
|
||||
label={t("settingsTheme")}
|
||||
onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
|
||||
>
|
||||
<MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
|
||||
<MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
|
||||
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ mb: 0.75, display: "block" }}>{t("settingsAccent")}</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={(e) => setAccentAnchor(e.currentTarget)}
|
||||
sx={{ gap: 1.25, justifyContent: "flex-start", minWidth: 180 }}
|
||||
<Box sx={{ display: "grid", gap: 2.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2.5 }}>
|
||||
<SectionCard title={t("settingsAppearance")}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
|
||||
<Select
|
||||
labelId="theme-mode-label"
|
||||
value={themeMode}
|
||||
label={t("settingsTheme")}
|
||||
onChange={(e) => onThemeModeChange(e.target.value as ThemeModePref)}
|
||||
>
|
||||
<Box sx={{ width: 20, height: 20, borderRadius: 999, bgcolor: accentOk ? accentColor : "#15803d", border: "1px solid", borderColor: "divider" }} />
|
||||
{accentOk ? accentColor.toUpperCase() : "#15803D"}
|
||||
</Button>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={resetAccent}>
|
||||
{t("settingsReset")}
|
||||
</Button>
|
||||
</Box>
|
||||
<MenuItem value="system">{t("settingsThemeSystem")}</MenuItem>
|
||||
<MenuItem value="dark">{t("settingsThemeDark")}</MenuItem>
|
||||
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SectionCard>
|
||||
|
||||
<Popover
|
||||
open={Boolean(accentAnchor)}
|
||||
anchorEl={accentAnchor}
|
||||
onClose={() => setAccentAnchor(null)}
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||
>
|
||||
<Box sx={{ p: 2, width: 280, display: "grid", gap: 1.5 }}>
|
||||
<Typography sx={{ fontWeight: 900 }}>{t("settingsAccent")}</Typography>
|
||||
<input
|
||||
aria-label={t("settingsAccent")}
|
||||
type="color"
|
||||
value={accentDraftOk ? accentDraft : "#15803d"}
|
||||
onChange={(e) => setAccentDraft(e.target.value)}
|
||||
style={{ width: "100%", height: 52, border: "none", background: "transparent", padding: 0, cursor: "pointer" }}
|
||||
/>
|
||||
<TextField
|
||||
label={t("settingsAccent")}
|
||||
value={accentDraft}
|
||||
onChange={(e) => setAccentDraft(e.target.value)}
|
||||
error={!accentDraftOk}
|
||||
helperText={accentDraftOk ? t("settingsAccentHelp") : t("settingsAccentInvalid")}
|
||||
fullWidth
|
||||
/>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{ACCENTS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => setAccentDraft(c)}
|
||||
title={c}
|
||||
aria-label={`${t("settingsAccent")} ${c}`}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
border: c.toLowerCase() === accentDraft.toLowerCase() ? "2px solid rgba(15,23,42,0.9)" : "1px solid rgba(148,163,184,0.35)",
|
||||
background: c,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 1 }}>
|
||||
<Button variant="text" onClick={() => setAccentAnchor(null)}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={applyAccent} disabled={!accentDraftOk}>{t("save")}</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Popover>
|
||||
<SectionCard title={t("settingsLanguageTitle")} subtitle={t("settingsLanguageBody")}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
|
||||
<Select
|
||||
labelId="language-label"
|
||||
value={language}
|
||||
label={t("settingsPreferredLanguage")}
|
||||
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
|
||||
>
|
||||
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
|
||||
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>
|
||||
{t("settingsSavedPerUser")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsLanguageTitle")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("settingsLanguageBody")}
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
|
||||
<Select
|
||||
labelId="language-label"
|
||||
value={language}
|
||||
label={t("settingsPreferredLanguage")}
|
||||
onChange={(e) => setLanguage(e.target.value as "en" | "no")}
|
||||
>
|
||||
<MenuItem value="en">{t("settingsEnglish")}</MenuItem>
|
||||
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{t("settingsMorePagesSoon")}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsJobs")}</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 3, flexWrap: "wrap" }}>
|
||||
<SectionCard title={t("settingsJobs")}>
|
||||
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||
<Box sx={{ minWidth: 240 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
{t("settingsPagination")}
|
||||
</Typography>
|
||||
<FormControl fullWidth>
|
||||
@@ -277,7 +172,7 @@ export default function SettingsView({
|
||||
</Box>
|
||||
|
||||
<Box sx={{ minWidth: 240 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||
{t("settingsColumns")}
|
||||
</Typography>
|
||||
{(
|
||||
@@ -297,8 +192,10 @@ export default function SettingsView({
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<ImportExportJobs />
|
||||
</Paper>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<ImportExportJobs />
|
||||
</Box>
|
||||
</SectionCard>
|
||||
|
||||
<QuickCaptureCard />
|
||||
</Box>
|
||||
@@ -309,9 +206,7 @@ export default function SettingsView({
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={2}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("settingsNotificationsBody")}</Typography>
|
||||
<SectionCard title={t("settingsNotificationsTitle")} subtitle={t("settingsNotificationsBody")}>
|
||||
<Box sx={{ display: "grid", gap: 1 }}>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={notificationPrefs.emailFollowUpReminders} onChange={(e) => setNotificationPrefs((prev) => ({ ...prev, emailFollowUpReminders: e.target.checked }))} />}
|
||||
@@ -333,18 +228,10 @@ export default function SettingsView({
|
||||
<Button variant="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
|
||||
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</SectionCard>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={3}>
|
||||
<AuthStatusCard />
|
||||
<GoogleAuthCard />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<EmailProviderConnections />
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value={tab} index={4}>
|
||||
<BackupCard />
|
||||
</TabPanel>
|
||||
</Paper>
|
||||
|
||||
@@ -18,6 +18,12 @@ export const translations = {
|
||||
home: "Home",
|
||||
analytics: "Analytics",
|
||||
overview: "Overview",
|
||||
dashboardPageSubtitle: "Your search at a glance — response rate, funnel, and what needs attention.",
|
||||
jobsPageSubtitle: "Filter, search, and manage every application in one table.",
|
||||
kanbanPageSubtitle: "Drag a card between stages to update its status.",
|
||||
remindersPageSubtitle: "Everything due for follow-up, soonest first.",
|
||||
correspondencePageSubtitle: "Every message linked to a job, in one inbox.",
|
||||
gmailReviewPageSubtitle: "Review Gmail threads before linking them to a job.",
|
||||
account: "Account",
|
||||
profile: "Profile",
|
||||
admin: "Admin",
|
||||
@@ -128,22 +134,17 @@ export const translations = {
|
||||
settingsTabGeneral: "General",
|
||||
settingsTabFollowUps: "Follow-ups",
|
||||
settingsTabNotifications: "Notifications",
|
||||
settingsTabAccount: "Account",
|
||||
settingsTabBackup: "Backup",
|
||||
settingsAppearance: "Appearance",
|
||||
settingsTheme: "Theme",
|
||||
settingsThemeSystem: "System",
|
||||
settingsThemeDark: "Dark",
|
||||
settingsThemeLight: "Light",
|
||||
settingsAccent: "Accent",
|
||||
settingsReset: "Reset",
|
||||
settingsSavedPerUser: "Saved per user on this browser.",
|
||||
settingsLanguageTitle: "Language and localization",
|
||||
settingsLanguageBody: "Set your preferred app language. This is also the language used when deciding whether imported job descriptions should show translated text.",
|
||||
settingsPreferredLanguage: "Preferred language",
|
||||
settingsEnglish: "English",
|
||||
settingsNorwegian: "Norwegian Bokmål",
|
||||
settingsMorePagesSoon: "More pages will be moved onto this translation system as the UI cleanup continues.",
|
||||
settingsJobs: "Jobs",
|
||||
settingsPagination: "Pagination",
|
||||
settingsRowsPerPage: "Rows per page",
|
||||
@@ -167,8 +168,6 @@ export const translations = {
|
||||
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
|
||||
settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs",
|
||||
settingsNotificationsInAppReminders: "Highlight reminders in the app",
|
||||
settingsAccentHelp: "Drag in the color picker, then save when it looks right.",
|
||||
settingsAccentInvalid: "Use a full hex color like #15803D.",
|
||||
settingsCheckSystemStatus: "Check system status",
|
||||
profileTitle: "Profile",
|
||||
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
|
||||
@@ -313,6 +312,15 @@ export const translations = {
|
||||
cropDialogSave: "Save image",
|
||||
dashboardOverviewTitle: "Dashboard overview",
|
||||
dashboardHeroLabel: "Job search overview",
|
||||
onboardingTitle: "Get set up",
|
||||
onboardingBody: "A few steps to get the most out of Jobbjakt.",
|
||||
onboardingDismiss: "Dismiss",
|
||||
onboardingStepCv: "Add your CV",
|
||||
onboardingStepCvAction: "Add CV",
|
||||
onboardingStepJob: "Import your first job",
|
||||
onboardingStepJobAction: "Add job",
|
||||
onboardingStepMatch: "Check your CV match score on a job",
|
||||
onboardingStepMatchAction: "Open jobs",
|
||||
dashboardResponseRate: "{rate}% response rate",
|
||||
dashboardMonthsShort: "{count} mo",
|
||||
dashboardAppliedCount: "{count} applied",
|
||||
@@ -613,6 +621,7 @@ export const translations = {
|
||||
googleAvailableToLink: "Available to link",
|
||||
googleLinkedDate: "Linked {date}",
|
||||
googleSignInHint: "Sign in with a Google account that has already been linked to your Jobbjakt user.",
|
||||
googleSignInHintSelfServe: "Continue with Google. New here? We'll create your account automatically.",
|
||||
continueWithGoogle: "Continue with Google",
|
||||
signInWithGoogle: "Sign in with Google",
|
||||
linkWithGoogle: "Link with Google",
|
||||
@@ -629,7 +638,7 @@ export const translations = {
|
||||
googleUnlinked: "Google account unlinked.",
|
||||
googleUnlinkFailed: "Failed to unlink Google account.",
|
||||
microsoftAccountTitle: "Microsoft account",
|
||||
microsoftSetupHint: "Set `REACT_APP_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
|
||||
microsoftSetupHint: "Set `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
|
||||
microsoftLinked: "Linked",
|
||||
microsoftAvailableToLink: "Available to link",
|
||||
microsoftLinkedDate: "Linked {date}",
|
||||
@@ -757,6 +766,9 @@ export const translations = {
|
||||
jobTableOverview: "Overview",
|
||||
jobTableNoSummaryYet: "No summary yet.",
|
||||
jobTableNoJobsFound: "No jobs found.",
|
||||
jobTableEmptyFirstTimeTitle: "No jobs yet — let's fix that.",
|
||||
jobTableEmptyFirstTimeBody: "Click \"Add job\" above to add one manually, or paste a job posting URL. There's also a one-click bookmarklet that captures a posting straight from the page you're viewing.",
|
||||
jobTableEmptyFirstTimeBookmarklet: "Set up the bookmarklet",
|
||||
jobTableSetStatus: "Set {status}",
|
||||
editJobTitle: "Edit job",
|
||||
editJobIntro: "Update job details, timeline status, documents, and notes from one editing workspace.",
|
||||
@@ -901,6 +913,7 @@ export const translations = {
|
||||
jobDetailsFollowUpSent: "Follow-up sent and logged.",
|
||||
jobDetailsFollowUpSendFailed: "Failed to send follow-up.",
|
||||
jobDetailsHowYouMatch: "How you match",
|
||||
jobDetailsAiFitHint: "AI opinion — strengths, gaps, and a tailored pitch based on your CV and this posting.",
|
||||
matchScoreTitle: "Match score",
|
||||
matchScoreLoading: "Scoring your CV against this role…",
|
||||
matchScoreBand_Strong: "Strong match",
|
||||
@@ -909,7 +922,7 @@ export const translations = {
|
||||
matchScoreBand_Unknown: "Not enough signal",
|
||||
matchScoreKeywordsCovered: "{matched}/{total} keywords",
|
||||
matchScoreNoSignal: "Add more CV detail or a fuller job description to get a reliable score.",
|
||||
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable.",
|
||||
matchScoreDeterministicHint: "Deterministic keyword coverage — no AI, so the score is stable and repeatable. For a written opinion on strengths and gaps, see the AI section below.",
|
||||
matchScoreMatched: "Matched keywords",
|
||||
matchScoreMissing: "Missing keywords",
|
||||
matchScoreNoneYet: "No matches found yet.",
|
||||
@@ -983,6 +996,12 @@ export const translations = {
|
||||
home: "Hjem",
|
||||
analytics: "Analyse",
|
||||
overview: "Oversikt",
|
||||
dashboardPageSubtitle: "Søket ditt i korte trekk — svarrate, trakt og hva som trenger oppmerksomhet.",
|
||||
jobsPageSubtitle: "Filtrer, søk og administrer alle søknader i én tabell.",
|
||||
kanbanPageSubtitle: "Dra et kort mellom stadier for å oppdatere status.",
|
||||
remindersPageSubtitle: "Alt som trenger oppfølging, snarest først.",
|
||||
correspondencePageSubtitle: "Alle meldinger koblet til en jobb, i én innboks.",
|
||||
gmailReviewPageSubtitle: "Se gjennom Gmail-tråder før du kobler dem til en jobb.",
|
||||
account: "Konto",
|
||||
profile: "Profil",
|
||||
admin: "Admin",
|
||||
@@ -1093,22 +1112,17 @@ export const translations = {
|
||||
settingsTabGeneral: "Generelt",
|
||||
settingsTabFollowUps: "Oppfølging",
|
||||
settingsTabNotifications: "Varsler",
|
||||
settingsTabAccount: "Konto",
|
||||
settingsTabBackup: "Sikkerhetskopi",
|
||||
settingsAppearance: "Utseende",
|
||||
settingsTheme: "Tema",
|
||||
settingsThemeSystem: "System",
|
||||
settingsThemeDark: "Mørkt",
|
||||
settingsThemeLight: "Lyst",
|
||||
settingsAccent: "Aksent",
|
||||
settingsReset: "Tilbakestill",
|
||||
settingsSavedPerUser: "Lagres per bruker i denne nettleseren.",
|
||||
settingsLanguageTitle: "Språk og lokalisering",
|
||||
settingsLanguageBody: "Velg foretrukket språk i appen. Dette brukes også når appen avgjør om importerte stillingsbeskrivelser skal vise oversatt tekst.",
|
||||
settingsPreferredLanguage: "Foretrukket språk",
|
||||
settingsEnglish: "Engelsk",
|
||||
settingsNorwegian: "Norsk Bokmål",
|
||||
settingsMorePagesSoon: "Flere sider flyttes til dette oversettelsessystemet etter hvert som UI-oppryddingen fortsetter.",
|
||||
settingsJobs: "Jobber",
|
||||
settingsPagination: "Paginering",
|
||||
settingsRowsPerPage: "Rader per side",
|
||||
@@ -1132,8 +1146,6 @@ export const translations = {
|
||||
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
|
||||
settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber",
|
||||
settingsNotificationsInAppReminders: "Fremhev påminnelser i appen",
|
||||
settingsAccentHelp: "Dra i fargevelgeren, og lagre når den ser riktig ut.",
|
||||
settingsAccentInvalid: "Bruk en full hex-farge som #15803D.",
|
||||
settingsCheckSystemStatus: "Sjekk systemstatus",
|
||||
profileTitle: "Profil",
|
||||
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
|
||||
@@ -1278,6 +1290,15 @@ export const translations = {
|
||||
cropDialogSave: "Lagre bilde",
|
||||
dashboardOverviewTitle: "Dashboard-oversikt",
|
||||
dashboardHeroLabel: "Oversikt over jobbsøket",
|
||||
onboardingTitle: "Kom i gang",
|
||||
onboardingBody: "Noen få steg for å få mest mulig ut av Jobbjakt.",
|
||||
onboardingDismiss: "Lukk",
|
||||
onboardingStepCv: "Legg til CV-en din",
|
||||
onboardingStepCvAction: "Legg til CV",
|
||||
onboardingStepJob: "Importer din første jobb",
|
||||
onboardingStepJobAction: "Legg til jobb",
|
||||
onboardingStepMatch: "Sjekk CV-matchscoren på en jobb",
|
||||
onboardingStepMatchAction: "Åpne jobber",
|
||||
dashboardResponseRate: "{rate}% svarrate",
|
||||
dashboardMonthsShort: "{count} md",
|
||||
dashboardAppliedCount: "{count} søkt",
|
||||
@@ -1578,6 +1599,7 @@ export const translations = {
|
||||
googleAvailableToLink: "Tilgjengelig for kobling",
|
||||
googleLinkedDate: "Koblet {date}",
|
||||
googleSignInHint: "Logg inn med en Google-konto som allerede er koblet til Jobbjakt-brukeren din.",
|
||||
googleSignInHintSelfServe: "Fortsett med Google. Ny her? Vi oppretter kontoen din automatisk.",
|
||||
continueWithGoogle: "Fortsett med Google",
|
||||
signInWithGoogle: "Logg inn med Google",
|
||||
linkWithGoogle: "Koble til med Google",
|
||||
@@ -1594,7 +1616,7 @@ export const translations = {
|
||||
googleUnlinked: "Google-konto koblet fra.",
|
||||
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
|
||||
microsoftAccountTitle: "Microsoft-konto",
|
||||
microsoftSetupHint: "Sett `REACT_APP_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
|
||||
microsoftSetupHint: "Sett `NEXT_PUBLIC_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
|
||||
microsoftLinked: "Koblet",
|
||||
microsoftAvailableToLink: "Tilgjengelig for kobling",
|
||||
microsoftLinkedDate: "Koblet {date}",
|
||||
@@ -1722,6 +1744,9 @@ export const translations = {
|
||||
jobTableOverview: "Oversikt",
|
||||
jobTableNoSummaryYet: "Ingen oppsummering ennå.",
|
||||
jobTableNoJobsFound: "Ingen jobber funnet.",
|
||||
jobTableEmptyFirstTimeTitle: "Ingen jobber ennå — la oss fikse det.",
|
||||
jobTableEmptyFirstTimeBody: "Klikk \"Legg til jobb\" over for å legge til en manuelt, eller lim inn en lenke til en stillingsannonse. Det finnes også et bokmerke som fanger en annonse rett fra siden du ser på.",
|
||||
jobTableEmptyFirstTimeBookmarklet: "Sett opp bokmerket",
|
||||
jobTableSetStatus: "Sett {status}",
|
||||
editJobTitle: "Rediger jobb",
|
||||
editJobIntro: "Oppdater jobbdetaljer, status i tidslinjen, dokumenter og notater fra ett redigeringsområde.",
|
||||
@@ -1866,6 +1891,7 @@ export const translations = {
|
||||
jobDetailsFollowUpSent: "Oppfølging sendt og loggført.",
|
||||
jobDetailsFollowUpSendFailed: "Kunne ikke sende oppfølging.",
|
||||
jobDetailsHowYouMatch: "Slik matcher du",
|
||||
jobDetailsAiFitHint: "AI-vurdering — styrker, svakheter og et skreddersydd pitch basert på CV-en din og denne annonsen.",
|
||||
matchScoreTitle: "Match-score",
|
||||
matchScoreLoading: "Vurderer CV-en mot denne stillingen…",
|
||||
matchScoreBand_Strong: "Sterk match",
|
||||
@@ -1874,7 +1900,7 @@ export const translations = {
|
||||
matchScoreBand_Unknown: "For lite grunnlag",
|
||||
matchScoreKeywordsCovered: "{matched}/{total} nøkkelord",
|
||||
matchScoreNoSignal: "Legg til mer CV-innhold eller en fyldigere stillingstekst for en pålitelig score.",
|
||||
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar.",
|
||||
matchScoreDeterministicHint: "Deterministisk nøkkelorddekning — ingen AI, så scoren er stabil og repeterbar. For en skriftlig vurdering av styrker og svakheter, se AI-seksjonen under.",
|
||||
matchScoreMatched: "Treff på nøkkelord",
|
||||
matchScoreMissing: "Manglende nøkkelord",
|
||||
matchScoreNoneYet: "Ingen treff ennå.",
|
||||
|
||||
@@ -59,6 +59,7 @@ const SIDEBAR_SELECTED_ICON = "#a5b4fc";
|
||||
|
||||
export default function AppShell({
|
||||
pageTitle,
|
||||
pageSubtitle,
|
||||
breadcrumbs,
|
||||
pathname,
|
||||
nav,
|
||||
@@ -76,6 +77,7 @@ export default function AppShell({
|
||||
children,
|
||||
}: {
|
||||
pageTitle: string;
|
||||
pageSubtitle?: string;
|
||||
breadcrumbs: string[];
|
||||
pathname: string;
|
||||
nav: NavItem[];
|
||||
@@ -481,6 +483,11 @@ export default function AppShell({
|
||||
<Typography variant="h5" sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>
|
||||
{pageTitle}
|
||||
</Typography>
|
||||
{pageSubtitle ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25, overflowWrap: "anywhere" }}>
|
||||
{pageSubtitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import SettingsView from './components/SettingsView';
|
||||
@@ -21,35 +21,27 @@ jest.mock('./api', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('./components/ImportExportJobs', () => () => <div>Import Export Stub</div>);
|
||||
jest.mock('./components/GoogleAuthCard', () => () => <div>Google Auth Stub</div>);
|
||||
jest.mock('./components/BackupCard', () => () => <div>Backup Stub</div>);
|
||||
jest.mock('./components/AuthStatusCard', () => () => <div>Auth Status Stub</div>);
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderView(onAccentColorChange = jest.fn()) {
|
||||
return {
|
||||
onAccentColorChange,
|
||||
...render(
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<SettingsView
|
||||
pageSize={20}
|
||||
onPageSizeChange={jest.fn()}
|
||||
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }}
|
||||
onColumnsChange={jest.fn()}
|
||||
themeMode="dark"
|
||||
onThemeModeChange={jest.fn()}
|
||||
accentColor="#15803d"
|
||||
onAccentColorChange={onAccentColorChange}
|
||||
onResetAccentColor={jest.fn()}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
};
|
||||
function renderView() {
|
||||
return render(
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<SettingsView
|
||||
pageSize={20}
|
||||
onPageSizeChange={jest.fn()}
|
||||
columns={{ status: true, dateApplied: true, daysSince: true, jobUrl: false }}
|
||||
onColumnsChange={jest.fn()}
|
||||
themeMode="dark"
|
||||
onThemeModeChange={jest.fn()}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -76,15 +68,10 @@ afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('settings view uses one follow-up section, one notification section, and staged accent apply', async () => {
|
||||
const { onAccentColorChange } = renderView();
|
||||
test('settings view has no accent picker and uses one follow-up section, one notification section', async () => {
|
||||
renderView();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /#15803D/i }));
|
||||
const accentInput = (await screen.findAllByLabelText('Accent'))[1] as HTMLInputElement;
|
||||
fireEvent.change(accentInput, { target: { value: '#2563eb' } });
|
||||
expect(onAccentColorChange).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
|
||||
expect(onAccentColorChange).toHaveBeenCalledWith('#2563eb');
|
||||
expect(screen.queryByText(/accent/i)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i }));
|
||||
expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument();
|
||||
|
||||
@@ -2,6 +2,10 @@ import { alpha, createTheme, darken, lighten } from "@mui/material/styles";
|
||||
|
||||
type PaletteLike = Record<string, any>;
|
||||
|
||||
// Single global brand accent -- matches the dark sidebar/landing page indigo used throughout
|
||||
// the app. Not user-configurable; see jobbjakt-nextjs-migration memory / UI rework notes.
|
||||
const ACCENT = "#6366F1";
|
||||
|
||||
function buildPrimary(main: string) {
|
||||
return {
|
||||
lighter: lighten(main, 0.82),
|
||||
@@ -12,7 +16,7 @@ function buildPrimary(main: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildLightPalette(accentColor: string): PaletteLike {
|
||||
function buildLightPalette(): PaletteLike {
|
||||
const textPrimary = "#1B1B1F";
|
||||
const textSecondary = "#46464F";
|
||||
|
||||
@@ -24,7 +28,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
|
||||
const disabledBackground = "#E4E1E6";
|
||||
|
||||
return {
|
||||
primary: buildPrimary(accentColor || "#6366F1"),
|
||||
primary: buildPrimary(ACCENT),
|
||||
secondary: {
|
||||
lighter: "#E0E0FF",
|
||||
light: "#C3C4E4",
|
||||
@@ -82,14 +86,14 @@ function buildLightPalette(accentColor: string): PaletteLike {
|
||||
// from the product mockups; cards/inputs (paper) sit above it.
|
||||
background: { default: "#F4F6FB", paper: background },
|
||||
action: {
|
||||
hover: alpha(accentColor || "#6366F1", 0.05),
|
||||
hover: alpha(ACCENT, 0.05),
|
||||
disabled: alpha(disabled, 0.6),
|
||||
disabledBackground: alpha(disabledBackground, 0.9),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildDarkPalette(accentColor: string): PaletteLike {
|
||||
function buildDarkPalette(): PaletteLike {
|
||||
const bg = "#0B0B0E";
|
||||
const paper = "#111116";
|
||||
const divider = alpha("#FFFFFF", 0.10);
|
||||
@@ -101,7 +105,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
|
||||
const disabledBackground = alpha("#FFFFFF", 0.08);
|
||||
|
||||
return {
|
||||
primary: buildPrimary(accentColor || "#6366F1"),
|
||||
primary: buildPrimary(ACCENT),
|
||||
secondary: {
|
||||
lighter: alpha(secondaryMain, 0.22),
|
||||
light: alpha(secondaryMain, 0.14),
|
||||
@@ -157,7 +161,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
|
||||
divider,
|
||||
background: { default: bg, paper },
|
||||
action: {
|
||||
hover: alpha(accentColor || "#6366F1", 0.16),
|
||||
hover: alpha(ACCENT, 0.16),
|
||||
disabled: alpha("#FFFFFF", 0.5),
|
||||
disabledBackground,
|
||||
},
|
||||
@@ -196,9 +200,9 @@ function buildTypography() {
|
||||
};
|
||||
}
|
||||
|
||||
export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
|
||||
const lightPalette = buildLightPalette(accentColor);
|
||||
const darkPalette = buildDarkPalette(accentColor);
|
||||
export const getTheme = (_mode: "light" | "dark") => {
|
||||
const lightPalette = buildLightPalette();
|
||||
const darkPalette = buildDarkPalette();
|
||||
|
||||
const theme = createTheme({
|
||||
breakpoints: {
|
||||
|
||||
@@ -19,17 +19,3 @@ export function getThemeModePref(): ThemeModePref {
|
||||
export function setThemeModePref(v: ThemeModePref) {
|
||||
window.localStorage.setItem(k("themeMode"), v);
|
||||
}
|
||||
|
||||
export function getAccentColor(): string {
|
||||
const raw = window.localStorage.getItem(k("accentColor"));
|
||||
if (raw && /^#[0-9a-fA-F]{6}$/.test(raw)) return raw;
|
||||
return "#6366f1";
|
||||
}
|
||||
|
||||
export function setAccentColor(v: string) {
|
||||
if (v && /^#[0-9a-fA-F]{6}$/.test(v)) window.localStorage.setItem(k("accentColor"), v);
|
||||
}
|
||||
|
||||
export function clearAccentColor() {
|
||||
window.localStorage.removeItem(k("accentColor"));
|
||||
}
|
||||
|
||||
@@ -82,10 +82,11 @@ export default function CorrespondenceInboxPage() {
|
||||
Cross-job view of imported correspondence and Gmail-linked history.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
|
||||
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
|
||||
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
|
||||
<Button variant="outlined" size="small" onClick={() => navigate("/correspondence/review")}>Review Gmail queue</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ export default function GmailReviewPage() {
|
||||
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
<Button variant="text" onClick={() => navigate("/correspondence")}>Back to inbox</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function LandingPage() {
|
||||
let active = true;
|
||||
api
|
||||
.get("/auth/me")
|
||||
.then(() => { if (active) navigate("/jobs", { replace: true }); })
|
||||
.then(() => { if (active) navigate("/dashboard", { replace: true }); })
|
||||
.catch(() => { if (active) setChecking(false); });
|
||||
return () => { active = false; };
|
||||
}, [navigate]);
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function LoginPage() {
|
||||
const [rememberMe, setRememberMe] = useState(() => getRememberMePref());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const nextPath = (location?.state?.from as string | undefined) ?? "/jobs";
|
||||
const nextPath = (location?.state?.from as string | undefined) ?? "/dashboard";
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
|
||||
@@ -10,6 +10,8 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import AuthStatusCard from "../components/AuthStatusCard";
|
||||
import EmailProviderConnections from "../components/EmailProviderConnections";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -562,8 +564,12 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<AuthStatusCard />
|
||||
<GoogleAuthCard />
|
||||
<MicrosoftAuthCard />
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<EmailProviderConnections />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
|
||||
Reference in New Issue
Block a user