Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33d899c243 | |||
| 86cdafb3ef | |||
| 0e5845a95a | |||
| ffb9888fb4 | |||
| f4503f7b2c |
@@ -5,6 +5,9 @@ AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
|
|||||||
AUTH_ADMIN_EMAIL=admin@example.com
|
AUTH_ADMIN_EMAIL=admin@example.com
|
||||||
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
|
||||||
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
|
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
|
GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET
|
||||||
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
|
# Optional. If omitted, the backend uses https://<your-domain>/api/gmail/oauth/callback
|
||||||
GOOGLE_GMAIL_REDIRECT_URI=
|
GOOGLE_GMAIL_REDIRECT_URI=
|
||||||
|
|||||||
@@ -19,14 +19,14 @@
|
|||||||
},
|
},
|
||||||
"Auth": {
|
"Auth": {
|
||||||
"Require": true,
|
"Require": true,
|
||||||
"AllowRegistration": false,
|
"AllowRegistration": true,
|
||||||
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
|
"JwtKey": "CHANGE_ME_DEV_ONLY_LONG_RANDOM_SECRET",
|
||||||
"JwtIssuer": "JobTrackerApi",
|
"JwtIssuer": "JobTrackerApi",
|
||||||
"JwtAudience": "job-tracker-ui",
|
"JwtAudience": "job-tracker-ui",
|
||||||
"JwtExpiresMinutes": 720,
|
"JwtExpiresMinutes": 720,
|
||||||
"AdminEmail": "admin@example.com",
|
"AdminEmail": "admin@example.com",
|
||||||
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
|
"AdminPassword": "CHANGE_ME_STRONG_DEV_PASSWORD",
|
||||||
"GoogleClientId": "CHANGE_ME_GOOGLE_CLIENT_ID",
|
"GoogleClientId": "723556162227-llqucvpog2esn1dutmtvuul1lv374or6.apps.googleusercontent.com",
|
||||||
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
|
"MicrosoftClientId": "CHANGE_ME_MICROSOFT_CLIENT_ID"
|
||||||
},
|
},
|
||||||
"App": {
|
"App": {
|
||||||
|
|||||||
+3
-1
@@ -19,8 +19,9 @@ services:
|
|||||||
- Auth__JwtKey=${AUTH_JWT_KEY}
|
- Auth__JwtKey=${AUTH_JWT_KEY}
|
||||||
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
|
- Auth__AdminEmail=${AUTH_ADMIN_EMAIL}
|
||||||
- Auth__AdminPassword=${AUTH_ADMIN_PASSWORD}
|
- 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__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
|
||||||
|
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
|
||||||
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
|
- Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET}
|
||||||
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
|
- Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI}
|
||||||
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
|
# Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph
|
||||||
@@ -64,6 +65,7 @@ services:
|
|||||||
shm_size: '1gb'
|
shm_size: '1gb'
|
||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
- 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`
|
# Optional override; default in production is `/api`
|
||||||
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
|
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ FROM node:20-alpine AS build
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
||||||
|
ARG NEXT_PUBLIC_MICROSOFT_CLIENT_ID
|
||||||
ARG NEXT_PUBLIC_API_BASE_URL
|
ARG NEXT_PUBLIC_API_BASE_URL
|
||||||
|
|
||||||
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
|
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
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json .npmrc ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { api } from "./api";
|
|||||||
import { resolveCaptureUrl } from "./captureUrl";
|
import { resolveCaptureUrl } from "./captureUrl";
|
||||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||||
import AppShell, { NavItem } from "./layout/AppShell";
|
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 AddJobModal = lazy(() => import("./components/AddJobModal"));
|
||||||
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
|
const KanbanBoard = lazy(() => import("./components/KanbanBoard"));
|
||||||
@@ -104,7 +104,7 @@ function PageLoader() {
|
|||||||
return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
|
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 location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -297,7 +297,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
<Route path="/admin/system" element={<AdminSystemPage />} />
|
<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="/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 />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -314,19 +314,16 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
|
const systemPrefersDark = useMediaQuery("(prefers-color-scheme: dark)", { defaultMatches: true, noSsr: true });
|
||||||
const [themeMode, setThemeMode] = useState<ThemeModePref>(() => getThemeModePref());
|
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 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(() => {
|
useEffect(() => {
|
||||||
const sync = () => { setThemeMode(getThemeModePref()); setAccentColorState(getAccentColor()); };
|
const sync = () => { setThemeMode(getThemeModePref()); };
|
||||||
window.addEventListener("auth-changed", sync);
|
window.addEventListener("auth-changed", sync);
|
||||||
return () => window.removeEventListener("auth-changed", sync);
|
return () => window.removeEventListener("auth-changed", sync);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onThemeModeChange = (v: ThemeModePref) => { setThemeModePref(v); setThemeMode(v); };
|
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 [jobPageSize, setJobPageSize] = useState<15 | 20 | 25>(() => {
|
||||||
const raw = window.localStorage.getItem("jobPageSize");
|
const raw = window.localStorage.getItem("jobPageSize");
|
||||||
@@ -349,14 +346,14 @@ export default function App() {
|
|||||||
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
|
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
|
||||||
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
|
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
|
||||||
{ path: "/reset-password", element: <ResetPasswordPage />, 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 /> },
|
{ path: "/*", element: <Shell jobPageSize={jobPageSize} setJobPageSize={setJobPageSize} jobColumns={jobColumns} setJobColumns={setJobColumns} themeMode={themeMode} onThemeModeChange={onThemeModeChange} />, errorElement: <RouteErrorPage /> },
|
||||||
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode, accentColor]);
|
], { future: { v7_relativeSplatPath: true } }), [jobColumns, jobPageSize, themeMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<ConfirmProvider>
|
<ConfirmProvider>
|
||||||
<PromptProvider>
|
<PromptProvider>
|
||||||
<CssVarsProvider key={`${effectiveMode}:${accentColor}`} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
<CssVarsProvider key={effectiveMode} theme={theme as any} defaultMode={effectiveMode} disableTransitionOnChange>
|
||||||
<CssBaseline enableColorScheme />
|
<CssBaseline enableColorScheme />
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
<RouterProvider router={router} future={{ v7_startTransition: true }} />
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
LinearProgress,
|
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Select,
|
Select,
|
||||||
Tab,
|
Tab,
|
||||||
@@ -1229,27 +1228,35 @@ function MatchScoreCard({ score, loading }: { score: MatchScore | null; loading:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
<Box sx={{ p: 1.75, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
<Box sx={{ display: "flex", gap: 2.5, alignItems: "center", flexWrap: "wrap", mb: 1.5 }}>
|
||||||
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1 }}>
|
{score.hasEnoughSignal ? (
|
||||||
<Typography variant="h4" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums" }}>{score.hasEnoughSignal ? `${score.score}%` : "—"}</Typography>
|
<Box sx={{ position: "relative", width: 92, height: 92, flexShrink: 0 }}>
|
||||||
<Typography variant="overline">{t("matchScoreTitle")}</Typography>
|
<CircularProgress variant="determinate" value={100} size={92} thickness={4} sx={{ color: "divider", position: "absolute" }} />
|
||||||
|
<CircularProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={score.score}
|
||||||
|
size={92}
|
||||||
|
thickness={4}
|
||||||
|
color={color === "inherit" ? "primary" : color}
|
||||||
|
sx={{ position: "absolute", "& .MuiCircularProgress-circle": { strokeLinecap: "round" } }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 800, fontVariantNumeric: "tabular-nums", lineHeight: 1 }}>{score.score}%</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>{t("matchScoreTitle")}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography variant="h4" sx={{ fontWeight: 800 }}>—</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ flex: 1, minWidth: 200 }}>
|
||||||
|
{!score.hasEnoughSignal ? <Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography> : null}
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
<Chip size="small" color={color === "inherit" ? "default" : color} label={bandLabel} />
|
||||||
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
<Chip size="small" variant="outlined" label={t("matchScoreKeywordsCovered", { matched: score.matchedCount, total: score.totalKeywords })} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
{score.hasEnoughSignal ? (
|
|
||||||
<LinearProgress
|
|
||||||
variant="determinate"
|
|
||||||
value={score.score}
|
|
||||||
color={color === "inherit" ? "primary" : color}
|
|
||||||
sx={{ height: 8, borderRadius: 4, mb: 1.5 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1 }}>{t("matchScoreNoSignal")}</Typography>
|
|
||||||
)}
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mb: 1 }}>{t("matchScoreDeterministicHint")}</Typography>
|
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
<Typography variant="overline">{t("matchScoreMatched")}</Typography>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -9,11 +9,9 @@ import {
|
|||||||
InputLabel,
|
InputLabel,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
Popover,
|
|
||||||
Select,
|
Select,
|
||||||
Tab,
|
Tab,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextField,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
|
||||||
@@ -21,12 +19,9 @@ import { useNavigate } from "react-router-dom";
|
|||||||
|
|
||||||
import { JobTableColumns } from "./JobTable";
|
import { JobTableColumns } from "./JobTable";
|
||||||
import ImportExportJobs from "./ImportExportJobs";
|
import ImportExportJobs from "./ImportExportJobs";
|
||||||
import GoogleAuthCard from "./GoogleAuthCard";
|
|
||||||
import EmailProviderConnections from "./EmailProviderConnections";
|
|
||||||
import RulesSettingsCard from "./RulesSettingsCard";
|
import RulesSettingsCard from "./RulesSettingsCard";
|
||||||
import BackupCard from "./BackupCard";
|
import BackupCard from "./BackupCard";
|
||||||
import QuickCaptureCard from "./QuickCaptureCard";
|
import QuickCaptureCard from "./QuickCaptureCard";
|
||||||
import AuthStatusCard from "./AuthStatusCard";
|
|
||||||
import { ThemeModePref } from "../themePrefs";
|
import { ThemeModePref } from "../themePrefs";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
|
||||||
@@ -37,17 +32,23 @@ interface Props {
|
|||||||
onColumnsChange: (next: JobTableColumns) => void;
|
onColumnsChange: (next: JobTableColumns) => void;
|
||||||
themeMode: ThemeModePref;
|
themeMode: ThemeModePref;
|
||||||
onThemeModeChange: (v: ThemeModePref) => void;
|
onThemeModeChange: (v: ThemeModePref) => void;
|
||||||
accentColor: string;
|
|
||||||
onAccentColorChange: (v: string) => void;
|
|
||||||
onResetAccentColor: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
|
function TabPanel({ value, index, children }: { value: number; index: number; children: React.ReactNode }) {
|
||||||
if (value !== index) return null;
|
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";
|
const NOTIFICATION_PREFS_KEY = "settings.notificationPrefs";
|
||||||
|
|
||||||
type NotificationPrefs = {
|
type NotificationPrefs = {
|
||||||
@@ -88,43 +89,19 @@ export default function SettingsView({
|
|||||||
onColumnsChange,
|
onColumnsChange,
|
||||||
themeMode,
|
themeMode,
|
||||||
onThemeModeChange,
|
onThemeModeChange,
|
||||||
accentColor,
|
|
||||||
onAccentColorChange,
|
|
||||||
onResetAccentColor,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
const { language, setLanguage, t } = useI18n();
|
const { language, setLanguage, t } = useI18n();
|
||||||
const [accentAnchor, setAccentAnchor] = useState<HTMLElement | null>(null);
|
|
||||||
const [accentDraft, setAccentDraft] = useState(accentColor);
|
|
||||||
const [notificationPrefs, setNotificationPrefs] = useState<NotificationPrefs>(() => loadNotificationPrefs());
|
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(() => {
|
useEffect(() => {
|
||||||
window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs));
|
window.localStorage.setItem(NOTIFICATION_PREFS_KEY, JSON.stringify(notificationPrefs));
|
||||||
}, [notificationPrefs]);
|
}, [notificationPrefs]);
|
||||||
|
|
||||||
const applyAccent = () => {
|
|
||||||
if (!accentDraftOk) return;
|
|
||||||
onAccentColorChange(accentDraft);
|
|
||||||
setAccentAnchor(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetAccent = () => {
|
|
||||||
onResetAccentColor();
|
|
||||||
setAccentDraft("#15803d");
|
|
||||||
setAccentAnchor(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper sx={{ mt: 0, p: 2 }}>
|
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||||
<Typography variant="h5" sx={{ mb: 1, fontWeight: 900 }}>
|
<Typography variant="h5" sx={{ mb: 0.5, fontWeight: 900 }}>
|
||||||
{t("settingsTitle")}
|
{t("settingsTitle")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
<Typography sx={{ color: "text.secondary", mb: 2 }}>
|
||||||
@@ -135,16 +112,14 @@ export default function SettingsView({
|
|||||||
<Tab label={t("settingsTabGeneral")} />
|
<Tab label={t("settingsTabGeneral")} />
|
||||||
<Tab label={t("settingsTabFollowUps")} />
|
<Tab label={t("settingsTabFollowUps")} />
|
||||||
<Tab label={t("settingsTabNotifications")} />
|
<Tab label={t("settingsTabNotifications")} />
|
||||||
<Tab label={t("settingsTabAccount")} />
|
|
||||||
<Tab label={t("settingsTabBackup")} />
|
<Tab label={t("settingsTabBackup")} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<TabPanel value={tab} index={0}>
|
<TabPanel value={tab} index={0}>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
<Box sx={{ display: "grid", gap: 2.5 }}>
|
||||||
<Paper sx={{ p: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2.5 }}>
|
||||||
<Typography sx={{ fontWeight: 950, mb: 1 }}>{t("settingsAppearance")}</Typography>
|
<SectionCard title={t("settingsAppearance")}>
|
||||||
|
<FormControl fullWidth>
|
||||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
|
||||||
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
|
<InputLabel id="theme-mode-label">{t("settingsTheme")}</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
labelId="theme-mode-label"
|
labelId="theme-mode-label"
|
||||||
@@ -157,85 +132,10 @@ export default function SettingsView({
|
|||||||
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
|
<MenuItem value="light">{t("settingsThemeLight")}</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, flexWrap: "wrap" }}>
|
<SectionCard title={t("settingsLanguageTitle")} subtitle={t("settingsLanguageBody")}>
|
||||||
<Box>
|
<FormControl fullWidth>
|
||||||
<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={{ 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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
<InputLabel id="language-label">{t("settingsPreferredLanguage")}</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
labelId="language-label"
|
labelId="language-label"
|
||||||
@@ -247,18 +147,13 @@ export default function SettingsView({
|
|||||||
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
|
<MenuItem value="no">{t("settingsNorwegian")}</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
</SectionCard>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<SectionCard title={t("settingsJobs")}>
|
||||||
{t("settingsMorePagesSoon")}
|
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
||||||
</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" }}>
|
|
||||||
<Box sx={{ minWidth: 240 }}>
|
<Box sx={{ minWidth: 240 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||||
{t("settingsPagination")}
|
{t("settingsPagination")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
@@ -277,7 +172,7 @@ export default function SettingsView({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ minWidth: 240 }}>
|
<Box sx={{ minWidth: 240 }}>
|
||||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||||
{t("settingsColumns")}
|
{t("settingsColumns")}
|
||||||
</Typography>
|
</Typography>
|
||||||
{(
|
{(
|
||||||
@@ -297,8 +192,10 @@ export default function SettingsView({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
<ImportExportJobs />
|
<ImportExportJobs />
|
||||||
</Paper>
|
</Box>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<QuickCaptureCard />
|
<QuickCaptureCard />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -309,9 +206,7 @@ export default function SettingsView({
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tab} index={2}>
|
<TabPanel value={tab} index={2}>
|
||||||
<Paper sx={{ p: 2 }}>
|
<SectionCard title={t("settingsNotificationsTitle")} subtitle={t("settingsNotificationsBody")}>
|
||||||
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsNotificationsTitle")}</Typography>
|
|
||||||
<Typography sx={{ color: "text.secondary", mb: 2 }}>{t("settingsNotificationsBody")}</Typography>
|
|
||||||
<Box sx={{ display: "grid", gap: 1 }}>
|
<Box sx={{ display: "grid", gap: 1 }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Checkbox checked={notificationPrefs.emailFollowUpReminders} onChange={(e) => setNotificationPrefs((prev) => ({ ...prev, emailFollowUpReminders: e.target.checked }))} />}
|
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="outlined" onClick={() => navigate("/reminders")}>{t("settingsOpenReminderInbox")}</Button>
|
||||||
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
|
<Button variant="text" onClick={() => navigate("/admin/system")}>{t("settingsCheckSystemStatus")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</SectionCard>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={tab} index={3}>
|
<TabPanel value={tab} index={3}>
|
||||||
<AuthStatusCard />
|
|
||||||
<GoogleAuthCard />
|
|
||||||
<Box sx={{ mt: 2 }}>
|
|
||||||
<EmailProviderConnections />
|
|
||||||
</Box>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel value={tab} index={4}>
|
|
||||||
<BackupCard />
|
<BackupCard />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|||||||
@@ -128,22 +128,17 @@ export const translations = {
|
|||||||
settingsTabGeneral: "General",
|
settingsTabGeneral: "General",
|
||||||
settingsTabFollowUps: "Follow-ups",
|
settingsTabFollowUps: "Follow-ups",
|
||||||
settingsTabNotifications: "Notifications",
|
settingsTabNotifications: "Notifications",
|
||||||
settingsTabAccount: "Account",
|
|
||||||
settingsTabBackup: "Backup",
|
settingsTabBackup: "Backup",
|
||||||
settingsAppearance: "Appearance",
|
settingsAppearance: "Appearance",
|
||||||
settingsTheme: "Theme",
|
settingsTheme: "Theme",
|
||||||
settingsThemeSystem: "System",
|
settingsThemeSystem: "System",
|
||||||
settingsThemeDark: "Dark",
|
settingsThemeDark: "Dark",
|
||||||
settingsThemeLight: "Light",
|
settingsThemeLight: "Light",
|
||||||
settingsAccent: "Accent",
|
|
||||||
settingsReset: "Reset",
|
|
||||||
settingsSavedPerUser: "Saved per user on this browser.",
|
|
||||||
settingsLanguageTitle: "Language and localization",
|
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.",
|
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",
|
settingsPreferredLanguage: "Preferred language",
|
||||||
settingsEnglish: "English",
|
settingsEnglish: "English",
|
||||||
settingsNorwegian: "Norwegian Bokmål",
|
settingsNorwegian: "Norwegian Bokmål",
|
||||||
settingsMorePagesSoon: "More pages will be moved onto this translation system as the UI cleanup continues.",
|
|
||||||
settingsJobs: "Jobs",
|
settingsJobs: "Jobs",
|
||||||
settingsPagination: "Pagination",
|
settingsPagination: "Pagination",
|
||||||
settingsRowsPerPage: "Rows per page",
|
settingsRowsPerPage: "Rows per page",
|
||||||
@@ -167,8 +162,6 @@ export const translations = {
|
|||||||
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
|
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
|
||||||
settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs",
|
settingsNotificationsGhostedJobs: "Email alerts for ghosted jobs",
|
||||||
settingsNotificationsInAppReminders: "Highlight reminders in the app",
|
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",
|
settingsCheckSystemStatus: "Check system status",
|
||||||
profileTitle: "Profile",
|
profileTitle: "Profile",
|
||||||
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
|
profileHeadlinePlaceholder: "Add a short headline to personalize your account view.",
|
||||||
@@ -629,7 +622,7 @@ export const translations = {
|
|||||||
googleUnlinked: "Google account unlinked.",
|
googleUnlinked: "Google account unlinked.",
|
||||||
googleUnlinkFailed: "Failed to unlink Google account.",
|
googleUnlinkFailed: "Failed to unlink Google account.",
|
||||||
microsoftAccountTitle: "Microsoft 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",
|
microsoftLinked: "Linked",
|
||||||
microsoftAvailableToLink: "Available to link",
|
microsoftAvailableToLink: "Available to link",
|
||||||
microsoftLinkedDate: "Linked {date}",
|
microsoftLinkedDate: "Linked {date}",
|
||||||
@@ -1093,22 +1086,17 @@ export const translations = {
|
|||||||
settingsTabGeneral: "Generelt",
|
settingsTabGeneral: "Generelt",
|
||||||
settingsTabFollowUps: "Oppfølging",
|
settingsTabFollowUps: "Oppfølging",
|
||||||
settingsTabNotifications: "Varsler",
|
settingsTabNotifications: "Varsler",
|
||||||
settingsTabAccount: "Konto",
|
|
||||||
settingsTabBackup: "Sikkerhetskopi",
|
settingsTabBackup: "Sikkerhetskopi",
|
||||||
settingsAppearance: "Utseende",
|
settingsAppearance: "Utseende",
|
||||||
settingsTheme: "Tema",
|
settingsTheme: "Tema",
|
||||||
settingsThemeSystem: "System",
|
settingsThemeSystem: "System",
|
||||||
settingsThemeDark: "Mørkt",
|
settingsThemeDark: "Mørkt",
|
||||||
settingsThemeLight: "Lyst",
|
settingsThemeLight: "Lyst",
|
||||||
settingsAccent: "Aksent",
|
|
||||||
settingsReset: "Tilbakestill",
|
|
||||||
settingsSavedPerUser: "Lagres per bruker i denne nettleseren.",
|
|
||||||
settingsLanguageTitle: "Språk og lokalisering",
|
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.",
|
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",
|
settingsPreferredLanguage: "Foretrukket språk",
|
||||||
settingsEnglish: "Engelsk",
|
settingsEnglish: "Engelsk",
|
||||||
settingsNorwegian: "Norsk Bokmål",
|
settingsNorwegian: "Norsk Bokmål",
|
||||||
settingsMorePagesSoon: "Flere sider flyttes til dette oversettelsessystemet etter hvert som UI-oppryddingen fortsetter.",
|
|
||||||
settingsJobs: "Jobber",
|
settingsJobs: "Jobber",
|
||||||
settingsPagination: "Paginering",
|
settingsPagination: "Paginering",
|
||||||
settingsRowsPerPage: "Rader per side",
|
settingsRowsPerPage: "Rader per side",
|
||||||
@@ -1132,8 +1120,6 @@ export const translations = {
|
|||||||
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
|
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
|
||||||
settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber",
|
settingsNotificationsGhostedJobs: "E-postvarsler for ghostede jobber",
|
||||||
settingsNotificationsInAppReminders: "Fremhev påminnelser i appen",
|
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",
|
settingsCheckSystemStatus: "Sjekk systemstatus",
|
||||||
profileTitle: "Profil",
|
profileTitle: "Profil",
|
||||||
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
|
profileHeadlinePlaceholder: "Legg til en kort overskrift for å gjøre kontovisningen mer personlig.",
|
||||||
@@ -1594,7 +1580,7 @@ export const translations = {
|
|||||||
googleUnlinked: "Google-konto koblet fra.",
|
googleUnlinked: "Google-konto koblet fra.",
|
||||||
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
|
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
|
||||||
microsoftAccountTitle: "Microsoft-konto",
|
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",
|
microsoftLinked: "Koblet",
|
||||||
microsoftAvailableToLink: "Tilgjengelig for kobling",
|
microsoftAvailableToLink: "Tilgjengelig for kobling",
|
||||||
microsoftLinkedDate: "Koblet {date}",
|
microsoftLinkedDate: "Koblet {date}",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import '@testing-library/jest-dom';
|
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 { MemoryRouter } from 'react-router-dom';
|
||||||
|
|
||||||
import SettingsView from './components/SettingsView';
|
import SettingsView from './components/SettingsView';
|
||||||
@@ -21,16 +21,12 @@ jest.mock('./api', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('./components/ImportExportJobs', () => () => <div>Import Export Stub</div>);
|
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/BackupCard', () => () => <div>Backup Stub</div>);
|
||||||
jest.mock('./components/AuthStatusCard', () => () => <div>Auth Status Stub</div>);
|
|
||||||
|
|
||||||
const mockedApi = api as jest.Mocked<typeof api>;
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
function renderView(onAccentColorChange = jest.fn()) {
|
function renderView() {
|
||||||
return {
|
return render(
|
||||||
onAccentColorChange,
|
|
||||||
...render(
|
|
||||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<I18nProvider>
|
<I18nProvider>
|
||||||
@@ -41,15 +37,11 @@ function renderView(onAccentColorChange = jest.fn()) {
|
|||||||
onColumnsChange={jest.fn()}
|
onColumnsChange={jest.fn()}
|
||||||
themeMode="dark"
|
themeMode="dark"
|
||||||
onThemeModeChange={jest.fn()}
|
onThemeModeChange={jest.fn()}
|
||||||
accentColor="#15803d"
|
|
||||||
onAccentColorChange={onAccentColorChange}
|
|
||||||
onResetAccentColor={jest.fn()}
|
|
||||||
/>
|
/>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
),
|
);
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -76,15 +68,10 @@ afterEach(() => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('settings view uses one follow-up section, one notification section, and staged accent apply', async () => {
|
test('settings view has no accent picker and uses one follow-up section, one notification section', async () => {
|
||||||
const { onAccentColorChange } = renderView();
|
renderView();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /#15803D/i }));
|
expect(screen.queryByText(/accent/i)).not.toBeInTheDocument();
|
||||||
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');
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i }));
|
fireEvent.click(screen.getByRole('tab', { name: /follow-ups/i }));
|
||||||
expect(await screen.findByText(/follow-up rules by scenario/i)).toBeInTheDocument();
|
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>;
|
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) {
|
function buildPrimary(main: string) {
|
||||||
return {
|
return {
|
||||||
lighter: lighten(main, 0.82),
|
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 textPrimary = "#1B1B1F";
|
||||||
const textSecondary = "#46464F";
|
const textSecondary = "#46464F";
|
||||||
|
|
||||||
@@ -24,7 +28,7 @@ function buildLightPalette(accentColor: string): PaletteLike {
|
|||||||
const disabledBackground = "#E4E1E6";
|
const disabledBackground = "#E4E1E6";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
primary: buildPrimary(accentColor || "#6366F1"),
|
primary: buildPrimary(ACCENT),
|
||||||
secondary: {
|
secondary: {
|
||||||
lighter: "#E0E0FF",
|
lighter: "#E0E0FF",
|
||||||
light: "#C3C4E4",
|
light: "#C3C4E4",
|
||||||
@@ -82,14 +86,14 @@ function buildLightPalette(accentColor: string): PaletteLike {
|
|||||||
// from the product mockups; cards/inputs (paper) sit above it.
|
// from the product mockups; cards/inputs (paper) sit above it.
|
||||||
background: { default: "#F4F6FB", paper: background },
|
background: { default: "#F4F6FB", paper: background },
|
||||||
action: {
|
action: {
|
||||||
hover: alpha(accentColor || "#6366F1", 0.05),
|
hover: alpha(ACCENT, 0.05),
|
||||||
disabled: alpha(disabled, 0.6),
|
disabled: alpha(disabled, 0.6),
|
||||||
disabledBackground: alpha(disabledBackground, 0.9),
|
disabledBackground: alpha(disabledBackground, 0.9),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDarkPalette(accentColor: string): PaletteLike {
|
function buildDarkPalette(): PaletteLike {
|
||||||
const bg = "#0B0B0E";
|
const bg = "#0B0B0E";
|
||||||
const paper = "#111116";
|
const paper = "#111116";
|
||||||
const divider = alpha("#FFFFFF", 0.10);
|
const divider = alpha("#FFFFFF", 0.10);
|
||||||
@@ -101,7 +105,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
|
|||||||
const disabledBackground = alpha("#FFFFFF", 0.08);
|
const disabledBackground = alpha("#FFFFFF", 0.08);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
primary: buildPrimary(accentColor || "#6366F1"),
|
primary: buildPrimary(ACCENT),
|
||||||
secondary: {
|
secondary: {
|
||||||
lighter: alpha(secondaryMain, 0.22),
|
lighter: alpha(secondaryMain, 0.22),
|
||||||
light: alpha(secondaryMain, 0.14),
|
light: alpha(secondaryMain, 0.14),
|
||||||
@@ -157,7 +161,7 @@ function buildDarkPalette(accentColor: string): PaletteLike {
|
|||||||
divider,
|
divider,
|
||||||
background: { default: bg, paper },
|
background: { default: bg, paper },
|
||||||
action: {
|
action: {
|
||||||
hover: alpha(accentColor || "#6366F1", 0.16),
|
hover: alpha(ACCENT, 0.16),
|
||||||
disabled: alpha("#FFFFFF", 0.5),
|
disabled: alpha("#FFFFFF", 0.5),
|
||||||
disabledBackground,
|
disabledBackground,
|
||||||
},
|
},
|
||||||
@@ -196,9 +200,9 @@ function buildTypography() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTheme = (_mode: "light" | "dark", accentColor: string) => {
|
export const getTheme = (_mode: "light" | "dark") => {
|
||||||
const lightPalette = buildLightPalette(accentColor);
|
const lightPalette = buildLightPalette();
|
||||||
const darkPalette = buildDarkPalette(accentColor);
|
const darkPalette = buildDarkPalette();
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
breakpoints: {
|
breakpoints: {
|
||||||
|
|||||||
@@ -19,17 +19,3 @@ export function getThemeModePref(): ThemeModePref {
|
|||||||
export function setThemeModePref(v: ThemeModePref) {
|
export function setThemeModePref(v: ThemeModePref) {
|
||||||
window.localStorage.setItem(k("themeMode"), v);
|
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"));
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
|||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||||
|
import AuthStatusCard from "../components/AuthStatusCard";
|
||||||
|
import EmailProviderConnections from "../components/EmailProviderConnections";
|
||||||
import CropImageDialog from "../components/CropImageDialog";
|
import CropImageDialog from "../components/CropImageDialog";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
@@ -562,8 +564,12 @@ export default function ProfilePage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<AuthStatusCard />
|
||||||
<GoogleAuthCard />
|
<GoogleAuthCard />
|
||||||
<MicrosoftAuthCard />
|
<MicrosoftAuthCard />
|
||||||
|
<Box sx={{ mt: 2 }}>
|
||||||
|
<EmailProviderConnections />
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user