feat: improve profile UI/UX and migrate to Mantine modals

This commit is contained in:
bipproduction
2026-02-07 15:05:28 +08:00
parent b9abcaadde
commit 718e7603d1
14 changed files with 752 additions and 760 deletions

150
CLAUDE.md
View File

@@ -1,150 +0,0 @@
# CLAUDE.md
## Project Overview
This repository uses **Bun + Elysia** as the single HTTP server and **Vite (middleware mode)** to serve a **React** frontend on the **same port** during development. The goal is a clean DX with **one origin**, **no proxy**, **no CORS**, and support for **react-dev-inspector** (click element → open editor).
**Key principles**:
* Bun/Elysia owns the port (e.g. `http://localhost:3000`).
* Vite runs **as middleware**, not as a standalone dev server.
* React Dev Inspector is enabled **only in dev**.
* Production build does **not** depend on Vite middleware.
---
## Stack
* Runtime: **Bun**
* Server: **Elysia**
* Frontend: **React**
* Tooling (dev): **Vite (middleware mode)**
* Inspector: **react-dev-inspector**
---
## Dev Architecture (Single Port)
```
Browser
http://localhost:3000
Elysia (Bun)
├─ API routes
├─ react-dev-inspector middleware
└─ Vite middlewares (HMR, transforms)
```
**Why this matters**:
* No split ports
* No proxy rewrites
* Stable source maps for inspector
---
## Vite API (Important)
> **Vite version matters.**
* For **Vite v3v4**: `createServer` is imported from `'vite'`.
* For **Vite v7+**: **Node APIs are exported from `vite/node`**.
**Use this for Vite v7+**:
```ts
import { createServer } from 'vite/node'
import type { ViteDevServer } from 'vite'
```
Do **not** import from internal paths like `vite/dist/*`.
---
## TypeScript Requirements
Ensure TypeScript can resolve Vite types correctly:
```json
{
"compilerOptions": {
"moduleResolution": "Bundler",
"types": ["bun-types", "vite/client"],
"jsx": "react-jsx"
}
}
```
If TypeScript cannot find `createServer`, check:
* Vite major version
* Import path (`vite/node` for v7+)
* `types` includes `vite/client`
---
## Development Flow
1. Start Bun server (`bun run dev`).
2. Elysia boots and creates a Vite dev server in **middleware mode**.
3. Requests are handled by Elysia and passed to Vite middlewares.
4. React loads with HMR and Inspector enabled.
5. **Alt/Option + Click** on a React element opens the source file.
---
## Inspector Usage
* Shortcut:
* macOS: **Option + Click**
* Windows/Linux: **Alt + Click**
* Editor can be configured in the Vite plugin:
```ts
Inspector({ editor: 'code' }) // VS Code
```
Inspector should be **disabled in production**.
---
## Production Notes
* Vite middleware is **dev-only**.
* Production should serve:
* Prebuilt static assets (Vite build output), or
* SSR output (if enabled later).
* Elysia remains the single server in all environments.
---
## Common Pitfalls
* ❌ Running Vite as a separate server (breaks single-port goal)
* ❌ Importing `createServer` from `'vite'` on Vite v7+
* ❌ Using internal Vite paths (`vite/dist/*`)
* ❌ Missing `vite/client` types
---
## Goals for Contributors
* Keep **one-port architecture** intact.
* Do not introduce dev proxies unless absolutely required.
* Prefer Bun-native solutions.
* Avoid relying on undocumented Vite internals.
---
## References
* Vite JavaScript API (v3): [https://v3.vite.dev/guide/api-javascript.html](https://v3.vite.dev/guide/api-javascript.html)
* Vite latest docs: [https://vite.dev/](https://vite.dev/)
* react-dev-inspector: [https://react-dev-inspector.zthxxx.me/](https://react-dev-inspector.zthxxx.me/)
---
If anything here becomes unclear, **check the Vite major version first** — most integration issues come from API changes across versions.

View File

@@ -1,64 +1,59 @@
# GEMINI.md # GEMINI.md
This file provides instructional context for the Gemini AI agent to understand and interact with this project efficiently. This project is a high-performance, full-stack React development template leveraging the Bun runtime. It is designed for a seamless developer experience with a unified "single-port" architecture.
## Project Overview ## Project Overview
A high-performance web application template using a "Single Port" architecture. It combines a Bun/Elysia backend with a React frontend, served through Vite in middleware mode. * **Runtime**: [Bun](https://bun.sh/)
* **Architecture**: "Single Port" (default: 3000). [ElysiaJS](https://elysiajs.com/) serves as the main HTTP server, integrating [Vite](https://vitejs.dev/) in **middleware mode** during development to provide HMR and React Dev Inspector support.
* **Frontend**: React 19 with [TanStack React Router](https://tanstack.com/router/latest) for type-safe, file-based routing.
* **UI Framework**: [Mantine UI](https://mantine.dev/) for a comprehensive component library and hooks.
* **Authentication**: [Better Auth](https://www.better-auth.com/) integrated with Elysia.
* **Database**: [Prisma ORM](https://www.prisma.io/) for type-safe database access.
* **Tooling**: [Biome](https://biomejs.dev/) for ultra-fast linting and formatting.
- **Runtime**: [Bun](https://bun.sh/) ## Building and Running
- **Backend**: [ElysiaJS](https://elysiajs.com/)
- **Frontend**: React 19
- **Routing**: [TanStack React Router](https://tanstack.com/router/latest) (File-based)
- **UI Framework**: [Mantine UI](https://mantine.dev/)
- **Bundler/Dev Tooling**: [Vite](https://vitejs.dev/) (Middleware Mode)
- **Linting/Formatting**: [Biome](https://biomejs.dev/)
- **Developer Experience**: Single port (3000), HMR, and [react-dev-inspector](https://github.com/zthxxx/react-dev-inspector) integration.
## Architecture: Single Port (DX) ### Development
* **Install dependencies**: `bun install`
* **Start development server**: `bun run dev` (Runs Elysia + Vite Middleware)
* **Update Route Tree**: `bun x tsr generate` (usually automatic via Vite plugin)
* **Database Migration**: `bun x prisma migrate dev`
The Elysia server (running on Bun) acts as the primary entry point. During development, it bridges requests to Vite's middleware. ### Production
* **Build Frontend**: `bun run build` (Outputs to `dist/`)
* **Start Production Server**: `bun run start` (Serves pre-built assets from `dist/` via Elysia)
- **Backend**: Handles API routes (`/api/*`) and custom developer tools (e.g., `/__open-in-editor`). ### Quality Control
- **Frontend**: All other requests are passed to Vite for HMR and asset transformation. * **Lint**: `bun run lint` (Biome check)
- **Entry Points**: * **Format**: `bun run format` (Biome write)
- Server: `src/index.ts` * **Type Check**: `bun x tsc --noEmit`
- Vite Config: `src/vite.ts`
- Frontend: `src/frontend.tsx`
- HTML: `src/index.html`
## Development Commands ## Development Conventions
- **Install Dependencies**: `bun install` ### Code Style & Structure
- **Start Dev Server**: `bun run dev` (Runs Elysia + Vite Middleware) * **Formatting**: Strictly use **Biome**. The project uses **tab indentation** and **double quotes** for JavaScript/TypeScript.
- **Lint & Fix**: `bun run lint` (Biome check) * **Imports**:
- **Format Code**: `bun run format` (Biome format) * Use the `node:` protocol for Node.js built-ins (e.g., `import fs from "node:fs"`).
- **Type Check**: `bun x tsc --noEmit` * Use the `@/` alias for absolute paths from the `src/` directory (e.g., `import { auth } from "@/utils/auth"`).
- **Production Build**: `bun run build` (Static build) * **Routing**: New routes should be added as files in `src/routes/` to leverage TanStack Router's file-based routing system.
- **Production Start**: `bun run start` (Serve production build)
## Project Structure ### Backend/API
* **Prefix**: All backend API routes are prefixed with `/api`.
* **Documentation**: Swagger documentation is available at `/api/docs` in development.
* **Authentication**: Handled at `/api/auth/*`. Protected routes use the `apiMiddleware` and custom guards.
- `src/`: Main source code. ### Frontend
- `src/index.ts`: Elysia server entry point. * **Theme**: Mantine is configured via `MantineProvider` in `src/App.tsx`.
- `src/vite.ts`: Vite server configuration for middleware mode. * **State Management**: [Valtio](https://valtio.pmnd.rs/) is used for simple proxy-based state (see `src/store/`).
- `src/frontend.tsx`: React client entry point. * **Dev Tools**: TanStack Router Devtools and React Dev Inspector are enabled in development.
- `src/routes/`: TanStack Router file-based routes.
- `src/routes/__root.tsx`: Root layout with authentication guards.
- `src/routes/index.tsx`: Home page.
- `src/utils/`: Helper utilities (e.g., `open-in-editor.ts`, API clients).
- `biome.json`: Biome configuration (tabs, double quotes, import organization).
- `postcss.config.cjs`: PostCSS configuration for Mantine UI.
## Coding Conventions ## Project Layout
- **Formatter/Linter**: Strictly use **Biome**. Indentation is set to **tabs**. * `src/index.ts`: Unified server entry point (Dev/Prod conditional logic).
- **Routing**: Use TanStack Router's file-based system in `src/routes/`. Avoid manual route definitions unless necessary. * `src/vite.ts`: Vite server configuration (Dev-only).
- **UI Components**: Prefer Mantine UI components. Always wrap the app with `MantineProvider`. * `src/routes/`: Frontend route definitions and layouts.
- **Imports**: Use the `node:` protocol for Node.js built-ins (e.g., `import fs from "node:fs"`). Biome handles import organization automatically. * `src/api/`: Elysia route modules.
- **Types**: Maintain strict TypeScript compliance. Use `tsc --noEmit` to verify. * `src/utils/`: Shared utilities (Auth, DB, Logging).
* `prisma/`: Database schema and migrations.
## Integration Details * `dist/`: Production build output (Git ignored).
- **React Dev Inspector**: Active in development. Use `Alt/Option + Click` to jump from the browser to the code in your editor.
- **Elysia-Vite Bridge**: The bridge in `src/index.ts` mocks Node.js `req`/`res` objects using a `Proxy` to make Bun's fetch-based requests compatible with Vite's Connect middleware.

View File

@@ -12,9 +12,9 @@
"@mantine/core": "^8.3.14", "@mantine/core": "^8.3.14",
"@mantine/dates": "^8.3.13", "@mantine/dates": "^8.3.13",
"@mantine/hooks": "^8.3.14", "@mantine/hooks": "^8.3.14",
"@mantine/modals": "^8.3.14",
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@react-dev-inspector/vite-plugin": "^2.0.1",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-router": "^1.158.1", "@tanstack/react-router": "^1.158.1",
"better-auth": "^1.4.18", "better-auth": "^1.4.18",
@@ -23,7 +23,6 @@
"pino": "^10.3.0", "pino": "^10.3.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"react": "^19", "react": "^19",
"react-dev-inspector": "^2.0.1",
"react-dom": "^19", "react-dom": "^19",
"valtio": "^2.3.0", "valtio": "^2.3.0",
}, },
@@ -31,6 +30,7 @@
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",
"@babel/plugin-transform-react-jsx-source": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@biomejs/biome": "2.3.14", "@biomejs/biome": "2.3.14",
"@react-dev-inspector/vite-plugin": "^2.0.1",
"@tanstack/react-router-devtools": "^1.158.1", "@tanstack/react-router-devtools": "^1.158.1",
"@tanstack/router-cli": "^1.157.16", "@tanstack/router-cli": "^1.157.16",
"@tanstack/router-plugin": "^1.157.16", "@tanstack/router-plugin": "^1.157.16",
@@ -45,6 +45,7 @@
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"react-dev-inspector": "^2.0.1",
"vite": "^7.3.1", "vite": "^7.3.1",
}, },
}, },
@@ -250,6 +251,8 @@
"@mantine/hooks": ["@mantine/hooks@8.3.14", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-0SbHnGEuHcF2QyjzBBcqidpjNmIb6n7TC3obnhkBToYhUTbMcJSK/8ei/yHtAelridJH4CPeohRlQdc0HajHyQ=="], "@mantine/hooks": ["@mantine/hooks@8.3.14", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-0SbHnGEuHcF2QyjzBBcqidpjNmIb6n7TC3obnhkBToYhUTbMcJSK/8ei/yHtAelridJH4CPeohRlQdc0HajHyQ=="],
"@mantine/modals": ["@mantine/modals@8.3.14", "", { "peerDependencies": { "@mantine/core": "8.3.14", "@mantine/hooks": "8.3.14", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-BBM53MBq0vKZ7MKmTbqdt6i5eZEoAbfllCHVlQ7J4Xlr1LehoxO3q0MuwPr5kkjSWAPw5okiviKoMYXIKBn53w=="],
"@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="], "@mrleebo/prisma-ast": ["@mrleebo/prisma-ast@0.13.1", "", { "dependencies": { "chevrotain": "^10.5.0", "lilconfig": "^2.1.0" } }, "sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw=="],
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="], "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
@@ -750,7 +753,7 @@
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
@@ -764,7 +767,7 @@
"is-root": ["is-root@2.1.0", "", {}, "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg=="], "is-root": ["is-root@2.1.0", "", {}, "sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg=="],
"is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], "is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isbot": ["isbot@5.1.34", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="], "isbot": ["isbot@5.1.34", "", {}, "sha512-aCMIBSKd/XPRYdiCQTLC8QHH4YT8B3JUADu+7COgYIZPvkeoMcUHMRjZLM9/7V8fCj+l7FSREc1lOPNjzogo/A=="],
@@ -1232,6 +1235,8 @@
"global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], "global-prefix/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="],
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "node-abi/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
@@ -1260,6 +1265,8 @@
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"wsl-utils/is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="],
"@prisma/config/c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "@prisma/config/c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"@prisma/config/c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "@prisma/config/c12/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
@@ -1284,10 +1291,6 @@
"react-dev-utils/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="], "react-dev-utils/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
"react-dev-utils/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"react-dev-utils/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"@prisma/config/c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "@prisma/config/c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"fork-ts-checker-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "fork-ts-checker-webpack-plugin/schema-utils/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],

View File

@@ -20,24 +20,25 @@
"@elysiajs/eden": "^1.4.6", "@elysiajs/eden": "^1.4.6",
"@elysiajs/swagger": "^1.3.1", "@elysiajs/swagger": "^1.3.1",
"@mantine/core": "^8.3.14", "@mantine/core": "^8.3.14",
"@mantine/hooks": "^8.3.14",
"@mantine/dates": "^8.3.13", "@mantine/dates": "^8.3.13",
"@react-dev-inspector/vite-plugin": "^2.0.1", "@mantine/hooks": "^8.3.14",
"@tanstack/react-router": "^1.158.1", "@mantine/modals": "^8.3.14",
"elysia": "^1.4.22",
"react": "^19",
"react-dev-inspector": "^2.0.1",
"react-dom": "^19",
"@prisma/adapter-pg": "^7.3.0", "@prisma/adapter-pg": "^7.3.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-router": "^1.158.1",
"better-auth": "^1.4.18", "better-auth": "^1.4.18",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"elysia": "^1.4.22",
"pino": "^10.3.0", "pino": "^10.3.0",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"react": "^19",
"react-dom": "^19",
"valtio": "^2.3.0" "valtio": "^2.3.0"
}, },
"devDependencies": { "devDependencies": {
"@react-dev-inspector/vite-plugin": "^2.0.1",
"react-dev-inspector": "^2.0.1",
"@babel/core": "^7.29.0", "@babel/core": "^7.29.0",
"@babel/plugin-transform-react-jsx-source": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@biomejs/biome": "2.3.14", "@biomejs/biome": "2.3.14",

View File

@@ -1,4 +1,3 @@
// This file was generated by Prisma and assumes you have installed the following: // This file was generated by Prisma and assumes you have installed the following:
// npm install --save-dev prisma dotenv // npm install --save-dev prisma dotenv
import "dotenv/config"; import "dotenv/config";

View File

@@ -5,7 +5,9 @@ async function seedAdminUser() {
const adminEmail = process.env.ADMIN_EMAIL; const adminEmail = process.env.ADMIN_EMAIL;
if (!adminEmail) { if (!adminEmail) {
console.log("No ADMIN_EMAIL environment variable found. Skipping admin role assignment."); console.log(
"No ADMIN_EMAIL environment variable found. Skipping admin role assignment.",
);
return; return;
} }
@@ -27,7 +29,9 @@ async function seedAdminUser() {
console.log(`User with email ${adminEmail} already has admin role.`); console.log(`User with email ${adminEmail} already has admin role.`);
} }
} else { } else {
console.log(`No user found with email ${adminEmail}. Skipping admin role assignment.`); console.log(
`No user found with email ${adminEmail}. Skipping admin role assignment.`,
);
} }
} catch (error) { } catch (error) {
console.error("Error seeding admin user:", error); console.error("Error seeding admin user:", error);

View File

@@ -7,14 +7,12 @@
/** biome-ignore-all lint/style/noNonNullAssertion: <explanation */ /** biome-ignore-all lint/style/noNonNullAssertion: <explanation */
/** biome-ignore-all lint/suspicious/noAssignInExpressions: <explanation */ /** biome-ignore-all lint/suspicious/noAssignInExpressions: <explanation */
import { MantineProvider } from "@mantine/core"; import { createTheme, MantineProvider } from "@mantine/core";
import { 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 { createTheme } from "@mantine/core";
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { ModalsProvider } from "@mantine/modals";
// Create a new router instance // Create a new router instance
export const router = createRouter({ export const router = createRouter({
@@ -29,7 +27,6 @@ declare module "@tanstack/react-router" {
} }
} }
const theme = createTheme({ const theme = createTheme({
/** Theme customization here */ /** Theme customization here */
}); });
@@ -58,7 +55,9 @@ const app = (
}} }}
> >
<MantineProvider theme={theme}> <MantineProvider theme={theme}>
<ModalsProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ModalsProvider>
</MantineProvider> </MantineProvider>
</InspectorWrapper> </InspectorWrapper>
); );

View File

@@ -1,15 +1,15 @@
/** 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 { 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 { cors } from "@elysiajs/cors";
import { swagger } from "@elysiajs/swagger"; import { swagger } from "@elysiajs/swagger";
import { Elysia } from "elysia";
import { apikey } from "./api/apikey";
import { apiMiddleware } from "./middleware/apiMiddleware"; 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({ const api = new Elysia({
prefix: "/api", prefix: "/api",
@@ -34,10 +34,14 @@ const api = new Elysia({
.use(apiMiddleware) .use(apiMiddleware)
.use(apikey); .use(apikey);
const vite = await createVite(); const app = new Elysia().use(api);
const app = new Elysia()
.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 { const { relativePath, lineNumber, columnNumber } = body as {
relativePath: string; relativePath: string;
lineNumber: number; lineNumber: number;
@@ -51,16 +55,14 @@ const app = new Elysia()
}); });
return { ok: true }; return { ok: true };
}) });
.use(api);
// Vite middleware for other requests // Vite middleware for other requests
app.all("*", async ({ request }) => { app.all("*", async ({ request }) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
// Serve transformed index.html for root or any path that should be handled by the SPA // 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 ( if (
pathname === "/" || pathname === "/" ||
(!pathname.includes(".") && (!pathname.includes(".") &&
@@ -145,10 +147,43 @@ app.all("*", async ({ request }) => {
resolve(new Response("Not Found", { status: 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); 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;

View File

@@ -21,8 +21,7 @@ type SessionResponse = {
async function fetchSession(): Promise<SessionResponse | null> { async function fetchSession(): Promise<SessionResponse | null> {
try { try {
const baseURL = const baseURL = import.meta.env.VITE_PUBLIC_URL || window.location.origin;
import.meta.env.VITE_PUBLIC_URL || window.location.origin;
const res = await fetch(`${baseURL}/api/session`, { const res = await fetch(`${baseURL}/api/session`, {
method: "GET", method: "GET",
credentials: "include", credentials: "include",

View File

@@ -94,7 +94,9 @@ function DashboardApikeyComponent() {
setCreating(true); setCreating(true);
const response = await apiClient.api.apikey.post({ const response = await apiClient.api.apikey.post({
name: newKeyName, name: newKeyName,
expiresAt: newKeyExpiresAt ? dayjs(newKeyExpiresAt).toISOString() : undefined, expiresAt: newKeyExpiresAt
? dayjs(newKeyExpiresAt).toISOString()
: undefined,
}); });
if (response.data) { if (response.data) {

View File

@@ -7,13 +7,13 @@ import {
Container, Container,
Grid, Grid,
Group, Group,
Modal,
Progress, Progress,
SimpleGrid, SimpleGrid,
Stack, Stack,
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals";
import { import {
IconClock, IconClock,
IconDatabase, IconDatabase,
@@ -21,7 +21,6 @@ import {
IconUserCheck, IconUserCheck,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { useSnapshot } from "valtio"; import { useSnapshot } from "valtio";
import { authClient } from "@/utils/auth-client"; import { authClient } from "@/utils/auth-client";
import { authStore } from "../../store/auth"; import { authStore } from "../../store/auth";
@@ -33,7 +32,19 @@ export const Route = createFileRoute("/dashboard/")({
function DashboardComponent() { function DashboardComponent() {
const snap = useSnapshot(authStore); const snap = useSnapshot(authStore);
const navigate = useNavigate(); 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 // Mock data for dashboard stats
const statsData = [ const statsData = [
@@ -84,11 +95,7 @@ function DashboardComponent() {
</Badge> </Badge>
</div> </div>
</Group> </Group>
<Button <Button variant="outline" color="red" onClick={openLogoutModal}>
variant="outline"
color="red"
onClick={() => setLogoutModalOpen(true)}
>
Sign Out Sign Out
</Button> </Button>
</Group> </Group>
@@ -197,28 +204,6 @@ function DashboardComponent() {
</Card> </Card>
</Grid.Col> </Grid.Col>
</Grid> </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> </Container>
); );
} }

View File

@@ -1,20 +1,28 @@
import { import {
ActionIcon, ActionIcon,
AppShell, AppShell,
Avatar,
Box,
Burger, Burger,
Group, Group,
Menu,
NavLink, NavLink,
rem, rem,
ScrollArea, ScrollArea,
Stack,
Text, Text,
Title Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import { import {
IconChevronRight,
IconHome, IconHome,
IconKey, IconKey,
IconLogout,
IconSettings, IconSettings,
IconUsers IconUser,
IconUsers,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
createFileRoute, createFileRoute,
@@ -22,6 +30,9 @@ import {
useLocation, useLocation,
useNavigate, useNavigate,
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { useSnapshot } from "valtio";
import { authStore } from "../../store/auth";
import { authClient } from "@/utils/auth-client";
export const Route = createFileRoute("/dashboard")({ export const Route = createFileRoute("/dashboard")({
component: DashboardLayout, component: DashboardLayout,
@@ -29,93 +40,279 @@ export const Route = createFileRoute("/dashboard")({
function DashboardLayout() { function DashboardLayout() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const snap = useSnapshot(authStore);
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure(); const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true); const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const navigate = useNavigate();
const navItems = [ const navItems = [
{ icon: IconHome, label: "Beranda", to: "/dashboard" }, {
{ icon: IconUsers, label: "Pengguna", to: "/dashboard/users" }, icon: IconHome,
{ icon: IconKey, label: "API Key", to: "/dashboard/apikey" }, label: "Beranda",
{ icon: IconSettings, label: "Pengaturan", to: "/dashboard/settings" }, 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 isActive = (path: string) => {
const current = location.pathname; const current = location.pathname;
if (path === "/dashboard")
if (path === "/dashboard") { return current === "/dashboard" || current === "/dashboard/";
return current === "/dashboard"; return current.startsWith(path);
}
return current === path || current.startsWith(`${path}/`);
}; };
return ( return (
<AppShell <AppShell
header={{ height: 70 }}
navbar={{ navbar={{
width: 300, width: 280,
breakpoint: "sm", breakpoint: "sm",
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened }, collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
}} }}
padding="md" padding="md"
header={{ height: 60 }} transitionDuration={500}
transitionTimingFunction="ease"
>
<AppShell.Header
bg="rgba(26, 26, 26, 0.8)"
style={{
backdropFilter: "blur(10px)",
borderBottom: "1px solid rgba(251, 240, 223, 0.1)",
}}
> >
<AppShell.Header>
<Group h="100%" px="md" justify="space-between"> <Group h="100%" px="md" justify="space-between">
<Group> <Group gap="xs">
<Burger <Burger
opened={mobileOpened} opened={mobileOpened}
onClick={toggleMobile} onClick={toggleMobile}
hiddenFrom="sm" hiddenFrom="sm"
size="sm" size="sm"
color="#f3d5a3"
/> />
<Burger <Burger
opened={desktopOpened} opened={desktopOpened}
onClick={toggleDesktop} onClick={toggleDesktop}
visibleFrom="sm" visibleFrom="sm"
size="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>
<Group>
<ActionIcon variant="subtle" size="lg"> <Group gap="md">
<IconSettings <Menu
style={{ width: rem(20), height: rem(20) }} shadow="md"
stroke={1.5} width={200}
/> position="bottom-end"
</ActionIcon> 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>
</Group> </Group>
</AppShell.Header> </AppShell.Header>
<AppShell.Navbar p="md"> <AppShell.Navbar
<ScrollArea h="calc(100vh - 120px)"> p="md"
<Group mb="lg"> bg="rgba(20, 20, 20, 1)"
<Text fw={500} size="lg"> style={{ borderRight: "1px solid rgba(251, 240, 223, 0.1)" }}
Navigasi Utama >
</Text> <AppShell.Section grow component={ScrollArea} mx="-md" px="md">
</Group> <Stack gap="xs" mt="md">
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <Tooltip
key={item.to} key={item.to}
label={item.description}
position="right"
disabled={!desktopOpened}
openDelay={500}
>
<NavLink
onClick={() => { onClick={() => {
navigate({ to: item.to }); navigate({ to: item.to });
if (mobileOpened) toggleMobile();
}} }}
leftSection={ leftSection={
<item.icon <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),
},
}}
/>
</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) }} style={{ width: rem(18), height: rem(18) }}
stroke={1.5} stroke={1.5}
/> />
} }
label={item.label} styles={{ root: { borderRadius: rem(8) } }}
active={isActive(item.to)}
/> />
))} <NavLink
</ScrollArea> 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.Navbar>
<AppShell.Main> <AppShell.Main bg="rgba(15, 15, 15, 1)">
<Box p="lg" style={{ minHeight: "calc(100vh - 100px)" }}>
<Outlet /> <Outlet />
</Box>
</AppShell.Main> </AppShell.Main>
</AppShell> </AppShell>
); );

View File

@@ -1,24 +1,31 @@
import { protectedRouteMiddleware } from "@/middleware/authMiddleware";
import { authClient } from "@/utils/auth-client";
import { import {
ActionIcon, ActionIcon,
Avatar, Avatar,
Badge, Badge,
Box,
Button, Button,
Card, Card,
Code, Code,
Container, Container,
Divider,
Grid,
Group, Group,
Modal, Paper,
SimpleGrid,
Stack, Stack,
Text, Text,
Title, Title,
Tooltip, Tooltip,
rem,
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals";
import { import {
IconAt, IconAt,
IconCheck, IconCheck,
IconCopy, IconCopy,
IconDashboard, IconDashboard,
IconExternalLink,
IconId, IconId,
IconLogout, IconLogout,
IconShield, IconShield,
@@ -27,291 +34,207 @@ 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 { 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")({
component: Profile, component: Profile,
beforeLoad: protectedRouteMiddleware,
onEnter({ context }) {
authStore.user = context?.user as any;
authStore.session = context?.session as any;
},
}); });
function Profile() { function Profile() {
const snap = useSnapshot(authStore); const snap = useSnapshot(authStore);
const navigate = useNavigate(); const navigate = useNavigate();
const [opened, setOpened] = useState(false); const [copied, setCopied] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
async function logout() { async function logout() {
await authClient.signOut(); await authClient.signOut();
navigate({ to: "/signin" }); 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) { if (navigator.clipboard) {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
setCopied(true); setCopied(key);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(null), 2000);
} }
}; };
return ( const InfoField = ({ icon: Icon, label, value, copyable = false, id = "" }: any) => (
<Container size="lg" py="xl"> <Paper withBorder p="md" radius="md" bg="rgba(251, 240, 223, 0.03)" style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}>
<Title order={1} mb="lg" ta="center"> <Group wrap="nowrap" align="flex-start">
User Profile <Box mt={3}>
</Title> <Icon size={20} stroke={1.5} color="#f3d5a3" />
</Box>
{/* Profile Header Card */} <Box style={{ flex: 1 }}>
<Card <Text size="xs" c="dimmed" tt="uppercase" fw={700} lts={0.5}>
withBorder {label}
p="xl" </Text>
radius="md" <Group gap="xs" mt={4} wrap="nowrap">
mb="xl" <Text fw={500} size="sm" c="#fbf0df" truncate="end" style={{ flex: 1 }}>
bg="rgba(251, 240, 223, 0.05)" {value || "N/A"}
style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }} </Text>
{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)}
> >
<Group justify="center" align="flex-start" gap="xl"> {copied === id ? <IconCheck size={14} /> : <IconCopy size={14} />}
</ActionIcon>
</Tooltip>
)}
</Group>
</Box>
</Group>
</Paper>
);
return (
<Container size="md" py={50}>
<Stack gap="xl">
{/* Header Section */}
<Group justify="space-between" align="center">
<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
variant="light"
color="orange"
leftSection={<IconDashboard size={18} />}
onClick={() => navigate({ to: "/dashboard" })}
>
Admin Panel
</Button>
)}
<Button
variant="outline"
color="red"
leftSection={<IconLogout size={18} />}
onClick={openLogoutModal}
>
Keluar
</Button>
</Group>
</Group>
<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 <Avatar
src={snap.user?.image} src={snap.user?.image}
size={120} size={120}
radius="xl" radius={120}
style={{ border: "2px solid rgba(251, 240, 223, 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="xs" justify="center"> <Stack gap={0} pb="md">
<Text size="xl" fw={700} c="#fbf0df"> <Title order={2} c="#fbf0df">{snap.user?.name}</Title>
{snap.user?.name} <Group gap="xs">
</Text> <Text c="dimmed" size="sm">{snap.user?.email}</Text>
<Group gap="sm"> <Text c="dimmed" size="xs"></Text>
<IconAt size={16} stroke={1.5} color="rgba(255, 255, 255, 0.6)" /> <Badge variant="dot" color={snap.user?.role === "admin" ? "orange" : "blue"} size="sm">
<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"} {snap.user?.role || "user"}
</Badge> </Badge>
</Group> </Group>
</Stack> </Stack>
</Group> </Group>
</Box>
</Card> </Card>
<Title order={2} mb="md" ta="center"> <Grid gutter="lg">
Account Information <Grid.Col span={{ base: 12, md: 7 }}>
</Title> <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>
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="xl"> <Grid.Col span={{ base: 12, md: 5 }}>
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)"> <Stack gap="md">
<Group> <Title order={4} c="#f3d5a3">Keamanan & Sesi</Title>
<IconId size={24} stroke={1.5} color="#f3d5a3" /> <Card withBorder radius="md" p="lg" bg="rgba(251, 240, 223, 0.03)" style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}>
<div> <Stack gap="md">
<Text size="sm" c="dimmed"> <Box>
User ID <Text size="xs" c="dimmed" tt="uppercase" fw={700} mb={8}>Sesi Saat Ini</Text>
</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"
>
<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>
</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>
<Card
withBorder
p="lg"
radius="md"
bg="rgba(251, 240, 223, 0.05)"
mb="xl"
>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
<Title order={3}>Session Information</Title> <Badge color="green" variant="light">Aktif Sekarang</Badge>
<Group> <Text size="xs" c="dimmed">ID: {snap.session?.id?.substring(0, 8)}...</Text>
{snap.user?.role === "admin" && (
<Button
leftSection={<IconDashboard size={16} />}
variant="light"
color="blue"
onClick={() => navigate({ to: "/dashboard" })}
>
Dashboard
</Button>
)}
<Button
leftSection={<IconLogout size={16} />}
variant="outline"
color="red"
onClick={() => setOpened(true)}
>
Sign Out
</Button>
</Group> </Group>
</Group> </Box>
<Group mt="md" justify="space-between">
<div> <Box>
<Text size="sm" c="dimmed" mb="xs"> <Text size="xs" c="dimmed" tt="uppercase" fw={700} mb={8}>Session Token</Text>
Session Token <Group gap="xs" wrap="nowrap">
</Text> <Code block style={{
<Group gap="xs"> backgroundColor: "rgba(0,0,0,0.3)",
<Code
block
style={{
fontSize: "0.8rem",
padding: "0.5rem 0.75rem",
backgroundColor: "rgba(26, 26, 26, 0.7)",
color: "#f3d5a3", color: "#f3d5a3",
}} border: "1px solid rgba(251, 240, 223, 0.1)",
> fontSize: rem(11),
{snap.session?.token flex: 1
? `${snap.session.token.substring(0, 30)}...` }}>
: "N/A"} {snap.session?.token ? `${snap.session.token.substring(0, 32)}...` : "N/A"}
</Code> </Code>
<Tooltip
label={copied ? "Copied!" : "Copy to clipboard"}
position="top"
>
<ActionIcon <ActionIcon
variant="light" variant="light"
color="gray" color="gray"
size="md" onClick={() => snap.session?.token && copyToClipboard(snap.session.token, "token")}
onClick={() =>
snap.session?.token && copyToClipboard(snap.session.token)
}
> >
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />} {copied === "token" ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon> </ActionIcon>
</Tooltip>
</Group> </Group>
</div> </Box>
</Group>
</Card>
<Modal <Button variant="light" color="gray" fullWidth leftSection={<IconExternalLink size={16} />}>
opened={opened} Riwayat Sesi
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>
<Button </Stack>
leftSection={<IconLogout size={16} />} </Card>
color="red" </Stack>
onClick={async () => { </Grid.Col>
await logout(); </Grid>
setOpened(false); </Stack>
}}
>
Sign Out
</Button>
</Group>
</Modal>
</Container> </Container>
); );
} }