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)
restheadspec.SetupMuxRoutes(apiV1Router, handler, nil)
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
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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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">
</head>
<body>

View File

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

View File

@@ -9,6 +9,9 @@ import {
Avatar,
Stack,
Image,
ActionIcon,
Tooltip,
useMantineColorScheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import {
@@ -24,11 +27,14 @@ import {
IconFileText,
IconDatabase,
IconLogout,
IconSun,
IconMoon,
} from "@tabler/icons-react";
import { useAuthStore } from "../stores/authStore";
export default function DashboardLayout() {
const { user, logout } = useAuthStore();
const { colorScheme, setColorScheme } = useMantineColorScheme();
const navigate = useNavigate();
const location = useLocation();
const [opened, { toggle }] = useDisclosure();
@@ -49,6 +55,17 @@ export default function DashboardLayout() {
const displayInitial = displayName[0]?.toUpperCase() || "U";
const logoSrc = `${import.meta.env.BASE_URL}logo.png`;
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 (
<AppShell
@@ -261,6 +278,16 @@ export default function DashboardLayout() {
<AppShell.Section>
<Stack gap="xs">
<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>
<Text size="sm" fw={500}>
{displayName}

View File

@@ -38,11 +38,16 @@ function getAppBasePath(pathname: string): 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 appBasePath = getAppBasePath(window.location.pathname);
if (hostname === "localhost" || hostname === "127.0.0.1") return "http://localhost:8080";
return `${protocol}//${hostname}${port ? `:${port}` : ""}${appBasePath}`.replace(/\/+$/, "");
if (hostname === "localhost" || hostname === "127.0.0.1")
return "http://localhost:8080";
return `${protocol}//${hostname}${port ? `:${port}` : ""}${appBasePath}`.replace(
/\/+$/,
"",
);
}
const API_BASE_URL = getApiBaseUrl();
@@ -73,7 +78,9 @@ function normalizeUser(raw: unknown): User | null {
const resolvedRole =
(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";
const claims =
@@ -91,7 +98,8 @@ function normalizeUser(raw: unknown): User | null {
id: resolvedID,
username: resolvedUsername,
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",
active: typeof value.active === "boolean" ? value.active : true,
created_at: typeof value.created_at === "string" ? value.created_at : "",
@@ -128,13 +136,17 @@ class ApiClient {
if (error.response?.status === 401) {
const requestPath = getNormalizedPathFromURL(error.config?.url || "");
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 isV1APIRequest = requestPath.startsWith("/api/v1/");
// 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.
this.clearAuth();
//this.clearAuth();
window.location.href = LOGIN_UI_PATH;
}
}
@@ -301,39 +313,45 @@ class ApiClient {
}
async getAccountConfigs(): Promise<WhatsAppAccountConfig[]> {
const { data } = await this.client.get<WhatsAppAccountConfig[]>("/api/accounts");
const { data } =
await this.client.get<WhatsAppAccountConfig[]>("/api/accounts");
return data;
}
async getAccountStatuses(): Promise<{ statuses: WhatsAppAccountRuntimeStatus[] }> {
const { data } = await this.client.get<{ statuses: WhatsAppAccountRuntimeStatus[] }>(
"/api/accounts/status",
);
async getAccountStatuses(): Promise<{
statuses: WhatsAppAccountRuntimeStatus[];
}> {
const { data } = await this.client.get<{
statuses: WhatsAppAccountRuntimeStatus[];
}>("/api/accounts/status");
return data;
}
async addAccountConfig(
account: WhatsAppAccountConfig,
): Promise<{ status: string; account_id: string }> {
const { data } = await this.client.post<{ status: string; account_id: string }>(
"/api/accounts/add",
account,
);
const { data } = await this.client.post<{
status: string;
account_id: string;
}>("/api/accounts/add", account);
return data;
}
async updateAccountConfig(
account: WhatsAppAccountConfig,
): Promise<{ status: string; account_id: string }> {
const { data } = await this.client.post<{ status: string; account_id: string }>(
"/api/accounts/update",
account,
);
const { data } = await this.client.post<{
status: string;
account_id: string;
}>("/api/accounts/update", account);
return data;
}
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;
}
@@ -341,10 +359,10 @@ class ApiClient {
endpoint: string,
payload: Record<string, unknown>,
): Promise<{ status?: string; [key: string]: unknown }> {
const { data } = await this.client.post<{ status?: string; [key: string]: unknown }>(
endpoint,
payload,
);
const { data } = await this.client.post<{
status?: string;
[key: string]: unknown;
}>(endpoint, payload);
return data;
}
@@ -420,7 +438,9 @@ class ApiClient {
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>>(
"/api/templates/upload",
payload,
@@ -449,10 +469,9 @@ class ApiClient {
}
async listFlows(accountId: string): Promise<FlowListResponse> {
const { data } = await this.client.post<FlowListResponse>(
"/api/flows",
{ account_id: accountId },
);
const { data } = await this.client.post<FlowListResponse>("/api/flows", {
account_id: accountId,
});
return data;
}
@@ -462,17 +481,21 @@ class ApiClient {
offset?: number;
sort?: string;
search?: string;
}): Promise<{ data: EventLog[]; meta: { total: number; limit: number; offset: number } }> {
const headers: Record<string, string> = { 'X-DetailApi': 'true' };
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;
}): Promise<{
data: EventLog[];
meta: { total: number; limit: number; offset: number };
}> {
const headers: Record<string, string> = { "X-DetailApi": "true" };
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 } }>(
'/api/v1/event_logs',
{ headers },
);
const { data } = await this.client.get<{
data: EventLog[];
meta: { total: number; limit: number; offset: number };
}>("/api/v1/event_logs", { headers });
return data;
}
@@ -484,7 +507,8 @@ class ApiClient {
}): Promise<MessageCacheListResponse> {
const searchParams = new URLSearchParams();
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);
const query = searchParams.toString();
@@ -494,21 +518,31 @@ class ApiClient {
}
async getMessageCacheStats(): Promise<MessageCacheStats> {
const { data } = await this.client.get<MessageCacheStats>("/api/cache/stats");
return data;
}
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)}`,
const { data } = await this.client.get<MessageCacheStats>(
"/api/v1/cache/stats",
);
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)}`,
);
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;
}
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;
}
@@ -529,17 +563,26 @@ class ApiClient {
return data;
}
async clearMessageCache(): Promise<{ success: boolean; cleared: number; message: string }> {
const { data } = await this.client.delete<{ success: boolean; cleared: number; message: string }>(
"/api/cache/clear?confirm=true",
);
async clearMessageCache(): Promise<{
success: boolean;
cleared: number;
message: string;
}> {
const { data } = await this.client.delete<{
success: boolean;
cleared: number;
message: string;
}>("/api/cache/clear?confirm=true");
return data;
}
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",
});
},
);
return data;
}