feat: improve profile UI/UX and migrate to Mantine modals
This commit is contained in:
@@ -7,14 +7,12 @@
|
||||
/** biome-ignore-all lint/style/noNonNullAssertion: <explanation */
|
||||
/** biome-ignore-all lint/suspicious/noAssignInExpressions: <explanation */
|
||||
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { RouterProvider } from "@tanstack/react-router";
|
||||
import { createTheme, MantineProvider } from "@mantine/core";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import { Inspector } from "react-dev-inspector";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createTheme } from "@mantine/core";
|
||||
|
||||
import { createRouter } from "@tanstack/react-router";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
|
||||
// Create a new router instance
|
||||
export const router = createRouter({
|
||||
@@ -29,45 +27,46 @@ declare module "@tanstack/react-router" {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const theme = createTheme({
|
||||
/** Theme customization here */
|
||||
/** Theme customization here */
|
||||
});
|
||||
|
||||
const InspectorWrapper = import.meta.env.DEV
|
||||
? Inspector
|
||||
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
? Inspector
|
||||
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<InspectorWrapper
|
||||
keys={["shift", "a"]}
|
||||
onClickElement={(e) => {
|
||||
if (!e.codeInfo) return;
|
||||
<InspectorWrapper
|
||||
keys={["shift", "a"]}
|
||||
onClickElement={(e) => {
|
||||
if (!e.codeInfo) return;
|
||||
|
||||
const url = import.meta.env.VITE_PUBLIC_URL;
|
||||
fetch(`${url}/__open-in-editor`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
relativePath: e.codeInfo.relativePath,
|
||||
lineNumber: e.codeInfo.lineNumber,
|
||||
columnNumber: e.codeInfo.columnNumber,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MantineProvider theme={theme}>
|
||||
<RouterProvider router={router} />
|
||||
</MantineProvider>
|
||||
</InspectorWrapper>
|
||||
const url = import.meta.env.VITE_PUBLIC_URL;
|
||||
fetch(`${url}/__open-in-editor`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
relativePath: e.codeInfo.relativePath,
|
||||
lineNumber: e.codeInfo.lineNumber,
|
||||
columnNumber: e.codeInfo.columnNumber,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MantineProvider theme={theme}>
|
||||
<ModalsProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</InspectorWrapper>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
|
||||
233
src/index.ts
233
src/index.ts
@@ -1,15 +1,15 @@
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: penjelasannya */
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { Elysia } from "elysia";
|
||||
import { openInEditor } from "./utils/open-in-editor";
|
||||
import { createVite } from "./vite";
|
||||
import { apikey } from "./api/apikey";
|
||||
import { auth } from "./utils/auth";
|
||||
import { cors } from "@elysiajs/cors";
|
||||
import { swagger } from "@elysiajs/swagger";
|
||||
import { Elysia } from "elysia";
|
||||
import { apikey } from "./api/apikey";
|
||||
import { apiMiddleware } from "./middleware/apiMiddleware";
|
||||
import { auth } from "./utils/auth";
|
||||
import { openInEditor } from "./utils/open-in-editor";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
const api = new Elysia({
|
||||
prefix: "/api",
|
||||
@@ -34,10 +34,14 @@ const api = new Elysia({
|
||||
.use(apiMiddleware)
|
||||
.use(apikey);
|
||||
|
||||
const vite = await createVite();
|
||||
const app = new Elysia()
|
||||
const app = new Elysia().use(api);
|
||||
|
||||
.post("/__open-in-editor", ({ body }) => {
|
||||
if (!isProduction) {
|
||||
// Development: Use Vite middleware
|
||||
const { createVite } = await import("./vite");
|
||||
const vite = await createVite();
|
||||
|
||||
app.post("/__open-in-editor", ({ body }) => {
|
||||
const { relativePath, lineNumber, columnNumber } = body as {
|
||||
relativePath: string;
|
||||
lineNumber: number;
|
||||
@@ -51,104 +55,135 @@ const app = new Elysia()
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
})
|
||||
.use(api);
|
||||
});
|
||||
|
||||
// Vite middleware for other requests
|
||||
app.all("*", async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
// Vite middleware for other requests
|
||||
app.all("*", async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Serve transformed index.html for root or any path that should be handled by the SPA
|
||||
// We check if it's not a file request (doesn't have a file extension or is a known SPA route)
|
||||
if (
|
||||
pathname === "/" ||
|
||||
(!pathname.includes(".") &&
|
||||
!pathname.startsWith("/@") &&
|
||||
!pathname.startsWith("/inspector") &&
|
||||
!pathname.startsWith("/__open-stack-frame-in-editor"))
|
||||
) {
|
||||
try {
|
||||
const htmlPath = path.resolve("src/index.html");
|
||||
let html = fs.readFileSync(htmlPath, "utf-8");
|
||||
html = await vite.transformIndexHtml(pathname, html);
|
||||
// Serve transformed index.html for root or any path that should be handled by the SPA
|
||||
if (
|
||||
pathname === "/" ||
|
||||
(!pathname.includes(".") &&
|
||||
!pathname.startsWith("/@") &&
|
||||
!pathname.startsWith("/inspector") &&
|
||||
!pathname.startsWith("/__open-stack-frame-in-editor"))
|
||||
) {
|
||||
try {
|
||||
const htmlPath = path.resolve("src/index.html");
|
||||
let html = fs.readFileSync(htmlPath, "utf-8");
|
||||
html = await vite.transformIndexHtml(pathname, html);
|
||||
|
||||
return new Response(html, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<Response>((resolve) => {
|
||||
// Use a Proxy to mock Node.js req because Bun's Request is read-only
|
||||
const req = new Proxy(request, {
|
||||
get(target, prop) {
|
||||
if (prop === "url") return pathname + url.search;
|
||||
if (prop === "method") return request.method;
|
||||
if (prop === "headers")
|
||||
return Object.fromEntries(request.headers as any);
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as any;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
getHeader(name: string) {
|
||||
return this.headers[name.toLowerCase()];
|
||||
},
|
||||
headers: {} as Record<string, string>,
|
||||
end(data: any) {
|
||||
// Handle potential Buffer or string data from Vite
|
||||
let body = data;
|
||||
if (data instanceof Uint8Array) {
|
||||
body = data;
|
||||
} else if (typeof data === "string") {
|
||||
body = data;
|
||||
} else if (data) {
|
||||
body = String(data);
|
||||
}
|
||||
|
||||
resolve(
|
||||
new Response(body || "", {
|
||||
status: this.statusCode,
|
||||
headers: this.headers,
|
||||
}),
|
||||
);
|
||||
},
|
||||
// Minimal event emitter mock
|
||||
once() {
|
||||
return this;
|
||||
},
|
||||
on() {
|
||||
return this;
|
||||
},
|
||||
emit() {
|
||||
return this;
|
||||
},
|
||||
removeListener() {
|
||||
return this;
|
||||
},
|
||||
} as any;
|
||||
|
||||
vite.middlewares(req, res, (err: any) => {
|
||||
if (err) {
|
||||
console.error("Vite middleware error:", err);
|
||||
resolve(new Response(err.stack || err.toString(), { status: 500 }));
|
||||
return;
|
||||
return new Response(html, {
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
// If Vite doesn't handle it, return 404
|
||||
resolve(new Response("Not Found", { status: 404 }));
|
||||
}
|
||||
|
||||
return new Promise<Response>((resolve) => {
|
||||
// Use a Proxy to mock Node.js req because Bun's Request is read-only
|
||||
const req = new Proxy(request, {
|
||||
get(target, prop) {
|
||||
if (prop === "url") return pathname + url.search;
|
||||
if (prop === "method") return request.method;
|
||||
if (prop === "headers")
|
||||
return Object.fromEntries(request.headers as any);
|
||||
return (target as any)[prop];
|
||||
},
|
||||
}) as any;
|
||||
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
},
|
||||
getHeader(name: string) {
|
||||
return this.headers[name.toLowerCase()];
|
||||
},
|
||||
headers: {} as Record<string, string>,
|
||||
end(data: any) {
|
||||
// Handle potential Buffer or string data from Vite
|
||||
let body = data;
|
||||
if (data instanceof Uint8Array) {
|
||||
body = data;
|
||||
} else if (typeof data === "string") {
|
||||
body = data;
|
||||
} else if (data) {
|
||||
body = String(data);
|
||||
}
|
||||
|
||||
resolve(
|
||||
new Response(body || "", {
|
||||
status: this.statusCode,
|
||||
headers: this.headers,
|
||||
}),
|
||||
);
|
||||
},
|
||||
// Minimal event emitter mock
|
||||
once() {
|
||||
return this;
|
||||
},
|
||||
on() {
|
||||
return this;
|
||||
},
|
||||
emit() {
|
||||
return this;
|
||||
},
|
||||
removeListener() {
|
||||
return this;
|
||||
},
|
||||
} as any;
|
||||
|
||||
vite.middlewares(req, res, (err: any) => {
|
||||
if (err) {
|
||||
console.error("Vite middleware error:", err);
|
||||
resolve(new Response(err.stack || err.toString(), { status: 500 }));
|
||||
return;
|
||||
}
|
||||
// If Vite doesn't handle it, return 404
|
||||
resolve(new Response("Not Found", { status: 404 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Production: Serve static files from dist
|
||||
app.get("*", async ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
let pathname = url.pathname;
|
||||
|
||||
// Skip API routes
|
||||
if (pathname.startsWith("/api")) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
if (pathname === "/") {
|
||||
pathname = "/index.html";
|
||||
}
|
||||
|
||||
const filePath = path.join("dist", pathname);
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const file = Bun.file(filePath);
|
||||
return new Response(file);
|
||||
}
|
||||
|
||||
// SPA Fallback
|
||||
const indexHtml = path.join("dist", "index.html");
|
||||
if (fs.existsSync(indexHtml)) {
|
||||
return new Response(Bun.file(indexHtml));
|
||||
}
|
||||
|
||||
return new Response("Not Found", { status: 404 });
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(3000);
|
||||
|
||||
console.log("🚀 Server running at http://localhost:3000");
|
||||
console.log(
|
||||
`🚀 Server running at http://localhost:3000 in ${isProduction ? "production" : "development"} mode`,
|
||||
);
|
||||
|
||||
export type ApiApp = typeof app;
|
||||
export type ApiApp = typeof app;
|
||||
|
||||
@@ -21,8 +21,7 @@ type SessionResponse = {
|
||||
|
||||
async function fetchSession(): Promise<SessionResponse | null> {
|
||||
try {
|
||||
const baseURL =
|
||||
import.meta.env.VITE_PUBLIC_URL || window.location.origin;
|
||||
const baseURL = import.meta.env.VITE_PUBLIC_URL || window.location.origin;
|
||||
const res = await fetch(`${baseURL}/api/session`, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
|
||||
@@ -94,7 +94,9 @@ function DashboardApikeyComponent() {
|
||||
setCreating(true);
|
||||
const response = await apiClient.api.apikey.post({
|
||||
name: newKeyName,
|
||||
expiresAt: newKeyExpiresAt ? dayjs(newKeyExpiresAt).toISOString() : undefined,
|
||||
expiresAt: newKeyExpiresAt
|
||||
? dayjs(newKeyExpiresAt).toISOString()
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Modal,
|
||||
Progress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import {
|
||||
IconClock,
|
||||
IconDatabase,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
IconUserCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useSnapshot } from "valtio";
|
||||
import { authClient } from "@/utils/auth-client";
|
||||
import { authStore } from "../../store/auth";
|
||||
@@ -33,7 +32,19 @@ export const Route = createFileRoute("/dashboard/")({
|
||||
function DashboardComponent() {
|
||||
const snap = useSnapshot(authStore);
|
||||
const navigate = useNavigate();
|
||||
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
|
||||
|
||||
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 = [
|
||||
@@ -84,11 +95,7 @@ function DashboardComponent() {
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setLogoutModalOpen(true)}
|
||||
>
|
||||
<Button variant="outline" color="red" onClick={openLogoutModal}>
|
||||
Sign Out
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -197,28 +204,6 @@ function DashboardComponent() {
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
<Modal
|
||||
opened={logoutModalOpen}
|
||||
onClose={() => setLogoutModalOpen(false)}
|
||||
title="Confirm Logout"
|
||||
centered
|
||||
>
|
||||
<Text mb="md">Are you sure you want to log out?</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="outline" onClick={() => setLogoutModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
authClient.signOut();
|
||||
setLogoutModalOpen(false);
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Avatar,
|
||||
Box,
|
||||
Burger,
|
||||
Group,
|
||||
Menu,
|
||||
NavLink,
|
||||
rem,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { modals } from "@mantine/modals";
|
||||
import {
|
||||
IconChevronRight,
|
||||
IconHome,
|
||||
IconKey,
|
||||
IconLogout,
|
||||
IconSettings,
|
||||
IconUsers
|
||||
IconUser,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
createFileRoute,
|
||||
@@ -22,6 +30,9 @@ import {
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "@tanstack/react-router";
|
||||
import { useSnapshot } from "valtio";
|
||||
import { authStore } from "../../store/auth";
|
||||
import { authClient } from "@/utils/auth-client";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: DashboardLayout,
|
||||
@@ -29,93 +40,279 @@ export const Route = createFileRoute("/dashboard")({
|
||||
|
||||
function DashboardLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const snap = useSnapshot(authStore);
|
||||
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const navItems = [
|
||||
{ icon: IconHome, label: "Beranda", to: "/dashboard" },
|
||||
{ icon: IconUsers, label: "Pengguna", to: "/dashboard/users" },
|
||||
{ icon: IconKey, label: "API Key", to: "/dashboard/apikey" },
|
||||
{ icon: IconSettings, label: "Pengaturan", to: "/dashboard/settings" },
|
||||
{
|
||||
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";
|
||||
}
|
||||
|
||||
return current === path || current.startsWith(`${path}/`);
|
||||
if (path === "/dashboard")
|
||||
return current === "/dashboard" || current === "/dashboard/";
|
||||
return current.startsWith(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 70 }}
|
||||
navbar={{
|
||||
width: 300,
|
||||
width: 280,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||
}}
|
||||
padding="md"
|
||||
header={{ height: 60 }}
|
||||
transitionDuration={500}
|
||||
transitionTimingFunction="ease"
|
||||
>
|
||||
<AppShell.Header>
|
||||
<AppShell.Header
|
||||
bg="rgba(26, 26, 26, 0.8)"
|
||||
style={{
|
||||
backdropFilter: "blur(10px)",
|
||||
borderBottom: "1px solid rgba(251, 240, 223, 0.1)",
|
||||
}}
|
||||
>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group>
|
||||
<Group gap="xs">
|
||||
<Burger
|
||||
opened={mobileOpened}
|
||||
onClick={toggleMobile}
|
||||
hiddenFrom="sm"
|
||||
size="sm"
|
||||
color="#f3d5a3"
|
||||
/>
|
||||
<Burger
|
||||
opened={desktopOpened}
|
||||
onClick={toggleDesktop}
|
||||
visibleFrom="sm"
|
||||
size="sm"
|
||||
color="#f3d5a3"
|
||||
/>
|
||||
<Title order={3}>Dashboard</Title>
|
||||
<Box visibleFrom="xs" ml="xs">
|
||||
<Text
|
||||
fw={800}
|
||||
size="xl"
|
||||
c="#f3d5a3"
|
||||
style={{ letterSpacing: "-0.5px" }}
|
||||
>
|
||||
ADMIN
|
||||
<Text span c="#fbf0df">
|
||||
PANEL
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group>
|
||||
<ActionIcon variant="subtle" size="lg">
|
||||
<IconSettings
|
||||
style={{ width: rem(20), height: rem(20) }}
|
||||
stroke={1.5}
|
||||
/>
|
||||
</ActionIcon>
|
||||
|
||||
<Group gap="md">
|
||||
<Menu
|
||||
shadow="md"
|
||||
width={200}
|
||||
position="bottom-end"
|
||||
transitionProps={{ transition: "pop-top-right" }}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Group
|
||||
gap="xs"
|
||||
style={{ cursor: "pointer" }}
|
||||
p="xs"
|
||||
hover-bg="rgba(255,255,255,0.05)"
|
||||
>
|
||||
<div
|
||||
style={{ textAlign: "right" }}
|
||||
className="visible-from-sm"
|
||||
>
|
||||
<Text size="sm" fw={600} c="#fbf0df">
|
||||
{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 #f3d5a3" }}
|
||||
>
|
||||
{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">
|
||||
<ScrollArea h="calc(100vh - 120px)">
|
||||
<Group mb="lg">
|
||||
<Text fw={500} size="lg">
|
||||
Navigasi Utama
|
||||
</Text>
|
||||
</Group>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
onClick={() => {
|
||||
navigate({ to: item.to });
|
||||
}}
|
||||
leftSection={
|
||||
<item.icon
|
||||
style={{ width: rem(18), height: rem(18) }}
|
||||
stroke={1.5}
|
||||
<AppShell.Navbar
|
||||
p="md"
|
||||
bg="rgba(20, 20, 20, 1)"
|
||||
style={{ borderRight: "1px solid rgba(251, 240, 223, 0.1)" }}
|
||||
>
|
||||
<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="filled"
|
||||
color="orange"
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: rem(8),
|
||||
marginBottom: rem(4),
|
||||
backgroundColor: isActive(item.to)
|
||||
? "rgba(243, 213, 163, 0.1)"
|
||||
: "transparent",
|
||||
color: isActive(item.to) ? "#f3d5a3" : "#fbf0df",
|
||||
"&:hover": {
|
||||
backgroundColor: "rgba(243, 213, 163, 0.05)",
|
||||
},
|
||||
},
|
||||
label: {
|
||||
fontSize: rem(14),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={item.label}
|
||||
active={isActive(item.to)}
|
||||
/>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
|
||||
<AppShell.Section
|
||||
style={{ borderTop: "1px solid rgba(251, 240, 223, 0.1)" }}
|
||||
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>
|
||||
<Outlet />
|
||||
<AppShell.Main bg="rgba(15, 15, 15, 1)">
|
||||
<Box p="lg" style={{ minHeight: "calc(100vh - 100px)" }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { protectedRouteMiddleware } from "@/middleware/authMiddleware";
|
||||
import { authClient } from "@/utils/auth-client";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Modal,
|
||||
SimpleGrid,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
rem,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import {
|
||||
IconAt,
|
||||
IconCheck,
|
||||
IconCopy,
|
||||
IconDashboard,
|
||||
IconExternalLink,
|
||||
IconId,
|
||||
IconLogout,
|
||||
IconShield,
|
||||
@@ -27,291 +34,207 @@ import {
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useSnapshot } from "valtio";
|
||||
import { authClient } from "@/utils/auth-client";
|
||||
import { authStore } from "../store/auth";
|
||||
|
||||
export const Route = createFileRoute("/profile")({
|
||||
component: Profile,
|
||||
beforeLoad: protectedRouteMiddleware,
|
||||
onEnter({ context }) {
|
||||
authStore.user = context?.user as any;
|
||||
authStore.session = context?.session as any;
|
||||
},
|
||||
});
|
||||
|
||||
function Profile() {
|
||||
const snap = useSnapshot(authStore);
|
||||
const navigate = useNavigate();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
async function logout() {
|
||||
await authClient.signOut();
|
||||
navigate({ to: "/signin" });
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
const openLogoutModal = () =>
|
||||
modals.openConfirmModal({
|
||||
title: <Text fw={700}>Konfirmasi Keluar</Text>,
|
||||
centered: true,
|
||||
size: "sm",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Apakah Anda yakin ingin keluar dari akun Anda? Anda harus masuk kembali untuk mengakses data Anda.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: "Keluar", cancel: "Batal" },
|
||||
confirmProps: { color: "red", leftSection: <IconLogout size={16} /> },
|
||||
onConfirm: logout,
|
||||
});
|
||||
|
||||
const copyToClipboard = (text: string, key: string) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<Title order={1} mb="lg" ta="center">
|
||||
User Profile
|
||||
</Title>
|
||||
|
||||
{/* Profile Header Card */}
|
||||
<Card
|
||||
withBorder
|
||||
p="xl"
|
||||
radius="md"
|
||||
mb="xl"
|
||||
bg="rgba(251, 240, 223, 0.05)"
|
||||
style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}
|
||||
>
|
||||
<Group justify="center" align="flex-start" gap="xl">
|
||||
<Avatar
|
||||
src={snap.user?.image}
|
||||
size={120}
|
||||
radius="xl"
|
||||
style={{ border: "2px solid rgba(251, 240, 223, 0.3)" }}
|
||||
>
|
||||
{snap.user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Stack gap="xs" justify="center">
|
||||
<Text size="xl" fw={700} c="#fbf0df">
|
||||
{snap.user?.name}
|
||||
const InfoField = ({ icon: Icon, label, value, copyable = false, id = "" }: any) => (
|
||||
<Paper withBorder p="md" radius="md" bg="rgba(251, 240, 223, 0.03)" style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}>
|
||||
<Group wrap="nowrap" align="flex-start">
|
||||
<Box mt={3}>
|
||||
<Icon size={20} stroke={1.5} color="#f3d5a3" />
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lts={0.5}>
|
||||
{label}
|
||||
</Text>
|
||||
<Group gap="xs" mt={4} wrap="nowrap">
|
||||
<Text fw={500} size="sm" c="#fbf0df" truncate="end" style={{ flex: 1 }}>
|
||||
{value || "N/A"}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
<IconAt size={16} stroke={1.5} color="rgba(255, 255, 255, 0.6)" />
|
||||
<Text c="dimmed">{snap.user?.email}</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<IconShield
|
||||
size={16}
|
||||
stroke={1.5}
|
||||
color="rgba(255, 255, 255, 0.6)"
|
||||
/>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={snap.user?.role === "admin" ? "green" : "blue"}
|
||||
>
|
||||
{snap.user?.role || "user"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Title order={2} mb="md" ta="center">
|
||||
Account Information
|
||||
</Title>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="xl">
|
||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||
<Group>
|
||||
<IconId size={24} stroke={1.5} color="#f3d5a3" />
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
User ID
|
||||
</Text>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Text fw={500} truncate="end" miw={0} c="#fbf0df">
|
||||
{snap.user?.id || "N/A"}
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||
position="top"
|
||||
{copyable && value && (
|
||||
<Tooltip label={copied === id ? "Copied!" : "Salin ke papan klip"} position="top" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color={copied === id ? "green" : "gray"}
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(value, id)}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
snap.user?.id && copyToClipboard(snap.user.id)
|
||||
}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
{copied === id ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||
<Group>
|
||||
<IconAt size={24} stroke={1.5} color="#f3d5a3" />
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Email
|
||||
</Text>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Text fw={500} truncate="end" miw={0} c="#fbf0df">
|
||||
{snap.user?.email || "N/A"}
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
snap.user?.email && copyToClipboard(snap.user.email)
|
||||
}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||
<Group>
|
||||
<IconUser size={24} stroke={1.5} color="#f3d5a3" />
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Name
|
||||
</Text>
|
||||
<Group gap="xs" mt="xs">
|
||||
<Text fw={500} truncate="end" miw={0} c="#fbf0df">
|
||||
{snap.user?.name || "N/A"}
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
snap.user?.name && copyToClipboard(snap.user.name)
|
||||
}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||
<Group>
|
||||
<IconShield size={24} stroke={1.5} color="#f3d5a3" />
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
Role
|
||||
</Text>
|
||||
<Text fw={500} mt="xs" c="#fbf0df">
|
||||
{snap.user?.role || "user"}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
p="lg"
|
||||
radius="md"
|
||||
bg="rgba(251, 240, 223, 0.05)"
|
||||
mb="xl"
|
||||
>
|
||||
return (
|
||||
<Container size="md" py={50}>
|
||||
<Stack gap="xl">
|
||||
{/* Header Section */}
|
||||
<Group justify="space-between" align="center">
|
||||
<Title order={3}>Session Information</Title>
|
||||
<Box>
|
||||
<Title order={1} c="#f3d5a3">Profil Saya</Title>
|
||||
<Text c="dimmed" size="sm">Kelola informasi akun dan pengaturan keamanan Anda</Text>
|
||||
</Box>
|
||||
<Group>
|
||||
{snap.user?.role === "admin" && (
|
||||
<Button
|
||||
leftSection={<IconDashboard size={16} />}
|
||||
variant="light"
|
||||
color="blue"
|
||||
color="orange"
|
||||
leftSection={<IconDashboard size={18} />}
|
||||
onClick={() => navigate({ to: "/dashboard" })}
|
||||
>
|
||||
Dashboard
|
||||
Admin Panel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
leftSection={<IconLogout size={16} />}
|
||||
variant="outline"
|
||||
color="red"
|
||||
onClick={() => setOpened(true)}
|
||||
leftSection={<IconLogout size={18} />}
|
||||
onClick={openLogoutModal}
|
||||
>
|
||||
Sign Out
|
||||
Keluar
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group mt="md" justify="space-between">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed" mb="xs">
|
||||
Session Token
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.5rem 0.75rem",
|
||||
backgroundColor: "rgba(26, 26, 26, 0.7)",
|
||||
color: "#f3d5a3",
|
||||
}}
|
||||
>
|
||||
{snap.session?.token
|
||||
? `${snap.session.token.substring(0, 30)}...`
|
||||
: "N/A"}
|
||||
</Code>
|
||||
<Tooltip
|
||||
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||
position="top"
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
size="md"
|
||||
onClick={() =>
|
||||
snap.session?.token && copyToClipboard(snap.session.token)
|
||||
}
|
||||
>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title="Confirm Sign Out"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">
|
||||
Are you sure you want to sign out? You will need to sign in again to
|
||||
access your account.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setOpened(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconLogout size={16} />}
|
||||
color="red"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
setOpened(false);
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
<Divider color="rgba(251, 240, 223, 0.1)" />
|
||||
|
||||
{/* Profile Overview Card */}
|
||||
<Card withBorder radius="lg" p={0} bg="rgba(26, 26, 26, 0.5)" style={{ overflow: "hidden" }}>
|
||||
<Box h={120} bg="linear-gradient(45deg, #2c2c2c 0%, #1a1a1a 100%)" style={{ borderBottom: "1px solid rgba(251, 240, 223, 0.1)" }} />
|
||||
<Box px="xl" pb="xl" style={{ marginTop: rem(-60) }}>
|
||||
<Group align="flex-end" gap="xl" mb="md">
|
||||
<Avatar
|
||||
src={snap.user?.image}
|
||||
size={120}
|
||||
radius={120}
|
||||
style={{ border: "4px solid #1a1a1a", boxShadow: "0 4px 10px rgba(0,0,0,0.3)" }}
|
||||
>
|
||||
{snap.user?.name?.charAt(0).toUpperCase()}
|
||||
</Avatar>
|
||||
<Stack gap={0} pb="md">
|
||||
<Title order={2} c="#fbf0df">{snap.user?.name}</Title>
|
||||
<Group gap="xs">
|
||||
<Text c="dimmed" size="sm">{snap.user?.email}</Text>
|
||||
<Text c="dimmed" size="xs">•</Text>
|
||||
<Badge variant="dot" color={snap.user?.role === "admin" ? "orange" : "blue"} size="sm">
|
||||
{snap.user?.role || "user"}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Grid gutter="lg">
|
||||
<Grid.Col span={{ base: 12, md: 7 }}>
|
||||
<Stack gap="md">
|
||||
<Title order={4} c="#f3d5a3">Informasi Identitas</Title>
|
||||
<Grid gutter="sm">
|
||||
<Grid.Col span={6}>
|
||||
<InfoField icon={IconUser} label="Nama Lengkap" value={snap.user?.name} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<InfoField icon={IconShield} label="Peran" value={snap.user?.role || "User"} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<InfoField icon={IconAt} label="Alamat Email" value={snap.user?.email} copyable id="email" />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<InfoField icon={IconId} label="Unique User ID" value={snap.user?.id} copyable id="userid" />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Stack gap="md">
|
||||
<Title order={4} c="#f3d5a3">Keamanan & Sesi</Title>
|
||||
<Card withBorder radius="md" p="lg" bg="rgba(251, 240, 223, 0.03)" style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}>
|
||||
<Stack gap="md">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700} mb={8}>Sesi Saat Ini</Text>
|
||||
<Group justify="space-between" align="center">
|
||||
<Badge color="green" variant="light">Aktif Sekarang</Badge>
|
||||
<Text size="xs" c="dimmed">ID: {snap.session?.id?.substring(0, 8)}...</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700} mb={8}>Session Token</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Code block style={{
|
||||
backgroundColor: "rgba(0,0,0,0.3)",
|
||||
color: "#f3d5a3",
|
||||
border: "1px solid rgba(251, 240, 223, 0.1)",
|
||||
fontSize: rem(11),
|
||||
flex: 1
|
||||
}}>
|
||||
{snap.session?.token ? `${snap.session.token.substring(0, 32)}...` : "N/A"}
|
||||
</Code>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="gray"
|
||||
onClick={() => snap.session?.token && copyToClipboard(snap.session.token, "token")}
|
||||
>
|
||||
{copied === "token" ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Button variant="light" color="gray" fullWidth leftSection={<IconExternalLink size={16} />}>
|
||||
Riwayat Sesi
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
64
src/vite.ts
64
src/vite.ts
@@ -5,36 +5,36 @@ import react from "@vitejs/plugin-react";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
|
||||
export async function createVite() {
|
||||
return createViteServer({
|
||||
root: process.cwd(),
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(process.cwd(), "./src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [
|
||||
[
|
||||
"@react-dev-inspector/babel-plugin",
|
||||
{
|
||||
relativePath: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
inspectorServer(),
|
||||
tanstackRouter(),
|
||||
],
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: true,
|
||||
},
|
||||
appType: "custom",
|
||||
optimizeDeps: {
|
||||
include: ["react", "react-dom", "@mantine/core"],
|
||||
},
|
||||
});
|
||||
return createViteServer({
|
||||
root: process.cwd(),
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(process.cwd(), "./src"),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [
|
||||
[
|
||||
"@react-dev-inspector/babel-plugin",
|
||||
{
|
||||
relativePath: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
inspectorServer(),
|
||||
tanstackRouter(),
|
||||
],
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: true,
|
||||
},
|
||||
appType: "custom",
|
||||
optimizeDeps: {
|
||||
include: ["react", "react-dom", "@mantine/core"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user