chore: prepare for migration to OpenAPI

This commit is contained in:
bipproduction
2026-02-07 18:04:37 +08:00
parent d2abd9dafb
commit 6abd32650d
9 changed files with 224 additions and 96 deletions

View File

@@ -18,6 +18,14 @@
"recommended": true "recommended": true
} }
}, },
"overrides": [
{
"includes": ["src/routeTree.gen.ts", "dist/**", "node_modules/**"],
"linter": { "enabled": false },
"formatter": { "enabled": false },
"assist": { "enabled": false }
}
],
"javascript": { "javascript": {
"formatter": { "formatter": {
"quoteStyle": "double" "quoteStyle": "double"

36
src/api/index.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import Elysia from "elysia";
import { apiMiddleware } from "../middleware/apiMiddleware";
import { auth } from "../utils/auth";
import { apikey } from "./apikey";
const isProduction = process.env.NODE_ENV === "production";
const api = new Elysia({
prefix: "/api",
})
.use(cors())
.all("/auth/*", ({ request }) => auth.handler(request))
.get("/session", async ({ request }) => {
const data = await auth.api.getSession({ headers: request.headers });
return { data };
})
.use(apiMiddleware)
.use(apikey);
if (!isProduction) {
api.use(
swagger({
path: "/docs",
documentation: {
info: {
title: "Bun + React API",
version: "1.0.0",
},
},
}),
);
}
export default api;

View File

@@ -8,12 +8,12 @@
/** biome-ignore-all lint/suspicious/noAssignInExpressions: <explanation */ /** biome-ignore-all lint/suspicious/noAssignInExpressions: <explanation */
import { createTheme, MantineProvider } from "@mantine/core"; import { createTheme, MantineProvider } from "@mantine/core";
import { ModalsProvider } from "@mantine/modals";
import { createRouter, RouterProvider } from "@tanstack/react-router"; import { createRouter, RouterProvider } from "@tanstack/react-router";
import { Inspector } from "react-dev-inspector"; import { Inspector } from "react-dev-inspector";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { ModalsProvider } from "@mantine/modals"; import { IS_DEV, VITE_PUBLIC_URL } from "./utils/env";
import { VITE_PUBLIC_URL, IS_DEV } from "./utils/env";
// Create a new router instance // Create a new router instance
export const router = createRouter({ export const router = createRouter({
@@ -40,12 +40,11 @@ const elem = document.getElementById("root")!;
const app = ( const app = (
<InspectorWrapper <InspectorWrapper
keys={["shift", "a"]} keys={["shift", "a"]}
onClickElement={(e) => { onClickElement={(e) => {
if (!e.codeInfo) return; if (!e.codeInfo) return;
const url = VITE_PUBLIC_URL; const url = VITE_PUBLIC_URL;
fetch(`${url}/__open-in-editor`, { fetch(`${url}/__open-in-editor`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -182,6 +182,6 @@ code {
*, *,
::before, ::before,
::after { ::after {
animation: none !important; animation: none;
} }
} }

View File

@@ -1,42 +1,13 @@
/** biome-ignore-all lint/suspicious/noExplicitAny: penjelasannya */ /** biome-ignore-all lint/suspicious/noExplicitAny: penjelasannya */
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger";
import { Elysia } from "elysia"; import { Elysia } from "elysia";
import { apikey } from "./api/apikey"; import api from "./api";
import { apiMiddleware } from "./middleware/apiMiddleware";
import { auth } from "./utils/auth";
import { openInEditor } from "./utils/open-in-editor"; import { openInEditor } from "./utils/open-in-editor";
const isProduction = process.env.NODE_ENV === "production"; const isProduction = process.env.NODE_ENV === "production";
const api = new Elysia({
prefix: "/api",
})
.use(cors())
.all("/auth/*", ({ request }) => auth.handler(request))
.get("/session", async ({ request }) => {
const data = await auth.api.getSession({ headers: request.headers });
return { data };
})
.use(apiMiddleware)
.use(apikey);
if (!isProduction) {
api.use(
swagger({
path: "/docs",
documentation: {
info: {
title: "Bun + React API",
version: "1.0.0",
},
},
}),
);
}
const app = new Elysia().use(api); const app = new Elysia().use(api);
if (!isProduction) { if (!isProduction) {
@@ -158,8 +129,11 @@ if (!isProduction) {
const pathname = url.pathname; const pathname = url.pathname;
// 1. Try exact match in dist // 1. Try exact match in dist
let filePath = path.join("dist", pathname === "/" ? "index.html" : pathname); let filePath = path.join(
"dist",
pathname === "/" ? "index.html" : pathname,
);
// 2. If not found and looks like an asset (has extension), try root of dist // 2. If not found and looks like an asset (has extension), try root of dist
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
if (pathname.includes(".") && !pathname.endsWith("/")) { if (pathname.includes(".") && !pathname.endsWith("/")) {
@@ -191,4 +165,4 @@ console.log(
`🚀 Server running at http://localhost:3000 in ${isProduction ? "production" : "development"} mode`, `🚀 Server running at http://localhost:3000 in ${isProduction ? "production" : "development"} mode`,
); );
export type ApiApp = typeof app; export type ApiApp = typeof app;

View File

@@ -1,5 +1,4 @@
import { import {
ActionIcon,
AppShell, AppShell,
Avatar, Avatar,
Box, Box,
@@ -31,8 +30,8 @@ import {
useNavigate, useNavigate,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { useSnapshot } from "valtio"; import { useSnapshot } from "valtio";
import { authStore } from "../../store/auth";
import { authClient } from "@/utils/auth-client"; import { authClient } from "@/utils/auth-client";
import { authStore } from "../../store/auth";
export const Route = createFileRoute("/dashboard")({ export const Route = createFileRoute("/dashboard")({
component: DashboardLayout, component: DashboardLayout,

View File

@@ -1,5 +1,3 @@
import { protectedRouteMiddleware } from "@/middleware/authMiddleware";
import { authClient } from "@/utils/auth-client";
import { import {
ActionIcon, ActionIcon,
Avatar, Avatar,
@@ -13,11 +11,11 @@ import {
Grid, Grid,
Group, Group,
Paper, Paper,
rem,
Stack, Stack,
Text, Text,
Title, Title,
Tooltip, Tooltip,
rem,
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { import {
@@ -34,6 +32,8 @@ import {
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { useSnapshot } from "valtio"; import { useSnapshot } from "valtio";
import { protectedRouteMiddleware } from "@/middleware/authMiddleware";
import { authClient } from "@/utils/auth-client";
import { authStore } from "../store/auth"; import { authStore } from "../store/auth";
export const Route = createFileRoute("/profile")({ export const Route = createFileRoute("/profile")({
@@ -62,7 +62,8 @@ function Profile() {
size: "sm", size: "sm",
children: ( children: (
<Text size="sm"> <Text size="sm">
Apakah Anda yakin ingin keluar dari akun Anda? Anda harus masuk kembali untuk mengakses data Anda. Apakah Anda yakin ingin keluar dari akun Anda? Anda harus masuk
kembali untuk mengakses data Anda.
</Text> </Text>
), ),
labels: { confirm: "Keluar", cancel: "Batal" }, labels: { confirm: "Keluar", cancel: "Batal" },
@@ -78,8 +79,20 @@ function Profile() {
} }
}; };
const InfoField = ({ icon: Icon, label, value, copyable = false, id = "" }: any) => ( const InfoField = ({
<Paper withBorder p="md" radius="md" bg="rgba(251, 240, 223, 0.03)" style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}> 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"> <Group wrap="nowrap" align="flex-start">
<Box mt={3}> <Box mt={3}>
<Icon size={20} stroke={1.5} color="#f3d5a3" /> <Icon size={20} stroke={1.5} color="#f3d5a3" />
@@ -89,18 +102,32 @@ function Profile() {
{label} {label}
</Text> </Text>
<Group gap="xs" mt={4} wrap="nowrap"> <Group gap="xs" mt={4} wrap="nowrap">
<Text fw={500} size="sm" c="#fbf0df" truncate="end" style={{ flex: 1 }}> <Text
fw={500}
size="sm"
c="#fbf0df"
truncate="end"
style={{ flex: 1 }}
>
{value || "N/A"} {value || "N/A"}
</Text> </Text>
{copyable && value && ( {copyable && value && (
<Tooltip label={copied === id ? "Copied!" : "Salin ke papan klip"} position="top" withArrow> <Tooltip
label={copied === id ? "Copied!" : "Salin ke papan klip"}
position="top"
withArrow
>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color={copied === id ? "green" : "gray"} color={copied === id ? "green" : "gray"}
size="sm" size="sm"
onClick={() => copyToClipboard(value, id)} onClick={() => copyToClipboard(value, id)}
> >
{copied === id ? <IconCheck size={14} /> : <IconCopy size={14} />} {copied === id ? (
<IconCheck size={14} />
) : (
<IconCopy size={14} />
)}
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
@@ -116,8 +143,12 @@ function Profile() {
{/* Header Section */} {/* Header Section */}
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Box> <Box>
<Title order={1} c="#f3d5a3">Profil Saya</Title> <Title order={1} c="#f3d5a3">
<Text c="dimmed" size="sm">Kelola informasi akun dan pengaturan keamanan Anda</Text> Profil Saya
</Title>
<Text c="dimmed" size="sm">
Kelola informasi akun dan pengaturan keamanan Anda
</Text>
</Box> </Box>
<Group> <Group>
{snap.user?.role === "admin" && ( {snap.user?.role === "admin" && (
@@ -144,24 +175,47 @@ function Profile() {
<Divider color="rgba(251, 240, 223, 0.1)" /> <Divider color="rgba(251, 240, 223, 0.1)" />
{/* Profile Overview Card */} {/* Profile Overview Card */}
<Card withBorder radius="lg" p={0} bg="rgba(26, 26, 26, 0.5)" style={{ overflow: "hidden" }}> <Card
<Box h={120} bg="linear-gradient(45deg, #2c2c2c 0%, #1a1a1a 100%)" style={{ borderBottom: "1px solid rgba(251, 240, 223, 0.1)" }} /> 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) }}> <Box px="xl" pb="xl" style={{ marginTop: rem(-60) }}>
<Group align="flex-end" gap="xl" mb="md"> <Group align="flex-end" gap="xl" mb="md">
<Avatar <Avatar
src={snap.user?.image} src={snap.user?.image}
size={120} size={120}
radius={120} radius={120}
style={{ border: "4px solid #1a1a1a", boxShadow: "0 4px 10px rgba(0,0,0,0.3)" }} style={{
border: "4px solid #1a1a1a",
boxShadow: "0 4px 10px rgba(0,0,0,0.3)",
}}
> >
{snap.user?.name?.charAt(0).toUpperCase()} {snap.user?.name?.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Stack gap={0} pb="md"> <Stack gap={0} pb="md">
<Title order={2} c="#fbf0df">{snap.user?.name}</Title> <Title order={2} c="#fbf0df">
{snap.user?.name}
</Title>
<Group gap="xs"> <Group gap="xs">
<Text c="dimmed" size="sm">{snap.user?.email}</Text> <Text c="dimmed" size="sm">
<Text c="dimmed" size="xs"></Text> {snap.user?.email}
<Badge variant="dot" color={snap.user?.role === "admin" ? "orange" : "blue"} size="sm"> </Text>
<Text c="dimmed" size="xs">
</Text>
<Badge
variant="dot"
color={snap.user?.role === "admin" ? "orange" : "blue"}
size="sm"
>
{snap.user?.role || "user"} {snap.user?.role || "user"}
</Badge> </Badge>
</Group> </Group>
@@ -173,19 +227,41 @@ function Profile() {
<Grid gutter="lg"> <Grid gutter="lg">
<Grid.Col span={{ base: 12, md: 7 }}> <Grid.Col span={{ base: 12, md: 7 }}>
<Stack gap="md"> <Stack gap="md">
<Title order={4} c="#f3d5a3">Informasi Identitas</Title> <Title order={4} c="#f3d5a3">
Informasi Identitas
</Title>
<Grid gutter="sm"> <Grid gutter="sm">
<Grid.Col span={6}> <Grid.Col span={6}>
<InfoField icon={IconUser} label="Nama Lengkap" value={snap.user?.name} /> <InfoField
icon={IconUser}
label="Nama Lengkap"
value={snap.user?.name}
/>
</Grid.Col> </Grid.Col>
<Grid.Col span={6}> <Grid.Col span={6}>
<InfoField icon={IconShield} label="Peran" value={snap.user?.role || "User"} /> <InfoField
icon={IconShield}
label="Peran"
value={snap.user?.role || "User"}
/>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<InfoField icon={IconAt} label="Alamat Email" value={snap.user?.email} copyable id="email" /> <InfoField
icon={IconAt}
label="Alamat Email"
value={snap.user?.email}
copyable
id="email"
/>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<InfoField icon={IconId} label="Unique User ID" value={snap.user?.id} copyable id="userid" /> <InfoField
icon={IconId}
label="Unique User ID"
value={snap.user?.id}
copyable
id="userid"
/>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Stack> </Stack>
@@ -193,40 +269,73 @@ function Profile() {
<Grid.Col span={{ base: 12, md: 5 }}> <Grid.Col span={{ base: 12, md: 5 }}>
<Stack gap="md"> <Stack gap="md">
<Title order={4} c="#f3d5a3">Keamanan & Sesi</Title> <Title order={4} c="#f3d5a3">
<Card withBorder radius="md" p="lg" bg="rgba(251, 240, 223, 0.03)" style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}> 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"> <Stack gap="md">
<Box> <Box>
<Text size="xs" c="dimmed" tt="uppercase" fw={700} mb={8}>Sesi Saat Ini</Text> <Text size="xs" c="dimmed" tt="uppercase" fw={700} mb={8}>
Sesi Saat Ini
</Text>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Badge color="green" variant="light">Aktif Sekarang</Badge> <Badge color="green" variant="light">
<Text size="xs" c="dimmed">ID: {snap.session?.id?.substring(0, 8)}...</Text> Aktif Sekarang
</Badge>
<Text size="xs" c="dimmed">
ID: {snap.session?.id?.substring(0, 8)}...
</Text>
</Group> </Group>
</Box> </Box>
<Box> <Box>
<Text size="xs" c="dimmed" tt="uppercase" fw={700} mb={8}>Session Token</Text> <Text size="xs" c="dimmed" tt="uppercase" fw={700} mb={8}>
Session Token
</Text>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<Code block style={{ <Code
backgroundColor: "rgba(0,0,0,0.3)", block
color: "#f3d5a3", style={{
border: "1px solid rgba(251, 240, 223, 0.1)", backgroundColor: "rgba(0,0,0,0.3)",
fontSize: rem(11), color: "#f3d5a3",
flex: 1 border: "1px solid rgba(251, 240, 223, 0.1)",
}}> fontSize: rem(11),
{snap.session?.token ? `${snap.session.token.substring(0, 32)}...` : "N/A"} flex: 1,
</Code> }}
<ActionIcon
variant="light"
color="gray"
onClick={() => snap.session?.token && copyToClipboard(snap.session.token, "token")}
> >
{copied === "token" ? <IconCheck size={16} /> : <IconCopy size={16} />} {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> </ActionIcon>
</Group> </Group>
</Box> </Box>
<Button variant="light" color="gray" fullWidth leftSection={<IconExternalLink size={16} />}> <Button
variant="light"
color="gray"
fullWidth
leftSection={<IconExternalLink size={16} />}
>
Riwayat Sesi Riwayat Sesi
</Button> </Button>
</Stack> </Stack>

View File

@@ -5,7 +5,7 @@
export const getEnv = (key: string, defaultValue = ""): string => { export const getEnv = (key: string, defaultValue = ""): string => {
// 1. Try Vite's import.meta.env // 1. Try Vite's import.meta.env
try { try {
if (typeof import.meta.env !== "undefined" && import.meta.env[key]) { if (import.meta.env?.[key]) {
return import.meta.env[key]; return import.meta.env[key];
} }
} catch {} } catch {}
@@ -20,11 +20,14 @@ export const getEnv = (key: string, defaultValue = ""): string => {
return defaultValue; return defaultValue;
}; };
export const VITE_PUBLIC_URL = getEnv("VITE_PUBLIC_URL", "http://localhost:3000"); export const VITE_PUBLIC_URL = getEnv(
"VITE_PUBLIC_URL",
"http://localhost:3000",
);
export const IS_DEV = (() => { export const IS_DEV = (() => {
try { try {
return typeof import.meta.env !== "undefined" && import.meta.env.DEV; return import.meta.env?.DEV;
} catch { } catch {
return false; return false;
} }

View File

@@ -1,9 +1,9 @@
// open-in-editor.ts // open-in-editor.ts
// DEV utility: open source file in local editor // DEV utility: open source file in local editor
import { spawn } from "child_process"; import { spawn } from "node:child_process";
import fs from "fs"; import fs from "node:fs";
import path from "path"; import path from "node:path";
/* ------------------------------------------------------- /* -------------------------------------------------------
* Types * Types