feat(ui): add theme toggle to dashboard layout
Some checks failed
CI / Build (push) Failing after -30m38s
CI / Lint (push) Failing after -30m36s
CI / Test (1.22) (push) Failing after -30m39s
CI / Test (1.23) (push) Failing after -30m39s

- Implement theme switching between light and dark modes
- Use Mantine's color scheme for automatic detection
- Add tooltip for theme toggle button
- Update App component to use 'auto' color scheme
This commit is contained in:
2026-03-05 01:25:03 +02:00
parent 6f8bac131c
commit aaf6ad473a
8 changed files with 204 additions and 133 deletions

View File

@@ -103,6 +103,7 @@ func NewServer(cfg *config.Config, db *bun.DB, wh WhatsHookedInterface) (*Server
// Setup ResolveSpec routes on the protected /api/v1 subrouter (auto-generated CRUD) // Setup ResolveSpec routes on the protected /api/v1 subrouter (auto-generated CRUD)
restheadspec.SetupMuxRoutes(apiV1Router, handler, nil) restheadspec.SetupMuxRoutes(apiV1Router, handler, nil)
apiV1Router.HandleFunc("/system/stats", handleSystemStats).Methods("GET") apiV1Router.HandleFunc("/system/stats", handleSystemStats).Methods("GET")
apiV1Router.HandleFunc("/cache/stats", wh.Handlers().GetCacheStats).Methods("GET")
// Add custom routes (login, logout, etc.) on main router // Add custom routes (login, logout, etc.) on main router
SetupCustomRoutes(router, secProvider, db) SetupCustomRoutes(router, secProvider, db)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/x-icon" href="favicon.ico" /> <link rel="icon" type="image/x-icon" href="favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title> <title>web</title>
<script type="module" crossorigin src="/ui/assets/index-ByFXF3HF.js"></script> <script type="module" crossorigin src="/ui/assets/index-BESUgSQy.js"></script>
<link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css"> <link rel="stylesheet" crossorigin href="/ui/assets/index-Bfia8Lvm.css">
</head> </head>
<body> <body>

View File

@@ -32,7 +32,7 @@ function App() {
}, [checkAuth]); }, [checkAuth]);
return ( return (
<MantineProvider defaultColorScheme="light"> <MantineProvider defaultColorScheme="auto">
<Notifications position="top-right" /> <Notifications position="top-right" />
<ModalsProvider> <ModalsProvider>
<BrowserRouter basename="/ui"> <BrowserRouter basename="/ui">

View File

@@ -9,6 +9,9 @@ import {
Avatar, Avatar,
Stack, Stack,
Image, Image,
ActionIcon,
Tooltip,
useMantineColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { import {
@@ -24,11 +27,14 @@ import {
IconFileText, IconFileText,
IconDatabase, IconDatabase,
IconLogout, IconLogout,
IconSun,
IconMoon,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useAuthStore } from "../stores/authStore"; import { useAuthStore } from "../stores/authStore";
export default function DashboardLayout() { export default function DashboardLayout() {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [opened, { toggle }] = useDisclosure(); const [opened, { toggle }] = useDisclosure();
@@ -49,6 +55,17 @@ export default function DashboardLayout() {
const displayInitial = displayName[0]?.toUpperCase() || "U"; const displayInitial = displayName[0]?.toUpperCase() || "U";
const logoSrc = `${import.meta.env.BASE_URL}logo.png`; const logoSrc = `${import.meta.env.BASE_URL}logo.png`;
const swaggerIconSrc = `${import.meta.env.BASE_URL}swagger-icon.svg`; const swaggerIconSrc = `${import.meta.env.BASE_URL}swagger-icon.svg`;
const isDark = colorScheme === "dark";
const toggleTheme = () => {
if (colorScheme === "auto") {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
setColorScheme(prefersDark ? "light" : "dark");
return;
}
setColorScheme(isDark ? "light" : "dark");
};
return ( return (
<AppShell <AppShell
@@ -261,6 +278,16 @@ export default function DashboardLayout() {
<AppShell.Section> <AppShell.Section>
<Stack gap="xs"> <Stack gap="xs">
<Group justify="space-between" px="sm"> <Group justify="space-between" px="sm">
<Tooltip label={isDark ? "Switch to light theme" : "Switch to dark theme"}>
<ActionIcon
variant="light"
size="lg"
onClick={toggleTheme}
aria-label={isDark ? "Switch to light theme" : "Switch to dark theme"}
>
{isDark ? <IconSun size={18} /> : <IconMoon size={18} />}
</ActionIcon>
</Tooltip>
<div> <div>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{displayName} {displayName}

View File

@@ -38,11 +38,16 @@ function getAppBasePath(pathname: string): string {
} }
function getApiBaseUrl(): string { function getApiBaseUrl(): string {
if (import.meta.env.VITE_API_URL) return import.meta.env.VITE_API_URL.replace(/\/+$/, ""); if (import.meta.env.VITE_API_URL)
return import.meta.env.VITE_API_URL.replace(/\/+$/, "");
const { hostname, protocol, port } = window.location; const { hostname, protocol, port } = window.location;
const appBasePath = getAppBasePath(window.location.pathname); const appBasePath = getAppBasePath(window.location.pathname);
if (hostname === "localhost" || hostname === "127.0.0.1") return "http://localhost:8080"; if (hostname === "localhost" || hostname === "127.0.0.1")
return `${protocol}//${hostname}${port ? `:${port}` : ""}${appBasePath}`.replace(/\/+$/, ""); return "http://localhost:8080";
return `${protocol}//${hostname}${port ? `:${port}` : ""}${appBasePath}`.replace(
/\/+$/,
"",
);
} }
const API_BASE_URL = getApiBaseUrl(); const API_BASE_URL = getApiBaseUrl();
@@ -73,7 +78,9 @@ function normalizeUser(raw: unknown): User | null {
const resolvedRole = const resolvedRole =
(typeof value.role === "string" && value.role) || (typeof value.role === "string" && value.role) ||
(Array.isArray(value.roles) && typeof value.roles[0] === "string" && value.roles[0]) || (Array.isArray(value.roles) &&
typeof value.roles[0] === "string" &&
value.roles[0]) ||
"user"; "user";
const claims = const claims =
@@ -91,7 +98,8 @@ function normalizeUser(raw: unknown): User | null {
id: resolvedID, id: resolvedID,
username: resolvedUsername, username: resolvedUsername,
email: typeof value.email === "string" ? value.email : "", email: typeof value.email === "string" ? value.email : "",
full_name: typeof value.full_name === "string" ? value.full_name : undefined, full_name:
typeof value.full_name === "string" ? value.full_name : undefined,
role: resolvedRole === "admin" ? "admin" : "user", role: resolvedRole === "admin" ? "admin" : "user",
active: typeof value.active === "boolean" ? value.active : true, active: typeof value.active === "boolean" ? value.active : true,
created_at: typeof value.created_at === "string" ? value.created_at : "", created_at: typeof value.created_at === "string" ? value.created_at : "",
@@ -128,13 +136,17 @@ class ApiClient {
if (error.response?.status === 401) { if (error.response?.status === 401) {
const requestPath = getNormalizedPathFromURL(error.config?.url || ""); const requestPath = getNormalizedPathFromURL(error.config?.url || "");
const currentPath = normalizePath(window.location.pathname); const currentPath = normalizePath(window.location.pathname);
const isLoginRequest = requestPath === LOGIN_API_PATH || requestPath.endsWith(LOGIN_API_PATH); const isLoginRequest =
requestPath === LOGIN_API_PATH ||
requestPath.endsWith(LOGIN_API_PATH);
const isLoginPage = currentPath === LOGIN_UI_PATH; const isLoginPage = currentPath === LOGIN_UI_PATH;
const isV1APIRequest = requestPath.startsWith("/api/v1/");
// Keep failed login attempts on the same page. // Keep failed login attempts on the same page.
if (!isLoginRequest && !isLoginPage) { // Only force logout for JWT-protected API v1 requests.
if (isV1APIRequest && !isLoginRequest && !isLoginPage) {
// Token expired or invalid, clear auth and redirect. // Token expired or invalid, clear auth and redirect.
this.clearAuth(); //this.clearAuth();
window.location.href = LOGIN_UI_PATH; window.location.href = LOGIN_UI_PATH;
} }
} }
@@ -301,39 +313,45 @@ class ApiClient {
} }
async getAccountConfigs(): Promise<WhatsAppAccountConfig[]> { async getAccountConfigs(): Promise<WhatsAppAccountConfig[]> {
const { data } = await this.client.get<WhatsAppAccountConfig[]>("/api/accounts"); const { data } =
await this.client.get<WhatsAppAccountConfig[]>("/api/accounts");
return data; return data;
} }
async getAccountStatuses(): Promise<{ statuses: WhatsAppAccountRuntimeStatus[] }> { async getAccountStatuses(): Promise<{
const { data } = await this.client.get<{ statuses: WhatsAppAccountRuntimeStatus[] }>( statuses: WhatsAppAccountRuntimeStatus[];
"/api/accounts/status", }> {
); const { data } = await this.client.get<{
statuses: WhatsAppAccountRuntimeStatus[];
}>("/api/accounts/status");
return data; return data;
} }
async addAccountConfig( async addAccountConfig(
account: WhatsAppAccountConfig, account: WhatsAppAccountConfig,
): Promise<{ status: string; account_id: string }> { ): Promise<{ status: string; account_id: string }> {
const { data } = await this.client.post<{ status: string; account_id: string }>( const { data } = await this.client.post<{
"/api/accounts/add", status: string;
account, account_id: string;
); }>("/api/accounts/add", account);
return data; return data;
} }
async updateAccountConfig( async updateAccountConfig(
account: WhatsAppAccountConfig, account: WhatsAppAccountConfig,
): Promise<{ status: string; account_id: string }> { ): Promise<{ status: string; account_id: string }> {
const { data } = await this.client.post<{ status: string; account_id: string }>( const { data } = await this.client.post<{
"/api/accounts/update", status: string;
account, account_id: string;
); }>("/api/accounts/update", account);
return data; return data;
} }
async removeAccountConfig(id: string): Promise<{ status: string }> { async removeAccountConfig(id: string): Promise<{ status: string }> {
const { data } = await this.client.post<{ status: string }>("/api/accounts/remove", { id }); const { data } = await this.client.post<{ status: string }>(
"/api/accounts/remove",
{ id },
);
return data; return data;
} }
@@ -341,10 +359,10 @@ class ApiClient {
endpoint: string, endpoint: string,
payload: Record<string, unknown>, payload: Record<string, unknown>,
): Promise<{ status?: string; [key: string]: unknown }> { ): Promise<{ status?: string; [key: string]: unknown }> {
const { data } = await this.client.post<{ status?: string; [key: string]: unknown }>( const { data } = await this.client.post<{
endpoint, status?: string;
payload, [key: string]: unknown;
); }>(endpoint, payload);
return data; return data;
} }
@@ -420,7 +438,9 @@ class ApiClient {
return data; return data;
} }
async uploadTemplate(payload: TemplateUploadRequest): Promise<Record<string, unknown>> { async uploadTemplate(
payload: TemplateUploadRequest,
): Promise<Record<string, unknown>> {
const { data } = await this.client.post<Record<string, unknown>>( const { data } = await this.client.post<Record<string, unknown>>(
"/api/templates/upload", "/api/templates/upload",
payload, payload,
@@ -449,10 +469,9 @@ class ApiClient {
} }
async listFlows(accountId: string): Promise<FlowListResponse> { async listFlows(accountId: string): Promise<FlowListResponse> {
const { data } = await this.client.post<FlowListResponse>( const { data } = await this.client.post<FlowListResponse>("/api/flows", {
"/api/flows", account_id: accountId,
{ account_id: accountId }, });
);
return data; return data;
} }
@@ -462,17 +481,21 @@ class ApiClient {
offset?: number; offset?: number;
sort?: string; sort?: string;
search?: string; search?: string;
}): Promise<{ data: EventLog[]; meta: { total: number; limit: number; offset: number } }> { }): Promise<{
const headers: Record<string, string> = { 'X-DetailApi': 'true' }; data: EventLog[];
if (params?.sort) headers['X-Sort'] = params.sort; meta: { total: number; limit: number; offset: number };
if (params?.limit) headers['X-Limit'] = String(params.limit); }> {
if (params?.offset !== undefined) headers['X-Offset'] = String(params.offset); const headers: Record<string, string> = { "X-DetailApi": "true" };
if (params?.search) headers['X-SearchOp-Like-EventType'] = params.search; if (params?.sort) headers["X-Sort"] = params.sort;
if (params?.limit) headers["X-Limit"] = String(params.limit);
if (params?.offset !== undefined)
headers["X-Offset"] = String(params.offset);
if (params?.search) headers["X-SearchOp-Like-EventType"] = params.search;
const { data } = await this.client.get<{ data: EventLog[]; meta: { total: number; limit: number; offset: number } }>( const { data } = await this.client.get<{
'/api/v1/event_logs', data: EventLog[];
{ headers }, meta: { total: number; limit: number; offset: number };
); }>("/api/v1/event_logs", { headers });
return data; return data;
} }
@@ -484,7 +507,8 @@ class ApiClient {
}): Promise<MessageCacheListResponse> { }): Promise<MessageCacheListResponse> {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
if (params?.limit) searchParams.set("limit", String(params.limit)); if (params?.limit) searchParams.set("limit", String(params.limit));
if (params?.offset !== undefined) searchParams.set("offset", String(params.offset)); if (params?.offset !== undefined)
searchParams.set("offset", String(params.offset));
if (params?.eventType) searchParams.set("event_type", params.eventType); if (params?.eventType) searchParams.set("event_type", params.eventType);
const query = searchParams.toString(); const query = searchParams.toString();
@@ -494,21 +518,31 @@ class ApiClient {
} }
async getMessageCacheStats(): Promise<MessageCacheStats> { async getMessageCacheStats(): Promise<MessageCacheStats> {
const { data } = await this.client.get<MessageCacheStats>("/api/cache/stats"); const { data } = await this.client.get<MessageCacheStats>(
return data; "/api/v1/cache/stats",
}
async replayCachedEvent(id: string): Promise<{ success: boolean; event_id: string; message: string }> {
const { data } = await this.client.post<{ success: boolean; event_id: string; message: string }>(
`/api/cache/event/replay?id=${encodeURIComponent(id)}`,
); );
return data; return data;
} }
async deleteCachedEvent(id: string): Promise<{ success: boolean; event_id: string; message: string }> { async replayCachedEvent(
const { data } = await this.client.delete<{ success: boolean; event_id: string; message: string }>( id: string,
`/api/cache/event/delete?id=${encodeURIComponent(id)}`, ): Promise<{ success: boolean; event_id: string; message: string }> {
); const { data } = await this.client.post<{
success: boolean;
event_id: string;
message: string;
}>(`/api/cache/event/replay?id=${encodeURIComponent(id)}`);
return data;
}
async deleteCachedEvent(
id: string,
): Promise<{ success: boolean; event_id: string; message: string }> {
const { data } = await this.client.delete<{
success: boolean;
event_id: string;
message: string;
}>(`/api/cache/event/delete?id=${encodeURIComponent(id)}`);
return data; return data;
} }
@@ -529,17 +563,26 @@ class ApiClient {
return data; return data;
} }
async clearMessageCache(): Promise<{ success: boolean; cleared: number; message: string }> { async clearMessageCache(): Promise<{
const { data } = await this.client.delete<{ success: boolean; cleared: number; message: string }>( success: boolean;
"/api/cache/clear?confirm=true", cleared: number;
); message: string;
}> {
const { data } = await this.client.delete<{
success: boolean;
cleared: number;
message: string;
}>("/api/cache/clear?confirm=true");
return data; return data;
} }
async getQRCode(accountId: string): Promise<Blob> { async getQRCode(accountId: string): Promise<Blob> {
const { data } = await this.client.get<Blob>(`/api/qr/${encodeURIComponent(accountId)}`, { const { data } = await this.client.get<Blob>(
`/api/qr/${encodeURIComponent(accountId)}`,
{
responseType: "blob", responseType: "blob",
}); },
);
return data; return data;
} }