feat: Integrate new dashboard design and components, remove old dashboard routes, and update dependencies.

This commit is contained in:
2026-02-10 15:00:11 +08:00
parent 1c2ef98dcd
commit 48cf6c44f5
82 changed files with 6896 additions and 261 deletions

View File

@@ -1,506 +0,0 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: <explanation */
import {
ActionIcon,
Alert,
Badge,
Button,
Card,
Container,
CopyButton,
Group,
LoadingOverlay,
Modal,
Stack,
Switch,
Table,
Text,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { DatePicker, type DatePickerValue } from "@mantine/dates";
import {
IconCalendar,
IconCircleCheck,
IconCircleX,
IconClock,
IconCopy,
IconEye,
IconEyeOff,
IconInfoCircle,
IconKey,
IconPlus,
IconTrash,
} from "@tabler/icons-react";
import { createFileRoute } from "@tanstack/react-router";
import dayjs from "dayjs";
import { useCallback, useEffect, useState } from "react";
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
import { apiClient } from "../../utils/api-client";
export const Route = createFileRoute("/dashboard/apikey")({
beforeLoad: protectedRouteMiddleware,
component: DashboardApikeyComponent,
});
interface ApiKey {
id: string;
name: string;
key: string;
isActive: boolean;
expiresAt: string | null;
createdAt: string;
updatedAt: string;
}
function DashboardApikeyComponent() {
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [newKeyExpiresAt, setNewKeyExpiresAt] = useState<DatePickerValue>(null);
const [creating, setCreating] = useState(false);
const [showKey, setShowKey] = useState<{ [key: string]: boolean }>({});
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [keyToDelete, setKeyToDelete] = useState<string | null>(null);
const fetchApiKeys = useCallback(async () => {
try {
setLoading(true);
const { data, error } = await apiClient.GET("/api/apikey/");
if (data) {
setApiKeys((data.apiKeys as any) || []);
}
if (error) {
setError("Failed to load API keys");
}
} catch (err) {
setError("Failed to load API keys");
console.error(err);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchApiKeys();
}, [fetchApiKeys]);
const handleCreateApiKey = async () => {
if (!newKeyName.trim()) {
setError("API key name is required");
return;
}
try {
setCreating(true);
const { data, error } = await apiClient.POST("/api/apikey/", {
body: {
name: newKeyName,
expiresAt: newKeyExpiresAt
? dayjs(newKeyExpiresAt).toISOString()
: undefined,
},
});
if (data) {
setApiKeys([...apiKeys, data.apiKey as any]);
setNewKeyName("");
setNewKeyExpiresAt(null);
setCreateModalOpen(false);
}
if (error) {
setError("Failed to create API key");
}
} catch (err) {
setError("Failed to create API key");
console.error(err);
} finally {
setCreating(false);
}
};
const handleToggleApiKey = async (id: string, currentStatus: boolean) => {
try {
if (!id) {
setError("API key ID is required");
return;
}
const { data, error } = await apiClient.POST("/api/apikey/update", {
body: {
id,
isActive: !currentStatus,
},
});
if (data) {
setApiKeys(
apiKeys.map((key) =>
key.id === id ? { ...key, isActive: !currentStatus } : key,
),
);
}
if (error) {
setError("Failed to update API key status");
}
} catch (err) {
setError("Failed to update API key status");
console.error(err);
}
};
const handleDeleteApiKey = async (id: string) => {
// Store the key ID and open the confirmation modal
setKeyToDelete(id);
setDeleteModalOpen(true);
};
const confirmDeleteApiKey = async () => {
if (!keyToDelete) return;
try {
const { error } = await apiClient.POST("/api/apikey/delete", {
body: {
id: keyToDelete,
},
});
if (!error) {
setApiKeys(apiKeys.filter((key: ApiKey) => key.id !== keyToDelete));
setDeleteModalOpen(false);
setKeyToDelete(null);
} else {
setError("Failed to delete API key");
}
} catch (err) {
setError("Failed to delete API key");
console.error(err);
}
};
const toggleShowKey = (id: string) => {
setShowKey((prev) => ({
...prev,
[id]: !prev[id],
}));
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString();
};
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
};
return (
<Container size="lg" py="xl">
<Title order={1} mb="lg" ta="center">
API Keys Management
</Title>
{error && (
<Alert title="Error" color="red" mb="md">
{error}
</Alert>
)}
<Card
withBorder
p="xl"
radius="md"
bg="rgba(251, 240, 223, 0.05)"
style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}
>
<Group justify="space-between" mb="md">
<Stack gap={0}>
<Title order={3}>Your API Keys</Title>
<Text size="sm" c="dimmed">
Manage your API keys for secure access to our services
</Text>
</Stack>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setCreateModalOpen(true)}
variant="light"
color="blue"
>
Create New API Key
</Button>
</Group>
<Table striped highlightOnHover mt="md" verticalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>
<Group gap={6}>
<IconKey size={16} stroke={1.5} /> Name
</Group>
</Table.Th>
<Table.Th>
<Group gap={6}>
<IconKey size={16} stroke={1.5} /> Key
</Group>
</Table.Th>
<Table.Th>
<Group gap={6}>
<IconCircleCheck size={16} stroke={1.5} /> Status
</Group>
</Table.Th>
<Table.Th>
<Group gap={6}>
<IconCalendar size={16} stroke={1.5} /> Expiration
</Group>
</Table.Th>
<Table.Th>
<Group gap={6}>
<IconClock size={16} stroke={1.5} /> Created
</Group>
</Table.Th>
<Table.Th>
<Group gap={6}>
<IconInfoCircle size={16} stroke={1.5} /> Actions
</Group>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{apiKeys.map((apiKey) => (
<Table.Tr
key={apiKey.id}
style={{ backgroundColor: "rgba(251, 240, 223, 0.02)" }}
>
<Table.Td>
<Text fw={500} c="#fbf0df">
{apiKey.name}
</Text>
</Table.Td>
<Table.Td>
<Group gap={6}>
{showKey[apiKey.id] ? (
<Text
c="#f3d5a3"
style={{ fontFamily: "monospace", fontSize: "0.85rem" }}
>
{apiKey.key}
</Text>
) : (
<Text
c="dimmed"
style={{ fontFamily: "monospace", fontSize: "0.85rem" }}
>
</Text>
)}
<CopyButton value={apiKey.key}>
{({ copied, copy }) => (
<Tooltip label={copied ? "Copied" : "Copy"}>
<ActionIcon
color={copied ? "green" : "gray"}
onClick={copy}
variant="subtle"
size="sm"
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
)}
</CopyButton>
<Tooltip
label={showKey[apiKey.id] ? "Hide key" : "Show key"}
>
<ActionIcon
color="gray"
onClick={() => toggleShowKey(apiKey.id)}
variant="subtle"
size="sm"
>
{showKey[apiKey.id] ? (
<IconEyeOff size={16} />
) : (
<IconEye size={16} />
)}
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
<Table.Td>
<Group>
<Tooltip
label={`API Key is ${apiKey.isActive ? "Active" : "Inactive"}`}
>
<Switch
checked={apiKey.isActive}
onChange={() =>
handleToggleApiKey(apiKey.id, apiKey.isActive)
}
size="md"
color={apiKey.isActive ? "green" : "gray"}
onLabel={<IconCircleCheck size={12} stroke={1.5} />}
offLabel={<IconCircleX size={12} stroke={1.5} />}
/>
</Tooltip>
</Group>
</Table.Td>
<Table.Td>
{apiKey.expiresAt ? (
<Group>
<Text>{formatDate(apiKey.expiresAt)}</Text>
<Text c="dimmed" size="sm">
{formatTime(apiKey.expiresAt)}
</Text>
</Group>
) : (
<Badge variant="outline" color="blue">
Never Expires
</Badge>
)}
</Table.Td>
<Table.Td>
<Group>
<Text>{formatDate(apiKey.createdAt)}</Text>
<Text c="dimmed" size="sm">
{formatTime(apiKey.createdAt)}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Delete API Key">
<ActionIcon
color="red"
onClick={() => handleDeleteApiKey(apiKey.id)}
variant="light"
size="lg"
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
{apiKeys.length === 0 && !loading && (
<Card
p="xl"
radius="md"
withBorder
mt="xl"
bg="rgba(251, 240, 223, 0.03)"
>
<Group justify="center" align="center">
<Stack align="center" gap="md">
<IconKey
size={48}
stroke={1.2}
color="rgba(251, 240, 223, 0.3)"
/>
<Text ta="center" c="dimmed" fz="lg">
No API keys created yet
</Text>
<Text ta="center" c="dimmed" size="sm">
Get started by creating your first API key
</Text>
<Button
leftSection={<IconPlus size={16} />}
onClick={() => setCreateModalOpen(true)}
variant="light"
color="blue"
mt="md"
>
Create New API Key
</Button>
</Stack>
</Group>
</Card>
)}
</Card>
<Modal
opened={createModalOpen}
onClose={() => {
setCreateModalOpen(false);
setError(null);
}}
title="Create New API Key"
centered
size="md"
>
<LoadingOverlay
visible={creating}
zIndex={1000}
overlayProps={{ radius: "sm", blur: 2 }}
/>
<TextInput
label="API Key Name"
placeholder="Enter a descriptive name for your API key"
value={newKeyName}
onChange={(e) => setNewKeyName(e.currentTarget.value)}
mb="md"
description="Choose a name that identifies the purpose of this API key"
/>
<DatePicker
value={newKeyExpiresAt}
onChange={setNewKeyExpiresAt}
mb="md"
/>
<Group justify="flex-end" mt="xl">
<Button
variant="subtle"
color="gray"
onClick={() => {
setCreateModalOpen(false);
setError(null);
}}
>
Cancel
</Button>
<Button
leftSection={<IconPlus size={16} />}
onClick={handleCreateApiKey}
color="blue"
>
Create API Key
</Button>
</Group>
</Modal>
<Modal
opened={deleteModalOpen}
onClose={() => setDeleteModalOpen(false)}
title="Confirm Delete"
centered
size="md"
>
<Stack>
<Text>Are you sure you want to delete this API key?</Text>
<Text size="sm" c="dimmed">
This action cannot be undone.
</Text>
<Group justify="flex-end" mt="xl">
<Button
variant="subtle"
color="gray"
onClick={() => setDeleteModalOpen(false)}
>
Cancel
</Button>
<Button color="red" onClick={confirmDeleteApiKey}>
Delete API Key
</Button>
</Group>
</Stack>
</Modal>
</Container>
);
}

View File

@@ -1,200 +0,0 @@
import { authClient } from "@/utils/auth-client";
import {
Avatar,
Badge,
Box,
Button,
Card,
Container,
Grid,
Group,
Progress,
SimpleGrid,
Stack,
Text,
Title,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import {
IconClock,
IconDatabase,
IconServer,
IconUserCheck,
} from "@tabler/icons-react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useSnapshot } from "valtio";
import { authStore } from "../../store/auth";
export const Route = createFileRoute("/dashboard/")({
component: DashboardComponent,
});
function DashboardComponent() {
const snap = useSnapshot(authStore);
const navigate = useNavigate();
const openLogoutModal = () =>
modals.openConfirmModal({
title: "Confirm Logout",
centered: true,
children: <Text size="sm">Are you sure you want to log out?</Text>,
labels: { confirm: "Logout", cancel: "Cancel" },
confirmProps: { color: "red" },
onConfirm: async () => {
await authClient.signOut();
navigate({ to: "/signin" });
},
});
// Mock data for dashboard stats
const statsData = [
{ title: "Total Users", value: "1,234", icon: <IconUserCheck size={24} /> },
{ title: "Server Uptime", value: "99.9%", icon: <IconClock size={24} /> },
{ title: "Database Load", value: "42%", icon: <IconDatabase size={24} /> },
{ title: "Active Sessions", value: "128", icon: <IconServer size={24} /> },
];
return (
<Container size="lg" py="xl">
<Title
order={1}
ta="center"
className=" text-blue-600 p-4 rounded-lg mt-10 shadow-lg"
>
Dashboard Overview
</Title>
{/* User Profile Card */}
<Card
withBorder
p="xl"
radius="md"
mb="xl"
style={{ border: "1px solid var(--mantine-color-default-border)" }}
>
<Group justify="space-between">
<Group>
<Avatar
src={snap.user?.image}
size={80}
radius="xl"
style={{
cursor: "pointer",
border: "2px solid var(--mantine-color-orange-filled)",
}}
onClick={() => navigate({ to: "/profile" })}
>
{snap.user?.name?.charAt(0).toUpperCase()}
</Avatar>
<div>
<Text size="lg" fw={600}>
{snap.user?.name}
</Text>
<Text c="dimmed" size="sm">
{snap.user?.email}
</Text>
<Badge mt="xs" variant="light" color="green">
Verified Account
</Badge>
</div>
</Group>
<Button variant="outline" color="red" onClick={openLogoutModal}>
Sign Out
</Button>
</Group>
</Card>
{/* Stats Grid */}
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg" mb="xl">
{statsData.map((stat, index) => (
<Card key={index.toString()} withBorder p="lg" radius="md">
<Group justify="space-between">
<Box>
<Text size="sm" c="dimmed">
{stat.title}
</Text>
<Text size="lg" fw={700}>
{stat.value}
</Text>
</Box>
<Box c="orange.6">{stat.icon}</Box>
</Group>
</Card>
))}
</SimpleGrid>
<Grid gutter="lg">
<Grid.Col span={{ base: 12, md: 8 }}>
<Card withBorder p="lg" radius="md" mb="lg">
<Title order={3} mb="md">
System Performance
</Title>
<Stack gap="md">
<Box>
<Group justify="space-between" mb="xs">
<Text size="sm">CPU Usage</Text>
<Text size="sm" fw={500}>
32%
</Text>
</Group>
<Progress value={32} color="green" />
</Box>
<Box>
<Group justify="space-between" mb="xs">
<Text size="sm">Memory Usage</Text>
<Text size="sm" fw={500}>
64%
</Text>
</Group>
<Progress value={64} color="blue" />
</Box>
<Box>
<Group justify="space-between" mb="xs">
<Text size="sm">Disk Usage</Text>
<Text size="sm" fw={500}>
45%
</Text>
</Group>
<Progress value={45} color="yellow" />
</Box>
</Stack>
</Card>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<Card withBorder p="lg" radius="md">
<Title order={3} mb="md">
Server Status
</Title>
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm">Main Server</Text>
<Badge color="green" variant="light">
Online
</Badge>
</Group>
<Group justify="space-between">
<Text size="sm">Database</Text>
<Badge color="green" variant="light">
Connected
</Badge>
</Group>
<Group justify="space-between">
<Text size="sm">Cache</Text>
<Badge color="green" variant="light">
Running
</Badge>
</Group>
<Group justify="space-between">
<Text size="sm">Backup</Text>
<Badge color="orange" variant="light">
Pending
</Badge>
</Group>
</Stack>
</Card>
</Grid.Col>
</Grid>
</Container>
);
}

View File

@@ -1,332 +0,0 @@
import {
AppShell,
Avatar,
Box,
Burger,
Group,
Menu,
NavLink,
rem,
ScrollArea,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import {
IconChevronRight,
IconHome,
IconKey,
IconLogout,
IconSettings,
IconUser,
IconUsers,
} from "@tabler/icons-react";
import {
createFileRoute,
Outlet,
useLocation,
useNavigate,
} from "@tanstack/react-router";
import { useSnapshot } from "valtio";
import { ColorSchemeToggle } from "@/components/ColorSchemeToggle";
import { protectedRouteMiddleware } from "@/middleware/authMiddleware";
import { authClient } from "@/utils/auth-client";
import { authStore } from "../../store/auth";
export const Route = createFileRoute("/dashboard")({
component: DashboardLayout,
beforeLoad: protectedRouteMiddleware,
onEnter({ context }) {
authStore.user = context?.user as any;
authStore.session = context?.session as any;
},
});
function DashboardLayout() {
const location = useLocation();
const navigate = useNavigate();
const snap = useSnapshot(authStore);
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const navItems = [
{
icon: IconHome,
label: "Beranda",
to: "/dashboard",
description: "Ringkasan sistem & statistik",
},
{
icon: IconUsers,
label: "Pengguna",
to: "/dashboard/users",
description: "Kelola akun & hak akses",
},
{
icon: IconKey,
label: "API Key",
to: "/dashboard/apikey",
description: "Manajemen kunci akses API",
},
{
icon: IconSettings,
label: "Pengaturan",
to: "/dashboard/settings",
description: "Konfigurasi sistem",
},
];
const handleLogout = async () => {
modals.openConfirmModal({
title: "Konfirmasi Keluar",
centered: true,
children: (
<Text size="sm">
Apakah Anda yakin ingin keluar dari sistem? Sesi Anda akan berakhir.
</Text>
),
labels: { confirm: "Keluar", cancel: "Batal" },
confirmProps: { color: "red" },
onConfirm: async () => {
await authClient.signOut();
navigate({ to: "/signin" });
},
});
};
const isActive = (path: string) => {
const current = location.pathname;
if (path === "/dashboard")
return current === "/dashboard" || current === "/dashboard/";
return current.startsWith(path);
};
return (
<AppShell
header={{ height: 70 }}
navbar={{
width: 280,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}}
padding="md"
transitionDuration={500}
transitionTimingFunction="ease"
>
<AppShell.Header
style={{
backdropFilter: "blur(10px)",
borderBottom: "1px solid var(--mantine-color-default-border)",
}}
>
<Group h="100%" px="md" justify="space-between">
<Group gap="xs">
<Burger
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Burger
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom="sm"
size="sm"
/>
<Box visibleFrom="xs" ml="xs">
<Text
fw={800}
size="xl"
c="orange.6"
style={{ letterSpacing: "-0.5px" }}
>
ADMIN
<Text span c="var(--mantine-color-text)">
PANEL
</Text>
</Text>
</Box>
</Group>
<Group gap="md">
<ColorSchemeToggle />
<Menu
shadow="md"
width={200}
position="bottom-end"
transitionProps={{ transition: "pop-top-right" }}
>
<Menu.Target>
<Group
gap="xs"
style={{ cursor: "pointer" }}
p="xs"
className="hover:bg-gray-50 dark:hover:bg-white/5 rounded-md"
>
<div
style={{ textAlign: "right" }}
className="visible-from-sm"
>
<Text size="sm" fw={600}>
{snap.user?.name}
</Text>
<Text size="xs" c="dimmed">
Administrator
</Text>
</div>
<Avatar
src={snap.user?.image}
radius="xl"
size="md"
style={{
border: "2px solid var(--mantine-color-orange-filled)",
}}
>
{snap.user?.name?.charAt(0)}
</Avatar>
</Group>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Akun</Menu.Label>
<Menu.Item
leftSection={
<IconUser style={{ width: rem(14), height: rem(14) }} />
}
onClick={() => navigate({ to: "/profile" })}
>
Profil Saya
</Menu.Item>
<Menu.Item
leftSection={
<IconSettings style={{ width: rem(14), height: rem(14) }} />
}
onClick={() => navigate({ to: "/dashboard/settings" })}
>
Pengaturan
</Menu.Item>
<Menu.Divider />
<Menu.Label>Bahaya</Menu.Label>
<Menu.Item
color="red"
leftSection={
<IconLogout style={{ width: rem(14), height: rem(14) }} />
}
onClick={handleLogout}
>
Keluar Sistem
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar
p="md"
style={{ borderRight: "1px solid var(--mantine-color-default-border)" }}
>
<AppShell.Section grow component={ScrollArea} mx="-md" px="md">
<Stack gap="xs" mt="md">
{navItems.map((item) => (
<Tooltip
key={item.to}
label={item.description}
position="right"
disabled={!desktopOpened}
openDelay={500}
>
<NavLink
onClick={() => {
navigate({ to: item.to });
if (mobileOpened) toggleMobile();
}}
leftSection={
<item.icon
style={{ width: rem(20), height: rem(20) }}
stroke={1.5}
/>
}
label={
<Box>
<Text size="sm" fw={isActive(item.to) ? 700 : 500}>
{item.label}
</Text>
</Box>
}
rightSection={<IconChevronRight size="0.8rem" stroke={1.5} />}
active={isActive(item.to)}
variant="light"
color="orange"
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(4),
},
label: {
fontSize: rem(14),
},
}}
/>
</Tooltip>
))}
</Stack>
</AppShell.Section>
<AppShell.Section
style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}
pt="md"
>
<NavLink
label="Pusat Bantuan"
leftSection={
<IconSettings
style={{ width: rem(18), height: rem(18) }}
stroke={1.5}
/>
}
styles={{ root: { borderRadius: rem(8) } }}
/>
<NavLink
label="Keluar"
onClick={handleLogout}
leftSection={
<IconLogout
style={{ width: rem(18), height: rem(18) }}
stroke={1.5}
color="red"
/>
}
c="red"
styles={{ root: { borderRadius: rem(8) } }}
/>
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>
<Box p="lg" style={{ minHeight: "calc(100vh - 100px)" }}>
<Outlet />
</Box>
</AppShell.Main>
</AppShell>
);
}

View File

@@ -1,11 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
export const Route = createFileRoute("/dashboard/settings")({
beforeLoad: protectedRouteMiddleware,
component: DashboardSettingsComponent,
});
function DashboardSettingsComponent() {
return <div>Hello from /dashboard/settings!</div>;
}

View File

@@ -1,11 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
export const Route = createFileRoute("/dashboard/users")({
beforeLoad: protectedRouteMiddleware,
component: DashboardUsersComponent,
});
function DashboardUsersComponent() {
return <div>Hello from /dashboard/users!</div>;
}