feat: Integrate new dashboard design and components, remove old dashboard routes, and update dependencies.
This commit is contained in:
506
src/routes/admin/apikey.tsx
Normal file
506
src/routes/admin/apikey.tsx
Normal file
@@ -0,0 +1,506 @@
|
||||
/** 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("/admin/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>
|
||||
);
|
||||
}
|
||||
200
src/routes/admin/index.tsx
Normal file
200
src/routes/admin/index.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
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 { authClient } from "@/utils/auth-client";
|
||||
import { authStore } from "../../store/auth";
|
||||
|
||||
export const Route = createFileRoute("/admin/")({
|
||||
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>
|
||||
);
|
||||
}
|
||||
332
src/routes/admin/route.tsx
Normal file
332
src/routes/admin/route.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
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("/admin")({
|
||||
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: "/admin",
|
||||
description: "Ringkasan sistem & statistik",
|
||||
},
|
||||
{
|
||||
icon: IconUsers,
|
||||
label: "Pengguna",
|
||||
to: "/admin/users",
|
||||
description: "Kelola akun & hak akses",
|
||||
},
|
||||
{
|
||||
icon: IconKey,
|
||||
label: "API Key",
|
||||
to: "/admin/apikey",
|
||||
description: "Manajemen kunci akses API",
|
||||
},
|
||||
{
|
||||
icon: IconSettings,
|
||||
label: "Pengaturan",
|
||||
to: "/admin/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 === "/admin")
|
||||
return current === "/admin" || current === "/admin/";
|
||||
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: "/admin/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>
|
||||
);
|
||||
}
|
||||
11
src/routes/admin/settings.tsx
Normal file
11
src/routes/admin/settings.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
|
||||
|
||||
export const Route = createFileRoute("/admin/settings")({
|
||||
beforeLoad: protectedRouteMiddleware,
|
||||
component: DashboardSettingsComponent,
|
||||
});
|
||||
|
||||
function DashboardSettingsComponent() {
|
||||
return <div>Hello from /admin/settings!</div>;
|
||||
}
|
||||
11
src/routes/admin/users.tsx
Normal file
11
src/routes/admin/users.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
|
||||
|
||||
export const Route = createFileRoute("/admin/users")({
|
||||
beforeLoad: protectedRouteMiddleware,
|
||||
component: DashboardUsersComponent,
|
||||
});
|
||||
|
||||
function DashboardUsersComponent() {
|
||||
return <div>Hello from /admin/users!</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user