feat: improve profile UI/UX and migrate to Mantine modals
This commit is contained in:
150
CLAUDE.md
150
CLAUDE.md
@@ -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 v3–v4**: `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.
|
|
||||||
93
GEMINI.md
93
GEMINI.md
@@ -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.
|
|
||||||
|
|||||||
19
bun.lock
19
bun.lock
@@ -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=="],
|
||||||
|
|||||||
31
package.json
31
package.json
@@ -20,25 +20,26 @@
|
|||||||
"@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/dates": "^8.3.13",
|
||||||
"@mantine/hooks": "^8.3.14",
|
"@mantine/hooks": "^8.3.14",
|
||||||
"@mantine/dates": "^8.3.13",
|
"@mantine/modals": "^8.3.14",
|
||||||
"@react-dev-inspector/vite-plugin": "^2.0.1",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
"@tanstack/react-router": "^1.158.1",
|
|
||||||
"elysia": "^1.4.22",
|
|
||||||
"react": "^19",
|
|
||||||
"react-dev-inspector": "^2.0.1",
|
|
||||||
"react-dom": "^19",
|
|
||||||
"@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",
|
||||||
"better-auth": "^1.4.18",
|
"@tanstack/react-router": "^1.158.1",
|
||||||
|
"better-auth": "^1.4.18",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"pino": "^10.3.0",
|
"elysia": "^1.4.22",
|
||||||
|
"pino": "^10.3.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"valtio": "^2.3.0"
|
"react": "^19",
|
||||||
|
"react-dom": "^19",
|
||||||
|
"valtio": "^2.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.29.0",
|
"@react-dev-inspector/vite-plugin": "^2.0.1",
|
||||||
|
"react-dev-inspector": "^2.0.1",
|
||||||
|
"@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",
|
||||||
"@tanstack/react-router-devtools": "^1.158.1",
|
"@tanstack/react-router-devtools": "^1.158.1",
|
||||||
@@ -51,10 +52,10 @@
|
|||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"@tanstack/router-cli": "^1.157.16",
|
"@tanstack/router-cli": "^1.157.16",
|
||||||
"@tanstack/router-plugin": "^1.157.16",
|
"@tanstack/router-plugin": "^1.157.16",
|
||||||
"concurrently": "^9.2.1",
|
"concurrently": "^9.2.1",
|
||||||
"fast-glob": "^3.3.3"
|
"fast-glob": "^3.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
|
|
||||||
// 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";
|
||||||
import { defineConfig, env } from "prisma/config";
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
migrations: {
|
migrations: {
|
||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
},
|
},
|
||||||
engine: "classic",
|
engine: "classic",
|
||||||
datasource: {
|
datasource: {
|
||||||
url: env("DATABASE_URL"),
|
url: env("DATABASE_URL"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,49 +1,53 @@
|
|||||||
import { prisma } from "@/utils/db";
|
import { prisma } from "@/utils/db";
|
||||||
|
|
||||||
async function seedAdminUser() {
|
async function seedAdminUser() {
|
||||||
// Load environment variables
|
// Load environment variables
|
||||||
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(
|
||||||
return;
|
"No ADMIN_EMAIL environment variable found. Skipping admin role assignment.",
|
||||||
}
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if admin user already exists
|
// Check if admin user already exists
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: { email: adminEmail },
|
where: { email: adminEmail },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
// Update existing user to have admin role if they don't already
|
// Update existing user to have admin role if they don't already
|
||||||
if (existingUser.role !== "admin") {
|
if (existingUser.role !== "admin") {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { email: adminEmail },
|
where: { email: adminEmail },
|
||||||
data: { role: "admin" },
|
data: { role: "admin" },
|
||||||
});
|
});
|
||||||
console.log(`User with email ${adminEmail} updated to admin role.`);
|
console.log(`User with email ${adminEmail} updated to admin role.`);
|
||||||
} else {
|
} else {
|
||||||
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) {
|
);
|
||||||
console.error("Error seeding admin user:", error);
|
}
|
||||||
throw error;
|
} catch (error) {
|
||||||
}
|
console.error("Error seeding admin user:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("Seeding database...");
|
console.log("Seeding database...");
|
||||||
|
|
||||||
await seedAdminUser();
|
await seedAdminUser();
|
||||||
|
|
||||||
console.log("Database seeding completed.");
|
console.log("Database seeding completed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
console.error("Error during seeding:", error);
|
console.error("Error during seeding:", error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,45 +27,46 @@ declare module "@tanstack/react-router" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
/** Theme customization here */
|
/** Theme customization here */
|
||||||
});
|
});
|
||||||
|
|
||||||
const InspectorWrapper = import.meta.env.DEV
|
const InspectorWrapper = import.meta.env.DEV
|
||||||
? Inspector
|
? Inspector
|
||||||
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||||
|
|
||||||
const elem = document.getElementById("root")!;
|
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 = import.meta.env.VITE_PUBLIC_URL;
|
const url = import.meta.env.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({
|
||||||
relativePath: e.codeInfo.relativePath,
|
relativePath: e.codeInfo.relativePath,
|
||||||
lineNumber: e.codeInfo.lineNumber,
|
lineNumber: e.codeInfo.lineNumber,
|
||||||
columnNumber: e.codeInfo.columnNumber,
|
columnNumber: e.codeInfo.columnNumber,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<MantineProvider theme={theme}>
|
<MantineProvider theme={theme}>
|
||||||
<RouterProvider router={router} />
|
<ModalsProvider>
|
||||||
</MantineProvider>
|
<RouterProvider router={router} />
|
||||||
</InspectorWrapper>
|
</ModalsProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</InspectorWrapper>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (import.meta.hot) {
|
if (import.meta.hot) {
|
||||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||||
root.render(app);
|
root.render(app);
|
||||||
} else {
|
} else {
|
||||||
// The hot module reloading API is not available in production.
|
// The hot module reloading API is not available in production.
|
||||||
createRoot(elem).render(app);
|
createRoot(elem).render(app);
|
||||||
}
|
}
|
||||||
|
|||||||
233
src/index.ts
233
src/index.ts
@@ -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,104 +55,135 @@ 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(".") &&
|
!pathname.startsWith("/@") &&
|
||||||
!pathname.startsWith("/@") &&
|
!pathname.startsWith("/inspector") &&
|
||||||
!pathname.startsWith("/inspector") &&
|
!pathname.startsWith("/__open-stack-frame-in-editor"))
|
||||||
!pathname.startsWith("/__open-stack-frame-in-editor"))
|
) {
|
||||||
) {
|
try {
|
||||||
try {
|
const htmlPath = path.resolve("src/index.html");
|
||||||
const htmlPath = path.resolve("src/index.html");
|
let html = fs.readFileSync(htmlPath, "utf-8");
|
||||||
let html = fs.readFileSync(htmlPath, "utf-8");
|
html = await vite.transformIndexHtml(pathname, html);
|
||||||
html = await vite.transformIndexHtml(pathname, html);
|
|
||||||
|
|
||||||
return new Response(html, {
|
return new Response(html, {
|
||||||
headers: { "Content-Type": "text/html" },
|
headers: { "Content-Type": "text/html" },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(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;
|
|
||||||
}
|
}
|
||||||
// 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);
|
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> {
|
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",
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<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 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}
|
||||||
onClick={() => {
|
label={item.description}
|
||||||
navigate({ to: item.to });
|
position="right"
|
||||||
}}
|
disabled={!desktopOpened}
|
||||||
leftSection={
|
openDelay={500}
|
||||||
<item.icon
|
>
|
||||||
style={{ width: rem(18), height: rem(18) }}
|
<NavLink
|
||||||
stroke={1.5}
|
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),
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
</Tooltip>
|
||||||
label={item.label}
|
))}
|
||||||
active={isActive(item.to)}
|
</Stack>
|
||||||
/>
|
</AppShell.Section>
|
||||||
))}
|
|
||||||
</ScrollArea>
|
<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.Navbar>
|
||||||
|
|
||||||
<AppShell.Main>
|
<AppShell.Main bg="rgba(15, 15, 15, 1)">
|
||||||
<Outlet />
|
<Box p="lg" style={{ minHeight: "calc(100vh - 100px)" }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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)" }}
|
|
||||||
>
|
|
||||||
<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}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="sm">
|
{copyable && value && (
|
||||||
<IconAt size={16} stroke={1.5} color="rgba(255, 255, 255, 0.6)" />
|
<Tooltip label={copied === id ? "Copied!" : "Salin ke papan klip"} position="top" withArrow>
|
||||||
<Text c="dimmed">{snap.user?.email}</Text>
|
<ActionIcon
|
||||||
</Group>
|
variant="subtle"
|
||||||
<Group gap="sm">
|
color={copied === id ? "green" : "gray"}
|
||||||
<IconShield
|
size="sm"
|
||||||
size={16}
|
onClick={() => copyToClipboard(value, id)}
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<ActionIcon
|
{copied === id ? <IconCheck size={14} /> : <IconCopy size={14} />}
|
||||||
variant="subtle"
|
</ActionIcon>
|
||||||
color="gray"
|
</Tooltip>
|
||||||
size="sm"
|
)}
|
||||||
onClick={() =>
|
|
||||||
snap.user?.id && copyToClipboard(snap.user.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Box>
|
||||||
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
</Group>
|
||||||
<Group>
|
</Paper>
|
||||||
<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
|
return (
|
||||||
withBorder
|
<Container size="md" py={50}>
|
||||||
p="lg"
|
<Stack gap="xl">
|
||||||
radius="md"
|
{/* Header Section */}
|
||||||
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>
|
<Box>
|
||||||
|
<Title order={1} c="#f3d5a3">Profil Saya</Title>
|
||||||
|
<Text c="dimmed" size="sm">Kelola informasi akun dan pengaturan keamanan Anda</Text>
|
||||||
|
</Box>
|
||||||
<Group>
|
<Group>
|
||||||
{snap.user?.role === "admin" && (
|
{snap.user?.role === "admin" && (
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconDashboard size={16} />}
|
|
||||||
variant="light"
|
variant="light"
|
||||||
color="blue"
|
color="orange"
|
||||||
|
leftSection={<IconDashboard size={18} />}
|
||||||
onClick={() => navigate({ to: "/dashboard" })}
|
onClick={() => navigate({ to: "/dashboard" })}
|
||||||
>
|
>
|
||||||
Dashboard
|
Admin Panel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
leftSection={<IconLogout size={16} />}
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
color="red"
|
color="red"
|
||||||
onClick={() => setOpened(true)}
|
leftSection={<IconLogout size={18} />}
|
||||||
|
onClick={openLogoutModal}
|
||||||
>
|
>
|
||||||
Sign Out
|
Keluar
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</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
|
<Divider color="rgba(251, 240, 223, 0.1)" />
|
||||||
opened={opened}
|
|
||||||
onClose={() => setOpened(false)}
|
{/* Profile Overview Card */}
|
||||||
title="Confirm Sign Out"
|
<Card withBorder radius="lg" p={0} bg="rgba(26, 26, 26, 0.5)" style={{ overflow: "hidden" }}>
|
||||||
centered
|
<Box h={120} bg="linear-gradient(45deg, #2c2c2c 0%, #1a1a1a 100%)" style={{ borderBottom: "1px solid rgba(251, 240, 223, 0.1)" }} />
|
||||||
size="sm"
|
<Box px="xl" pb="xl" style={{ marginTop: rem(-60) }}>
|
||||||
>
|
<Group align="flex-end" gap="xl" mb="md">
|
||||||
<Text mb="md">
|
<Avatar
|
||||||
Are you sure you want to sign out? You will need to sign in again to
|
src={snap.user?.image}
|
||||||
access your account.
|
size={120}
|
||||||
</Text>
|
radius={120}
|
||||||
<Group justify="flex-end">
|
style={{ border: "4px solid #1a1a1a", boxShadow: "0 4px 10px rgba(0,0,0,0.3)" }}
|
||||||
<Button
|
>
|
||||||
variant="subtle"
|
{snap.user?.name?.charAt(0).toUpperCase()}
|
||||||
color="gray"
|
</Avatar>
|
||||||
onClick={() => setOpened(false)}
|
<Stack gap={0} pb="md">
|
||||||
>
|
<Title order={2} c="#fbf0df">{snap.user?.name}</Title>
|
||||||
Cancel
|
<Group gap="xs">
|
||||||
</Button>
|
<Text c="dimmed" size="sm">{snap.user?.email}</Text>
|
||||||
<Button
|
<Text c="dimmed" size="xs">•</Text>
|
||||||
leftSection={<IconLogout size={16} />}
|
<Badge variant="dot" color={snap.user?.role === "admin" ? "orange" : "blue"} size="sm">
|
||||||
color="red"
|
{snap.user?.role || "user"}
|
||||||
onClick={async () => {
|
</Badge>
|
||||||
await logout();
|
</Group>
|
||||||
setOpened(false);
|
</Stack>
|
||||||
}}
|
</Group>
|
||||||
>
|
</Box>
|
||||||
Sign Out
|
</Card>
|
||||||
</Button>
|
|
||||||
</Group>
|
<Grid gutter="lg">
|
||||||
</Modal>
|
<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>
|
</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";
|
import { createServer as createViteServer } from "vite";
|
||||||
|
|
||||||
export async function createVite() {
|
export async function createVite() {
|
||||||
return createViteServer({
|
return createViteServer({
|
||||||
root: process.cwd(),
|
root: process.cwd(),
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(process.cwd(), "./src"),
|
"@": path.resolve(process.cwd(), "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react({
|
react({
|
||||||
babel: {
|
babel: {
|
||||||
plugins: [
|
plugins: [
|
||||||
[
|
[
|
||||||
"@react-dev-inspector/babel-plugin",
|
"@react-dev-inspector/babel-plugin",
|
||||||
{
|
{
|
||||||
relativePath: true,
|
relativePath: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
inspectorServer(),
|
inspectorServer(),
|
||||||
tanstackRouter(),
|
tanstackRouter(),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
middlewareMode: true,
|
middlewareMode: true,
|
||||||
hmr: true,
|
hmr: true,
|
||||||
},
|
},
|
||||||
appType: "custom",
|
appType: "custom",
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
include: ["react", "react-dom", "@mantine/core"],
|
include: ["react", "react-dom", "@mantine/core"],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user