Initial commit: Setup Bun, Elysia, Vite, React, TanStack Router, Mantine, and Biome
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
/generated/prisma
|
||||||
150
CLAUDE.md
Normal file
150
CLAUDE.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# 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.
|
||||||
64
GEMINI.md
Normal file
64
GEMINI.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# GEMINI.md
|
||||||
|
|
||||||
|
This file provides instructional context for the Gemini AI agent to understand and interact with this project efficiently.
|
||||||
|
|
||||||
|
## 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/)
|
||||||
|
- **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)
|
||||||
|
|
||||||
|
The Elysia server (running on Bun) acts as the primary entry point. During development, it bridges requests to Vite's middleware.
|
||||||
|
|
||||||
|
- **Backend**: Handles API routes (`/api/*`) and custom developer tools (e.g., `/__open-in-editor`).
|
||||||
|
- **Frontend**: All other requests are passed to Vite for HMR and asset transformation.
|
||||||
|
- **Entry Points**:
|
||||||
|
- Server: `src/index.ts`
|
||||||
|
- Vite Config: `src/vite.ts`
|
||||||
|
- Frontend: `src/frontend.tsx`
|
||||||
|
- HTML: `src/index.html`
|
||||||
|
|
||||||
|
## Development Commands
|
||||||
|
|
||||||
|
- **Install Dependencies**: `bun install`
|
||||||
|
- **Start Dev Server**: `bun run dev` (Runs Elysia + Vite Middleware)
|
||||||
|
- **Lint & Fix**: `bun run lint` (Biome check)
|
||||||
|
- **Format Code**: `bun run format` (Biome format)
|
||||||
|
- **Type Check**: `bun x tsc --noEmit`
|
||||||
|
- **Production Build**: `bun run build` (Static build)
|
||||||
|
- **Production Start**: `bun run start` (Serve production build)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `src/`: Main source code.
|
||||||
|
- `src/index.ts`: Elysia server entry point.
|
||||||
|
- `src/vite.ts`: Vite server configuration for middleware mode.
|
||||||
|
- `src/frontend.tsx`: React client entry point.
|
||||||
|
- `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
|
||||||
|
|
||||||
|
- **Formatter/Linter**: Strictly use **Biome**. Indentation is set to **tabs**.
|
||||||
|
- **Routing**: Use TanStack Router's file-based system in `src/routes/`. Avoid manual route definitions unless necessary.
|
||||||
|
- **UI Components**: Prefer Mantine UI components. Always wrap the app with `MantineProvider`.
|
||||||
|
- **Imports**: Use the `node:` protocol for Node.js built-ins (e.g., `import fs from "node:fs"`). Biome handles import organization automatically.
|
||||||
|
- **Types**: Maintain strict TypeScript compliance. Use `tsc --noEmit` to verify.
|
||||||
|
|
||||||
|
## Integration Details
|
||||||
|
|
||||||
|
- **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.
|
||||||
21
README.md
Normal file
21
README.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# bun-react-template
|
||||||
|
|
||||||
|
To install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
To start a development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
To run for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun start
|
||||||
|
```
|
||||||
|
|
||||||
|
This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||||
64
REFRENSI.md
Normal file
64
REFRENSI.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
src/vite.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createServer as createViteServer } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import Inspector from '@react-dev-inspector/vite-plugin'
|
||||||
|
|
||||||
|
export async function createVite() {
|
||||||
|
return createViteServer({
|
||||||
|
root: process.cwd(),
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
Inspector({
|
||||||
|
editor: 'code'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
middlewareMode: true
|
||||||
|
},
|
||||||
|
appType: 'custom'
|
||||||
|
```
|
||||||
|
|
||||||
|
src/index.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { Elysia } from 'elysia'
|
||||||
|
import { createVite } from './vite'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
const vite = await createVite()
|
||||||
|
const app = new Elysia()
|
||||||
|
|
||||||
|
// 🔹 Vite middleware
|
||||||
|
app.use({
|
||||||
|
name: 'vite',
|
||||||
|
async fn(ctx) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
vite.middlewares(ctx.req, ctx.res, err =>
|
||||||
|
err ? reject(err) : resolve()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🔹 Serve HTML entry
|
||||||
|
app.get('/', async ({ set }) => {
|
||||||
|
const htmlPath = path.resolve('src/client/index.html')
|
||||||
|
let html = fs.readFileSync(htmlPath, 'utf-8')
|
||||||
|
|
||||||
|
html = await vite.transformIndexHtml('/', html)
|
||||||
|
|
||||||
|
set.headers['content-type'] = 'text/html'
|
||||||
|
return html
|
||||||
|
})
|
||||||
|
|
||||||
|
// 🔹 API contoh
|
||||||
|
app.get('/api/health', () => ({ ok: true }))
|
||||||
|
|
||||||
|
app.listen(3000)
|
||||||
|
|
||||||
|
console.log('🚀 http://localhost:3000')
|
||||||
|
|
||||||
|
```
|
||||||
34
biome.json
Normal file
34
biome.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.3.14/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": false
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab"
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "double"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"enabled": true,
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
bun-env.d.ts
vendored
Normal file
17
bun-env.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// Generated by `bun init`
|
||||||
|
|
||||||
|
declare module "*.svg" {
|
||||||
|
/**
|
||||||
|
* A path to the SVG file
|
||||||
|
*/
|
||||||
|
const path: `${string}.svg`;
|
||||||
|
export = path;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.module.css" {
|
||||||
|
/**
|
||||||
|
* A record of class names to their corresponding CSS module classes
|
||||||
|
*/
|
||||||
|
const classes: { readonly [key: string]: string };
|
||||||
|
export = classes;
|
||||||
|
}
|
||||||
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[serve.static]
|
||||||
|
env = "BUN_PUBLIC_*"
|
||||||
60
package.json
Normal file
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "bun-react-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "REACT_EDITOR=antigravity bun --hot src/index.ts",
|
||||||
|
"vite:dev": "bun vite-config.ts",
|
||||||
|
"vite:build": "bun vite-config.ts build",
|
||||||
|
"vite:preview": "bun vite-config.ts preview",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"check": "biome check --write .",
|
||||||
|
"format": "biome format --write .",
|
||||||
|
"build": "bun build ./src/index.html --outdir=dist --sourcemap --target=browser --minify --define:process.env.NODE_ENV='\"production\"' --env='BUN_PUBLIC_*'",
|
||||||
|
"start": "NODE_ENV=production bun src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@better-auth/cli": "^1.4.18",
|
||||||
|
"@elysiajs/cors": "^1.4.1",
|
||||||
|
"@elysiajs/eden": "^1.4.6",
|
||||||
|
"@elysiajs/swagger": "^1.3.1",
|
||||||
|
"@mantine/core": "^8.3.14",
|
||||||
|
"@mantine/hooks": "^8.3.14",
|
||||||
|
"@mantine/dates": "^8.3.13",
|
||||||
|
"@react-dev-inspector/vite-plugin": "^2.0.1",
|
||||||
|
"@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",
|
||||||
|
"@tabler/icons-react": "^3.36.1",
|
||||||
|
"better-auth": "^1.4.18",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
|
"pino": "^10.3.0",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
|
"valtio": "^2.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.29.0",
|
||||||
|
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||||
|
"@biomejs/biome": "2.3.14",
|
||||||
|
"@tanstack/react-router-devtools": "^1.158.1",
|
||||||
|
"@tanstack/router-vite-plugin": "^1.158.1",
|
||||||
|
"@types/bun": "latest",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^5.1.3",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"prisma": "^6.19.2",
|
||||||
|
"@tanstack/router-cli": "^1.157.16",
|
||||||
|
"@tanstack/router-plugin": "^1.157.16",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"fast-glob": "^3.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
postcss.config.cjs
Normal file
14
postcss.config.cjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-preset-mantine": {},
|
||||||
|
"postcss-simple-vars": {
|
||||||
|
variables: {
|
||||||
|
"mantine-breakpoint-xs": "36em",
|
||||||
|
"mantine-breakpoint-sm": "48em",
|
||||||
|
"mantine-breakpoint-md": "62em",
|
||||||
|
"mantine-breakpoint-lg": "75em",
|
||||||
|
"mantine-breakpoint-xl": "88em",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
16
prisma.config.ts
Normal file
16
prisma.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
|
||||||
|
// This file was generated by Prisma and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
engine: "classic",
|
||||||
|
datasource: {
|
||||||
|
url: env("DATABASE_URL"),
|
||||||
|
},
|
||||||
|
});
|
||||||
88
prisma/schema.prisma
Normal file
88
prisma/schema.prisma
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
name String?
|
||||||
|
emailVerified Boolean?
|
||||||
|
image String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
role String? @default("user")
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
apiKeys ApiKey[]
|
||||||
|
|
||||||
|
@@map("user")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
expiresAt DateTime
|
||||||
|
token String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("session")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
accountId String
|
||||||
|
providerId String
|
||||||
|
accessToken String?
|
||||||
|
refreshToken String?
|
||||||
|
expiresAt DateTime?
|
||||||
|
password String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
idToken String?
|
||||||
|
accessTokenExpiresAt DateTime?
|
||||||
|
refreshTokenExpiresAt DateTime?
|
||||||
|
scope String?
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("account")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Verification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
identifier String
|
||||||
|
value String
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([identifier])
|
||||||
|
@@map("verification")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
key String @unique
|
||||||
|
userId String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
expiresAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("api_key")
|
||||||
|
}
|
||||||
49
prisma/seed.ts
Normal file
49
prisma/seed.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { prisma } from "@/utils/db";
|
||||||
|
|
||||||
|
async function seedAdminUser() {
|
||||||
|
// Load environment variables
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL;
|
||||||
|
|
||||||
|
if (!adminEmail) {
|
||||||
|
console.log("No ADMIN_EMAIL environment variable found. Skipping admin role assignment.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if admin user already exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
// Update existing user to have admin role if they don't already
|
||||||
|
if (existingUser.role !== "admin") {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
data: { role: "admin" },
|
||||||
|
});
|
||||||
|
console.log(`User with email ${adminEmail} updated to admin role.`);
|
||||||
|
} else {
|
||||||
|
console.log(`User with email ${adminEmail} already has admin role.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`No user found with email ${adminEmail}. Skipping admin role assignment.`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error seeding admin user:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("Seeding database...");
|
||||||
|
|
||||||
|
await seedAdminUser();
|
||||||
|
|
||||||
|
console.log("Database seeding completed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error("Error during seeding:", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
233
src/api/apikey.ts
Normal file
233
src/api/apikey.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/** biome-ignore-all lint/suspicious/noExplicitAny: <explanation */
|
||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { prisma } from "../utils/db";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
export const apikey = new Elysia({
|
||||||
|
prefix: "/apikey",
|
||||||
|
})
|
||||||
|
.get(
|
||||||
|
"/",
|
||||||
|
async (ctx) => {
|
||||||
|
const { set, user } = ctx as any;
|
||||||
|
try {
|
||||||
|
// logger.info({ userId: user?.id }, 'Fetching API keys');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeys = await prisma.apiKey.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
key: true,
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ count: apiKeys.length, userId: user?.id },
|
||||||
|
"Fetched API keys",
|
||||||
|
);
|
||||||
|
|
||||||
|
return { apiKeys };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to fetch API keys");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to fetch API keys" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
detail: {
|
||||||
|
summary: "Get all API keys",
|
||||||
|
description: "Get all API keys",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/",
|
||||||
|
async (ctx) => {
|
||||||
|
const { body, set, user } = ctx as any;
|
||||||
|
try {
|
||||||
|
const { name, expiresAt } = body;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique API key
|
||||||
|
const apiKeyValue = `sk-${nanoid(32)}`;
|
||||||
|
|
||||||
|
const newApiKey = await prisma.apiKey.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
key: apiKeyValue,
|
||||||
|
userId: user.id,
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
key: true,
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { apiKey: newApiKey };
|
||||||
|
} catch (error) {
|
||||||
|
set.status = 500;
|
||||||
|
logger.error({ error }, "Failed to create API key");
|
||||||
|
return { error: "Failed to create API key" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
expiresAt: t.Optional(t.String()), // ISO date string
|
||||||
|
}),
|
||||||
|
detail: {
|
||||||
|
summary: "Create a new API key",
|
||||||
|
description: "Create a new API key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/update",
|
||||||
|
async (ctx) => {
|
||||||
|
const { body, set, user } = ctx as any;
|
||||||
|
try {
|
||||||
|
const { id, isActive, expiresAt } = body;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ id, isActive, expiresAt, userId: user?.id },
|
||||||
|
"Patch API key called",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
logger.error(
|
||||||
|
{ id, isActive, expiresAt, userId: user?.id },
|
||||||
|
"Unauthorized",
|
||||||
|
);
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the API key belongs to the user
|
||||||
|
const apiKey = await prisma.apiKey.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug({ apiKey }, "Found API key");
|
||||||
|
|
||||||
|
if (!apiKey || apiKey.userId !== user.id) {
|
||||||
|
set.status = 403;
|
||||||
|
logger.error({ id, apiKey, userId: user?.id }, "Forbidden");
|
||||||
|
return { error: "Forbidden" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedApiKey = await prisma.apiKey.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
isActive,
|
||||||
|
expiresAt:
|
||||||
|
expiresAt !== undefined
|
||||||
|
? expiresAt
|
||||||
|
? new Date(expiresAt)
|
||||||
|
: null
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
key: true,
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ apiKeyId: updatedApiKey.id }, "Updated API key");
|
||||||
|
|
||||||
|
return { apiKey: updatedApiKey };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Error updating API key");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to update API key" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
isActive: t.Boolean(),
|
||||||
|
expiresAt: t.Optional(t.Union([t.String(), t.Null()])), // ISO date string or null
|
||||||
|
}),
|
||||||
|
detail: {
|
||||||
|
summary: "Update an API key",
|
||||||
|
description: "Update an API key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/delete",
|
||||||
|
async (ctx) => {
|
||||||
|
const { body, set, user } = ctx as any;
|
||||||
|
try {
|
||||||
|
const { id } = body;
|
||||||
|
|
||||||
|
logger.info({ id, userId: user?.id }, "Deleting API key");
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the API key belongs to the user
|
||||||
|
const apiKey = await prisma.apiKey.findUnique({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiKey || apiKey.userId !== user.id) {
|
||||||
|
set.status = 403;
|
||||||
|
logger.warn(
|
||||||
|
{ id, userId: user?.id },
|
||||||
|
"Attempt to delete API key from another user",
|
||||||
|
);
|
||||||
|
return { error: "Forbidden" };
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.apiKey.delete({
|
||||||
|
where: { id },
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ id }, "Deleted API key");
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "Failed to delete API key");
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Failed to delete API key" };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
id: t.String(),
|
||||||
|
}),
|
||||||
|
detail: {
|
||||||
|
summary: "Delete an API key",
|
||||||
|
description: "Delete an API key",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
73
src/frontend.tsx
Normal file
73
src/frontend.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* This file is the entry point for the React app, it sets up the root
|
||||||
|
* element and renders the App component to the DOM.
|
||||||
|
*
|
||||||
|
* It is included in `src/index.html`.
|
||||||
|
*/
|
||||||
|
/** biome-ignore-all lint/style/noNonNullAssertion: <explanation */
|
||||||
|
/** biome-ignore-all lint/suspicious/noAssignInExpressions: <explanation */
|
||||||
|
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { RouterProvider } from "@tanstack/react-router";
|
||||||
|
import { Inspector } from "react-dev-inspector";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { createTheme } from "@mantine/core";
|
||||||
|
|
||||||
|
import { createRouter } from "@tanstack/react-router";
|
||||||
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
|
// Create a new router instance
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: "intent",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the router instance for type safety
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const theme = createTheme({
|
||||||
|
/** Theme customization here */
|
||||||
|
});
|
||||||
|
|
||||||
|
const InspectorWrapper = import.meta.env.DEV
|
||||||
|
? Inspector
|
||||||
|
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||||
|
|
||||||
|
const elem = document.getElementById("root")!;
|
||||||
|
const app = (
|
||||||
|
<InspectorWrapper
|
||||||
|
keys={["shift", "a"]}
|
||||||
|
onClickElement={(e) => {
|
||||||
|
if (!e.codeInfo) return;
|
||||||
|
|
||||||
|
const url = import.meta.env.VITE_PUBLIC_URL;
|
||||||
|
fetch(`${url}/__open-in-editor`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
relativePath: e.codeInfo.relativePath,
|
||||||
|
lineNumber: e.codeInfo.lineNumber,
|
||||||
|
columnNumber: e.codeInfo.columnNumber,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MantineProvider theme={theme}>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</MantineProvider>
|
||||||
|
</InspectorWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||||
|
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||||
|
root.render(app);
|
||||||
|
} else {
|
||||||
|
// The hot module reloading API is not available in production.
|
||||||
|
createRoot(elem).render(app);
|
||||||
|
}
|
||||||
187
src/index.css
Normal file
187
src/index.css
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
:root {
|
||||||
|
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
line-height: 1.5;
|
||||||
|
color-scheme: light dark;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
background-color: #242424;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.05;
|
||||||
|
background: url("./logo.svg");
|
||||||
|
background-size: 256px;
|
||||||
|
transform: rotate(-12deg) scale(1.35);
|
||||||
|
animation: slide 30s linear infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes slide {
|
||||||
|
from {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-position: 256px 224px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.app {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 0.3s;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.bun-logo {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
.bun-logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #fbf0dfaa);
|
||||||
|
}
|
||||||
|
.react-logo {
|
||||||
|
animation: spin 20s linear infinite;
|
||||||
|
}
|
||||||
|
.react-logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 3.2em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
border-radius: 0.3em;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.api-tester {
|
||||||
|
margin: 2rem auto 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
text-align: left;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.endpoint-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: #1a1a1a;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font: monospace;
|
||||||
|
border: 2px solid #fbf0df;
|
||||||
|
transition: 0.3s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.endpoint-row:focus-within {
|
||||||
|
border-color: #f3d5a3;
|
||||||
|
}
|
||||||
|
.method {
|
||||||
|
background: #fbf0df;
|
||||||
|
color: #1a1a1a;
|
||||||
|
padding: 0.3rem 0.7rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9em;
|
||||||
|
appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
width: min-content;
|
||||||
|
display: block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.method option {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.url-input {
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
background: 0;
|
||||||
|
border: 0;
|
||||||
|
color: #fbf0df;
|
||||||
|
font: 1em monospace;
|
||||||
|
padding: 0.2rem;
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
.url-input:focus {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.url-input::placeholder {
|
||||||
|
color: rgba(251, 240, 223, 0.4);
|
||||||
|
}
|
||||||
|
.send-button {
|
||||||
|
background: #fbf0df;
|
||||||
|
color: #1a1a1a;
|
||||||
|
border: 0;
|
||||||
|
padding: 0.4rem 1.2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: 0.1s;
|
||||||
|
cursor: var(--bun-cursor);
|
||||||
|
}
|
||||||
|
.send-button:hover {
|
||||||
|
background: #f3d5a3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.response-area {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border: 2px solid #fbf0df;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: #fbf0df;
|
||||||
|
font: monospace;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.response-area:focus {
|
||||||
|
border-color: #f3d5a3;
|
||||||
|
}
|
||||||
|
.response-area::placeholder {
|
||||||
|
color: rgba(251, 240, 223, 0.4);
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/index.html
Normal file
14
src/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!-- data-mantine-color-scheme="dark" -->
|
||||||
|
<html lang="en" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
|
||||||
|
<title>Bun + React</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/frontend.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
154
src/index.ts
Normal file
154
src/index.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/** biome-ignore-all lint/suspicious/noExplicitAny: penjelasannya */
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { Elysia } from "elysia";
|
||||||
|
import { openInEditor } from "./utils/open-in-editor";
|
||||||
|
import { createVite } from "./vite";
|
||||||
|
import { apikey } from "./api/apikey";
|
||||||
|
import { auth } from "./utils/auth";
|
||||||
|
import { cors } from "@elysiajs/cors";
|
||||||
|
import { swagger } from "@elysiajs/swagger";
|
||||||
|
import { apiMiddleware } from "./middleware/apiMiddleware";
|
||||||
|
|
||||||
|
|
||||||
|
const api = new Elysia({
|
||||||
|
prefix: "/api",
|
||||||
|
})
|
||||||
|
.all("/auth/*", ({ request }) => auth.handler(request))
|
||||||
|
.use(cors())
|
||||||
|
.use(
|
||||||
|
swagger({
|
||||||
|
path: "/docs",
|
||||||
|
documentation: {
|
||||||
|
info: {
|
||||||
|
title: "Bun + React API",
|
||||||
|
version: "1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.get("/session", async ({ request }) => {
|
||||||
|
const data = await auth.api.getSession({ headers: request.headers });
|
||||||
|
return { data };
|
||||||
|
})
|
||||||
|
.use(apiMiddleware)
|
||||||
|
.use(apikey);
|
||||||
|
|
||||||
|
const vite = await createVite();
|
||||||
|
const app = new Elysia()
|
||||||
|
|
||||||
|
.post("/__open-in-editor", ({ body }) => {
|
||||||
|
const { relativePath, lineNumber, columnNumber } = body as {
|
||||||
|
relativePath: string;
|
||||||
|
lineNumber: number;
|
||||||
|
columnNumber: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
openInEditor(relativePath, {
|
||||||
|
line: lineNumber,
|
||||||
|
column: columnNumber,
|
||||||
|
editor: "antigravity",
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
})
|
||||||
|
.use(api);
|
||||||
|
|
||||||
|
// Vite middleware for other requests
|
||||||
|
app.all("*", async ({ request }) => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
// Serve transformed index.html for root or any path that should be handled by the SPA
|
||||||
|
// We check if it's not a file request (doesn't have a file extension or is a known SPA route)
|
||||||
|
if (
|
||||||
|
pathname === "/" ||
|
||||||
|
(!pathname.includes(".") &&
|
||||||
|
!pathname.startsWith("/@") &&
|
||||||
|
!pathname.startsWith("/inspector") &&
|
||||||
|
!pathname.startsWith("/__open-stack-frame-in-editor"))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const htmlPath = path.resolve("src/index.html");
|
||||||
|
let html = fs.readFileSync(htmlPath, "utf-8");
|
||||||
|
html = await vite.transformIndexHtml(pathname, html);
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
headers: { "Content-Type": "text/html" },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<Response>((resolve) => {
|
||||||
|
// Use a Proxy to mock Node.js req because Bun's Request is read-only
|
||||||
|
const req = new Proxy(request, {
|
||||||
|
get(target, prop) {
|
||||||
|
if (prop === "url") return pathname + url.search;
|
||||||
|
if (prop === "method") return request.method;
|
||||||
|
if (prop === "headers")
|
||||||
|
return Object.fromEntries(request.headers as any);
|
||||||
|
return (target as any)[prop];
|
||||||
|
},
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
const res = {
|
||||||
|
statusCode: 200,
|
||||||
|
setHeader(name: string, value: string) {
|
||||||
|
this.headers[name.toLowerCase()] = value;
|
||||||
|
},
|
||||||
|
getHeader(name: string) {
|
||||||
|
return this.headers[name.toLowerCase()];
|
||||||
|
},
|
||||||
|
headers: {} as Record<string, string>,
|
||||||
|
end(data: any) {
|
||||||
|
// Handle potential Buffer or string data from Vite
|
||||||
|
let body = data;
|
||||||
|
if (data instanceof Uint8Array) {
|
||||||
|
body = data;
|
||||||
|
} else if (typeof data === "string") {
|
||||||
|
body = data;
|
||||||
|
} else if (data) {
|
||||||
|
body = String(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
new Response(body || "", {
|
||||||
|
status: this.statusCode,
|
||||||
|
headers: this.headers,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Minimal event emitter mock
|
||||||
|
once() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
on() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
emit() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
removeListener() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
vite.middlewares(req, res, (err: any) => {
|
||||||
|
if (err) {
|
||||||
|
console.error("Vite middleware error:", err);
|
||||||
|
resolve(new Response(err.stack || err.toString(), { status: 500 }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If Vite doesn't handle it, return 404
|
||||||
|
resolve(new Response("Not Found", { status: 404 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(3000);
|
||||||
|
|
||||||
|
console.log("🚀 Server running at http://localhost:3000");
|
||||||
|
|
||||||
|
export type ApiApp = typeof app;
|
||||||
1
src/logo.svg
Normal file
1
src/logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
95
src/middleware/apiMiddleware.tsx
Normal file
95
src/middleware/apiMiddleware.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import type Elysia from "elysia";
|
||||||
|
import { auth } from "@/utils/auth";
|
||||||
|
import { prisma } from "@/utils/db";
|
||||||
|
import logger from "@/utils/logger";
|
||||||
|
|
||||||
|
export function apiMiddleware(app: Elysia) {
|
||||||
|
return app
|
||||||
|
.derive(async ({ request }) => {
|
||||||
|
const headers = request.headers;
|
||||||
|
|
||||||
|
// First, try to get user from session (Better Auth)
|
||||||
|
const userSession = await auth.api.getSession({
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userSession?.user) {
|
||||||
|
// Return user data from session if authenticated via session
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
...userSession.user,
|
||||||
|
id: userSession.user.id,
|
||||||
|
email: userSession.user.email,
|
||||||
|
name: userSession.user.name,
|
||||||
|
image: userSession.user.image,
|
||||||
|
emailVerified: userSession.user.emailVerified,
|
||||||
|
role: userSession.user.role || "user",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no session, try API key authentication
|
||||||
|
let apiKey = headers.get("x-api-key");
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
// Also check Authorization header for API key
|
||||||
|
const authHeader =
|
||||||
|
headers.get("authorization") || headers.get("Authorization");
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
apiKey = authHeader.substring(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Look up the API key in the database
|
||||||
|
const apiKeyRecord = await prisma.apiKey.findFirst({
|
||||||
|
where: {
|
||||||
|
key: apiKey,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: true, // Include the associated user
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!apiKeyRecord) {
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if API key has expired
|
||||||
|
if (
|
||||||
|
apiKeyRecord.expiresAt &&
|
||||||
|
new Date(apiKeyRecord.expiresAt) < new Date()
|
||||||
|
) {
|
||||||
|
logger.info({ keyId: apiKeyRecord.id }, "[AUTH] API key expired");
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the associated user data
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: apiKeyRecord.user.id,
|
||||||
|
email: apiKeyRecord.user.email,
|
||||||
|
name: apiKeyRecord.user.name,
|
||||||
|
image: apiKeyRecord.user.image,
|
||||||
|
emailVerified: apiKeyRecord.user.emailVerified,
|
||||||
|
role: apiKeyRecord.user.role || "user",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, "[AUTH] Error verifying API key");
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.onBeforeHandle(({ user, set, request }) => {
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`[AUTH] Unauthorized: ${request.method} ${request.url}`);
|
||||||
|
set.status = 401;
|
||||||
|
return { message: "Unauthorized" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
125
src/middleware/authMiddleware.tsx
Normal file
125
src/middleware/authMiddleware.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { redirect } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
* Types
|
||||||
|
* ================================ */
|
||||||
|
|
||||||
|
type UserRole = "user" | "admin";
|
||||||
|
|
||||||
|
type SessionUser = {
|
||||||
|
id: string;
|
||||||
|
role: UserRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionResponse = {
|
||||||
|
user?: SessionUser;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
* Session Fetcher
|
||||||
|
* ================================ */
|
||||||
|
|
||||||
|
async function fetchSession(): Promise<SessionResponse | null> {
|
||||||
|
try {
|
||||||
|
const baseURL =
|
||||||
|
import.meta.env.VITE_PUBLIC_URL || window.location.origin;
|
||||||
|
const res = await fetch(`${baseURL}/api/session`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const { data } = await res.json();
|
||||||
|
return data as SessionResponse;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
* Redirect Helper
|
||||||
|
* ================================ */
|
||||||
|
|
||||||
|
function redirectToLogin(to: string, currentHref: string) {
|
||||||
|
throw redirect({
|
||||||
|
to,
|
||||||
|
search: { redirect: currentHref },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
* Route Rules (Pattern Based)
|
||||||
|
* ================================ */
|
||||||
|
|
||||||
|
type RouteRule = {
|
||||||
|
match: (pathname: string) => boolean;
|
||||||
|
requireAuth?: boolean;
|
||||||
|
requiredRole?: UserRole;
|
||||||
|
redirectTo?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeRules: RouteRule[] = [
|
||||||
|
{
|
||||||
|
match: (p) => p === "/profile" || p.startsWith("/profile/"),
|
||||||
|
requireAuth: true,
|
||||||
|
redirectTo: "/signin",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
match: (p) => p === "/dashboard" || p.startsWith("/dashboard/"),
|
||||||
|
requireAuth: true,
|
||||||
|
requiredRole: "admin",
|
||||||
|
redirectTo: "/profile",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
* Rule Resolver
|
||||||
|
* ================================ */
|
||||||
|
|
||||||
|
function findRouteRule(pathname: string): RouteRule | undefined {
|
||||||
|
return routeRules.find((rule) => rule.match(pathname));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
* Protected Route Factory
|
||||||
|
* ================================ */
|
||||||
|
|
||||||
|
export interface ProtectedRouteOptions {
|
||||||
|
redirectTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProtectedRoute(options: ProtectedRouteOptions = {}) {
|
||||||
|
const { redirectTo = "/signin" } = options;
|
||||||
|
|
||||||
|
return async ({
|
||||||
|
location,
|
||||||
|
}: {
|
||||||
|
location: { pathname: string; href: string };
|
||||||
|
}) => {
|
||||||
|
const rule = findRouteRule(location.pathname);
|
||||||
|
if (!rule) return;
|
||||||
|
|
||||||
|
const session = await fetchSession();
|
||||||
|
const user = session?.user;
|
||||||
|
|
||||||
|
if (rule.requireAuth && !user) {
|
||||||
|
redirectToLogin(rule.redirectTo ?? redirectTo, location.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.requiredRole && user?.role !== rule.requiredRole) {
|
||||||
|
redirectToLogin(rule.redirectTo ?? redirectTo, location.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
* Default Middleware Export
|
||||||
|
* ================================ */
|
||||||
|
|
||||||
|
export const protectedRouteMiddleware = createProtectedRoute();
|
||||||
8
src/react.svg
Normal file
8
src/react.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
|
||||||
|
<circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
|
||||||
|
<g stroke="#61dafb" stroke-width="1" fill="none">
|
||||||
|
<ellipse rx="11" ry="4.2"/>
|
||||||
|
<ellipse rx="11" ry="4.2" transform="rotate(60)"/>
|
||||||
|
<ellipse rx="11" ry="4.2" transform="rotate(120)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 338 B |
281
src/routeTree.gen.ts
Normal file
281
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as SignupRouteImport } from './routes/signup'
|
||||||
|
import { Route as SigninRouteImport } from './routes/signin'
|
||||||
|
import { Route as ProfileRouteImport } from './routes/profile'
|
||||||
|
import { Route as DashboardRouteRouteImport } from './routes/dashboard/route'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
import { Route as UsersIndexRouteImport } from './routes/users/index'
|
||||||
|
import { Route as DashboardIndexRouteImport } from './routes/dashboard/index'
|
||||||
|
import { Route as UsersIdRouteImport } from './routes/users/$id'
|
||||||
|
import { Route as DashboardUsersRouteImport } from './routes/dashboard/users'
|
||||||
|
import { Route as DashboardSettingsRouteImport } from './routes/dashboard/settings'
|
||||||
|
import { Route as DashboardApikeyRouteImport } from './routes/dashboard/apikey'
|
||||||
|
|
||||||
|
const SignupRoute = SignupRouteImport.update({
|
||||||
|
id: '/signup',
|
||||||
|
path: '/signup',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const SigninRoute = SigninRouteImport.update({
|
||||||
|
id: '/signin',
|
||||||
|
path: '/signin',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ProfileRoute = ProfileRouteImport.update({
|
||||||
|
id: '/profile',
|
||||||
|
path: '/profile',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DashboardRouteRoute = DashboardRouteRouteImport.update({
|
||||||
|
id: '/dashboard',
|
||||||
|
path: '/dashboard',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const UsersIndexRoute = UsersIndexRouteImport.update({
|
||||||
|
id: '/users/',
|
||||||
|
path: '/users/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DashboardIndexRoute = DashboardIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => DashboardRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const UsersIdRoute = UsersIdRouteImport.update({
|
||||||
|
id: '/users/$id',
|
||||||
|
path: '/users/$id',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const DashboardUsersRoute = DashboardUsersRouteImport.update({
|
||||||
|
id: '/users',
|
||||||
|
path: '/users',
|
||||||
|
getParentRoute: () => DashboardRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardSettingsRoute = DashboardSettingsRouteImport.update({
|
||||||
|
id: '/settings',
|
||||||
|
path: '/settings',
|
||||||
|
getParentRoute: () => DashboardRouteRoute,
|
||||||
|
} as any)
|
||||||
|
const DashboardApikeyRoute = DashboardApikeyRouteImport.update({
|
||||||
|
id: '/apikey',
|
||||||
|
path: '/apikey',
|
||||||
|
getParentRoute: () => DashboardRouteRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/dashboard': typeof DashboardRouteRouteWithChildren
|
||||||
|
'/profile': typeof ProfileRoute
|
||||||
|
'/signin': typeof SigninRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
|
'/dashboard/apikey': typeof DashboardApikeyRoute
|
||||||
|
'/dashboard/settings': typeof DashboardSettingsRoute
|
||||||
|
'/dashboard/users': typeof DashboardUsersRoute
|
||||||
|
'/users/$id': typeof UsersIdRoute
|
||||||
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
|
'/users/': typeof UsersIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/profile': typeof ProfileRoute
|
||||||
|
'/signin': typeof SigninRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
|
'/dashboard/apikey': typeof DashboardApikeyRoute
|
||||||
|
'/dashboard/settings': typeof DashboardSettingsRoute
|
||||||
|
'/dashboard/users': typeof DashboardUsersRoute
|
||||||
|
'/users/$id': typeof UsersIdRoute
|
||||||
|
'/dashboard': typeof DashboardIndexRoute
|
||||||
|
'/users': typeof UsersIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/dashboard': typeof DashboardRouteRouteWithChildren
|
||||||
|
'/profile': typeof ProfileRoute
|
||||||
|
'/signin': typeof SigninRoute
|
||||||
|
'/signup': typeof SignupRoute
|
||||||
|
'/dashboard/apikey': typeof DashboardApikeyRoute
|
||||||
|
'/dashboard/settings': typeof DashboardSettingsRoute
|
||||||
|
'/dashboard/users': typeof DashboardUsersRoute
|
||||||
|
'/users/$id': typeof UsersIdRoute
|
||||||
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
|
'/users/': typeof UsersIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/profile'
|
||||||
|
| '/signin'
|
||||||
|
| '/signup'
|
||||||
|
| '/dashboard/apikey'
|
||||||
|
| '/dashboard/settings'
|
||||||
|
| '/dashboard/users'
|
||||||
|
| '/users/$id'
|
||||||
|
| '/dashboard/'
|
||||||
|
| '/users/'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/profile'
|
||||||
|
| '/signin'
|
||||||
|
| '/signup'
|
||||||
|
| '/dashboard/apikey'
|
||||||
|
| '/dashboard/settings'
|
||||||
|
| '/dashboard/users'
|
||||||
|
| '/users/$id'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/users'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/dashboard'
|
||||||
|
| '/profile'
|
||||||
|
| '/signin'
|
||||||
|
| '/signup'
|
||||||
|
| '/dashboard/apikey'
|
||||||
|
| '/dashboard/settings'
|
||||||
|
| '/dashboard/users'
|
||||||
|
| '/users/$id'
|
||||||
|
| '/dashboard/'
|
||||||
|
| '/users/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
DashboardRouteRoute: typeof DashboardRouteRouteWithChildren
|
||||||
|
ProfileRoute: typeof ProfileRoute
|
||||||
|
SigninRoute: typeof SigninRoute
|
||||||
|
SignupRoute: typeof SignupRoute
|
||||||
|
UsersIdRoute: typeof UsersIdRoute
|
||||||
|
UsersIndexRoute: typeof UsersIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/signup': {
|
||||||
|
id: '/signup'
|
||||||
|
path: '/signup'
|
||||||
|
fullPath: '/signup'
|
||||||
|
preLoaderRoute: typeof SignupRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/signin': {
|
||||||
|
id: '/signin'
|
||||||
|
path: '/signin'
|
||||||
|
fullPath: '/signin'
|
||||||
|
preLoaderRoute: typeof SigninRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/profile': {
|
||||||
|
id: '/profile'
|
||||||
|
path: '/profile'
|
||||||
|
fullPath: '/profile'
|
||||||
|
preLoaderRoute: typeof ProfileRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/dashboard': {
|
||||||
|
id: '/dashboard'
|
||||||
|
path: '/dashboard'
|
||||||
|
fullPath: '/dashboard'
|
||||||
|
preLoaderRoute: typeof DashboardRouteRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/users/': {
|
||||||
|
id: '/users/'
|
||||||
|
path: '/users'
|
||||||
|
fullPath: '/users/'
|
||||||
|
preLoaderRoute: typeof UsersIndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/dashboard/': {
|
||||||
|
id: '/dashboard/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/dashboard/'
|
||||||
|
preLoaderRoute: typeof DashboardIndexRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteRoute
|
||||||
|
}
|
||||||
|
'/users/$id': {
|
||||||
|
id: '/users/$id'
|
||||||
|
path: '/users/$id'
|
||||||
|
fullPath: '/users/$id'
|
||||||
|
preLoaderRoute: typeof UsersIdRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/dashboard/users': {
|
||||||
|
id: '/dashboard/users'
|
||||||
|
path: '/users'
|
||||||
|
fullPath: '/dashboard/users'
|
||||||
|
preLoaderRoute: typeof DashboardUsersRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteRoute
|
||||||
|
}
|
||||||
|
'/dashboard/settings': {
|
||||||
|
id: '/dashboard/settings'
|
||||||
|
path: '/settings'
|
||||||
|
fullPath: '/dashboard/settings'
|
||||||
|
preLoaderRoute: typeof DashboardSettingsRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteRoute
|
||||||
|
}
|
||||||
|
'/dashboard/apikey': {
|
||||||
|
id: '/dashboard/apikey'
|
||||||
|
path: '/apikey'
|
||||||
|
fullPath: '/dashboard/apikey'
|
||||||
|
preLoaderRoute: typeof DashboardApikeyRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteRoute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardRouteRouteChildren {
|
||||||
|
DashboardApikeyRoute: typeof DashboardApikeyRoute
|
||||||
|
DashboardSettingsRoute: typeof DashboardSettingsRoute
|
||||||
|
DashboardUsersRoute: typeof DashboardUsersRoute
|
||||||
|
DashboardIndexRoute: typeof DashboardIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardRouteRouteChildren: DashboardRouteRouteChildren = {
|
||||||
|
DashboardApikeyRoute: DashboardApikeyRoute,
|
||||||
|
DashboardSettingsRoute: DashboardSettingsRoute,
|
||||||
|
DashboardUsersRoute: DashboardUsersRoute,
|
||||||
|
DashboardIndexRoute: DashboardIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardRouteRouteWithChildren = DashboardRouteRoute._addFileChildren(
|
||||||
|
DashboardRouteRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
DashboardRouteRoute: DashboardRouteRouteWithChildren,
|
||||||
|
ProfileRoute: ProfileRoute,
|
||||||
|
SigninRoute: SigninRoute,
|
||||||
|
SignupRoute: SignupRoute,
|
||||||
|
UsersIdRoute: UsersIdRoute,
|
||||||
|
UsersIndexRoute: UsersIndexRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
15
src/router.tsx
Normal file
15
src/router.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createRouter } from "@tanstack/react-router";
|
||||||
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
|
// Create a new router instance
|
||||||
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
defaultPreload: "intent",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the router instance for type safety
|
||||||
|
declare module "@tanstack/react-router" {
|
||||||
|
interface Register {
|
||||||
|
router: typeof router;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/routes/__root.tsx
Normal file
19
src/routes/__root.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/** biome-ignore-all lint/suspicious/noExplicitAny: <explanation */
|
||||||
|
import { protectedRouteMiddleware } from "@/middleware/authMiddleware";
|
||||||
|
import { authStore } from "@/store/auth";
|
||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import "@mantine/dates/styles.css";
|
||||||
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
component: RootComponent,
|
||||||
|
beforeLoad: protectedRouteMiddleware,
|
||||||
|
onEnter({ context }) {
|
||||||
|
authStore.user = context?.user as any;
|
||||||
|
authStore.session = context?.session as any;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootComponent() {
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
||||||
485
src/routes/dashboard/apikey.tsx
Normal file
485
src/routes/dashboard/apikey.tsx
Normal file
@@ -0,0 +1,485 @@
|
|||||||
|
/** biome-ignore-all lint/suspicious/noExplicitAny: <explanation */
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
CopyButton,
|
||||||
|
Group,
|
||||||
|
LoadingOverlay,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { DatePicker, type DatePickerValue } from "@mantine/dates";
|
||||||
|
import {
|
||||||
|
IconCalendar,
|
||||||
|
IconCircleCheck,
|
||||||
|
IconCircleX,
|
||||||
|
IconClock,
|
||||||
|
IconCopy,
|
||||||
|
IconEye,
|
||||||
|
IconEyeOff,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconKey,
|
||||||
|
IconPlus,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
|
||||||
|
import { apiClient } from "../../utils/api-client";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/apikey")({
|
||||||
|
beforeLoad: protectedRouteMiddleware,
|
||||||
|
component: DashboardApikeyComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
key: string;
|
||||||
|
isActive: boolean;
|
||||||
|
expiresAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashboardApikeyComponent() {
|
||||||
|
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
|
const [newKeyName, setNewKeyName] = useState("");
|
||||||
|
const [newKeyExpiresAt, setNewKeyExpiresAt] = useState<DatePickerValue>(null);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [showKey, setShowKey] = useState<{ [key: string]: boolean }>({});
|
||||||
|
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||||
|
const [keyToDelete, setKeyToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchApiKeys = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiClient.api.apikey.get();
|
||||||
|
if (response.data) {
|
||||||
|
setApiKeys((response.data.apiKeys as any) || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to load API keys");
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchApiKeys();
|
||||||
|
}, [fetchApiKeys]);
|
||||||
|
|
||||||
|
const handleCreateApiKey = async () => {
|
||||||
|
if (!newKeyName.trim()) {
|
||||||
|
setError("API key name is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setCreating(true);
|
||||||
|
const response = await apiClient.api.apikey.post({
|
||||||
|
name: newKeyName,
|
||||||
|
expiresAt: newKeyExpiresAt ? dayjs(newKeyExpiresAt).toISOString() : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
setApiKeys([...apiKeys, response.data.apiKey as any]);
|
||||||
|
setNewKeyName("");
|
||||||
|
setNewKeyExpiresAt(null);
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to create API key");
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleApiKey = async (id: string, currentStatus: boolean) => {
|
||||||
|
try {
|
||||||
|
if (!id) {
|
||||||
|
setError("API key ID is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await apiClient.api.apikey.update.post({
|
||||||
|
id,
|
||||||
|
isActive: !currentStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data) {
|
||||||
|
setApiKeys(
|
||||||
|
apiKeys.map((key) =>
|
||||||
|
key.id === id ? { ...key, isActive: !currentStatus } : key,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to update API key status");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteApiKey = async (id: string) => {
|
||||||
|
// Store the key ID and open the confirmation modal
|
||||||
|
setKeyToDelete(id);
|
||||||
|
setDeleteModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDeleteApiKey = async () => {
|
||||||
|
if (!keyToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.api.apikey.delete.post({
|
||||||
|
id: keyToDelete,
|
||||||
|
});
|
||||||
|
setApiKeys(apiKeys.filter((key: ApiKey) => key.id !== keyToDelete));
|
||||||
|
setDeleteModalOpen(false);
|
||||||
|
setKeyToDelete(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError("Failed to delete API key");
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleShowKey = (id: string) => {
|
||||||
|
setShowKey((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: !prev[id],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="lg" py="xl">
|
||||||
|
<Title order={1} mb="lg" ta="center">
|
||||||
|
API Keys Management
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert title="Error" color="red" mb="md">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
p="xl"
|
||||||
|
radius="md"
|
||||||
|
bg="rgba(251, 240, 223, 0.05)"
|
||||||
|
style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" mb="md">
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Title order={3}>Your API Keys</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Manage your API keys for secure access to our services
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
>
|
||||||
|
Create New API Key
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Table striped highlightOnHover mt="md" verticalSpacing="md">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconKey size={16} stroke={1.5} /> Name
|
||||||
|
</Group>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconKey size={16} stroke={1.5} /> Key
|
||||||
|
</Group>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconCircleCheck size={16} stroke={1.5} /> Status
|
||||||
|
</Group>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconCalendar size={16} stroke={1.5} /> Expiration
|
||||||
|
</Group>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconClock size={16} stroke={1.5} /> Created
|
||||||
|
</Group>
|
||||||
|
</Table.Th>
|
||||||
|
<Table.Th>
|
||||||
|
<Group gap={6}>
|
||||||
|
<IconInfoCircle size={16} stroke={1.5} /> Actions
|
||||||
|
</Group>
|
||||||
|
</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{apiKeys.map((apiKey) => (
|
||||||
|
<Table.Tr
|
||||||
|
key={apiKey.id}
|
||||||
|
style={{ backgroundColor: "rgba(251, 240, 223, 0.02)" }}
|
||||||
|
>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={500} c="#fbf0df">
|
||||||
|
{apiKey.name}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={6}>
|
||||||
|
{showKey[apiKey.id] ? (
|
||||||
|
<Text
|
||||||
|
c="#f3d5a3"
|
||||||
|
style={{ fontFamily: "monospace", fontSize: "0.85rem" }}
|
||||||
|
>
|
||||||
|
{apiKey.key}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
style={{ fontFamily: "monospace", fontSize: "0.85rem" }}
|
||||||
|
>
|
||||||
|
••••••••••••••••••••••••••••••••
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<CopyButton value={apiKey.key}>
|
||||||
|
{({ copied, copy }) => (
|
||||||
|
<Tooltip label={copied ? "Copied" : "Copy"}>
|
||||||
|
<ActionIcon
|
||||||
|
color={copied ? "green" : "gray"}
|
||||||
|
onClick={copy}
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<IconCopy size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</CopyButton>
|
||||||
|
<Tooltip
|
||||||
|
label={showKey[apiKey.id] ? "Hide key" : "Show key"}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
color="gray"
|
||||||
|
onClick={() => toggleShowKey(apiKey.id)}
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{showKey[apiKey.id] ? (
|
||||||
|
<IconEyeOff size={16} />
|
||||||
|
) : (
|
||||||
|
<IconEye size={16} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group>
|
||||||
|
<Tooltip
|
||||||
|
label={`API Key is ${apiKey.isActive ? "Active" : "Inactive"}`}
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={apiKey.isActive}
|
||||||
|
onChange={() =>
|
||||||
|
handleToggleApiKey(apiKey.id, apiKey.isActive)
|
||||||
|
}
|
||||||
|
size="md"
|
||||||
|
color={apiKey.isActive ? "green" : "gray"}
|
||||||
|
onLabel={<IconCircleCheck size={12} stroke={1.5} />}
|
||||||
|
offLabel={<IconCircleX size={12} stroke={1.5} />}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{apiKey.expiresAt ? (
|
||||||
|
<Group>
|
||||||
|
<Text>{formatDate(apiKey.expiresAt)}</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{formatTime(apiKey.expiresAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" color="blue">
|
||||||
|
Never Expires
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group>
|
||||||
|
<Text>{formatDate(apiKey.createdAt)}</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{formatTime(apiKey.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group>
|
||||||
|
<Tooltip label="Delete API Key">
|
||||||
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
|
onClick={() => handleDeleteApiKey(apiKey.id)}
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<IconTrash size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{apiKeys.length === 0 && !loading && (
|
||||||
|
<Card
|
||||||
|
p="xl"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
mt="xl"
|
||||||
|
bg="rgba(251, 240, 223, 0.03)"
|
||||||
|
>
|
||||||
|
<Group justify="center" align="center">
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<IconKey
|
||||||
|
size={48}
|
||||||
|
stroke={1.2}
|
||||||
|
color="rgba(251, 240, 223, 0.3)"
|
||||||
|
/>
|
||||||
|
<Text ta="center" c="dimmed" fz="lg">
|
||||||
|
No API keys created yet
|
||||||
|
</Text>
|
||||||
|
<Text ta="center" c="dimmed" size="sm">
|
||||||
|
Get started by creating your first API key
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => setCreateModalOpen(true)}
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
mt="md"
|
||||||
|
>
|
||||||
|
Create New API Key
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={createModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
title="Create New API Key"
|
||||||
|
centered
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<LoadingOverlay
|
||||||
|
visible={creating}
|
||||||
|
zIndex={1000}
|
||||||
|
overlayProps={{ radius: "sm", blur: 2 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="API Key Name"
|
||||||
|
placeholder="Enter a descriptive name for your API key"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.currentTarget.value)}
|
||||||
|
mb="md"
|
||||||
|
description="Choose a name that identifies the purpose of this API key"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DatePicker
|
||||||
|
value={newKeyExpiresAt}
|
||||||
|
onChange={setNewKeyExpiresAt}
|
||||||
|
mb="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateModalOpen(false);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={handleCreateApiKey}
|
||||||
|
color="blue"
|
||||||
|
>
|
||||||
|
Create API Key
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={deleteModalOpen}
|
||||||
|
onClose={() => setDeleteModalOpen(false)}
|
||||||
|
title="Confirm Delete"
|
||||||
|
centered
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text>Are you sure you want to delete this API key?</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
This action cannot be undone.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="xl">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setDeleteModalOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="red" onClick={confirmDeleteApiKey}>
|
||||||
|
Delete API Key
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
224
src/routes/dashboard/index.tsx
Normal file
224
src/routes/dashboard/index.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
Progress,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconClock,
|
||||||
|
IconDatabase,
|
||||||
|
IconServer,
|
||||||
|
IconUserCheck,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSnapshot } from "valtio";
|
||||||
|
import { authClient } from "@/utils/auth-client";
|
||||||
|
import { authStore } from "../../store/auth";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/")({
|
||||||
|
component: DashboardComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function DashboardComponent() {
|
||||||
|
const snap = useSnapshot(authStore);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [logoutModalOpen, setLogoutModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// Mock data for dashboard stats
|
||||||
|
const statsData = [
|
||||||
|
{ title: "Total Users", value: "1,234", icon: <IconUserCheck size={24} /> },
|
||||||
|
{ title: "Server Uptime", value: "99.9%", icon: <IconClock size={24} /> },
|
||||||
|
{ title: "Database Load", value: "42%", icon: <IconDatabase size={24} /> },
|
||||||
|
{ title: "Active Sessions", value: "128", icon: <IconServer size={24} /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="lg" py="xl">
|
||||||
|
<Title order={1} mb="lg" ta="center">
|
||||||
|
Dashboard Overview
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{/* User Profile Card */}
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
p="xl"
|
||||||
|
radius="md"
|
||||||
|
mb="xl"
|
||||||
|
bg="rgba(251, 240, 223, 0.05)"
|
||||||
|
style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<Avatar
|
||||||
|
src={snap.user?.image}
|
||||||
|
size={80}
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
border: "2px solid rgba(251, 240, 223, 0.3)",
|
||||||
|
}}
|
||||||
|
onClick={() => navigate({ to: "/profile" })}
|
||||||
|
>
|
||||||
|
{snap.user?.name?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<Text size="lg" fw={600} c="#fbf0df">
|
||||||
|
{snap.user?.name}
|
||||||
|
</Text>
|
||||||
|
<Text c="dimmed" size="sm">
|
||||||
|
{snap.user?.email}
|
||||||
|
</Text>
|
||||||
|
<Badge mt="xs" variant="light" color="green">
|
||||||
|
Verified Account
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
color="red"
|
||||||
|
onClick={() => setLogoutModalOpen(true)}
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="lg" mb="xl">
|
||||||
|
{statsData.map((stat, index) => (
|
||||||
|
<Card
|
||||||
|
key={index.toString()}
|
||||||
|
withBorder
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
bg="rgba(251, 240, 223, 0.05)"
|
||||||
|
>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Box>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{stat.title}
|
||||||
|
</Text>
|
||||||
|
<Text size="lg" fw={700} c="#fbf0df">
|
||||||
|
{stat.value}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box c="#f3d5a3">{stat.icon}</Box>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Grid gutter="lg">
|
||||||
|
<Grid.Col span={{ base: 12, md: 8 }}>
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
p="lg"
|
||||||
|
radius="md"
|
||||||
|
mb="lg"
|
||||||
|
bg="rgba(251, 240, 223, 0.05)"
|
||||||
|
>
|
||||||
|
<Title order={3} mb="md">
|
||||||
|
System Performance
|
||||||
|
</Title>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="sm">CPU Usage</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
32%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress value={32} color="green" />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="sm">Memory Usage</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
64%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress value={64} color="blue" />
|
||||||
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Group justify="space-between" mb="xs">
|
||||||
|
<Text size="sm">Disk Usage</Text>
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
45%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress value={45} color="yellow" />
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
|
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||||
|
<Title order={3} mb="md">
|
||||||
|
Server Status
|
||||||
|
</Title>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm">Main Server</Text>
|
||||||
|
<Badge color="green" variant="light">
|
||||||
|
Online
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm">Database</Text>
|
||||||
|
<Badge color="green" variant="light">
|
||||||
|
Connected
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm">Cache</Text>
|
||||||
|
<Badge color="green" variant="light">
|
||||||
|
Running
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm">Backup</Text>
|
||||||
|
<Badge color="orange" variant="light">
|
||||||
|
Pending
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
src/routes/dashboard/route.tsx
Normal file
122
src/routes/dashboard/route.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
AppShell,
|
||||||
|
Burger,
|
||||||
|
Group,
|
||||||
|
NavLink,
|
||||||
|
rem,
|
||||||
|
ScrollArea,
|
||||||
|
Text,
|
||||||
|
Title
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import {
|
||||||
|
IconHome,
|
||||||
|
IconKey,
|
||||||
|
IconSettings,
|
||||||
|
IconUsers
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import {
|
||||||
|
createFileRoute,
|
||||||
|
Outlet,
|
||||||
|
useLocation,
|
||||||
|
useNavigate,
|
||||||
|
} from "@tanstack/react-router";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard")({
|
||||||
|
component: DashboardLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function DashboardLayout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
|
||||||
|
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ icon: IconHome, label: "Beranda", to: "/dashboard" },
|
||||||
|
{ icon: IconUsers, label: "Pengguna", to: "/dashboard/users" },
|
||||||
|
{ icon: IconKey, label: "API Key", to: "/dashboard/apikey" },
|
||||||
|
{ icon: IconSettings, label: "Pengaturan", to: "/dashboard/settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
|
const current = location.pathname;
|
||||||
|
|
||||||
|
if (path === "/dashboard") {
|
||||||
|
return current === "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
return current === path || current.startsWith(`${path}/`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
navbar={{
|
||||||
|
width: 300,
|
||||||
|
breakpoint: "sm",
|
||||||
|
collapsed: { mobile: !mobileOpened, desktop: !desktopOpened },
|
||||||
|
}}
|
||||||
|
padding="md"
|
||||||
|
header={{ height: 60 }}
|
||||||
|
>
|
||||||
|
<AppShell.Header>
|
||||||
|
<Group h="100%" px="md" justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<Burger
|
||||||
|
opened={mobileOpened}
|
||||||
|
onClick={toggleMobile}
|
||||||
|
hiddenFrom="sm"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Burger
|
||||||
|
opened={desktopOpened}
|
||||||
|
onClick={toggleDesktop}
|
||||||
|
visibleFrom="sm"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Title order={3}>Dashboard</Title>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<ActionIcon variant="subtle" size="lg">
|
||||||
|
<IconSettings
|
||||||
|
style={{ width: rem(20), height: rem(20) }}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</AppShell.Header>
|
||||||
|
|
||||||
|
<AppShell.Navbar p="md">
|
||||||
|
<ScrollArea h="calc(100vh - 120px)">
|
||||||
|
<Group mb="lg">
|
||||||
|
<Text fw={500} size="lg">
|
||||||
|
Navigasi Utama
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
onClick={() => {
|
||||||
|
navigate({ to: item.to });
|
||||||
|
}}
|
||||||
|
leftSection={
|
||||||
|
<item.icon
|
||||||
|
style={{ width: rem(18), height: rem(18) }}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={item.label}
|
||||||
|
active={isActive(item.to)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ScrollArea>
|
||||||
|
</AppShell.Navbar>
|
||||||
|
|
||||||
|
<AppShell.Main>
|
||||||
|
<Outlet />
|
||||||
|
</AppShell.Main>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/routes/dashboard/settings.tsx
Normal file
11
src/routes/dashboard/settings.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/settings")({
|
||||||
|
beforeLoad: protectedRouteMiddleware,
|
||||||
|
component: DashboardSettingsComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function DashboardSettingsComponent() {
|
||||||
|
return <div>Hello from /dashboard/settings!</div>;
|
||||||
|
}
|
||||||
11
src/routes/dashboard/users.tsx
Normal file
11
src/routes/dashboard/users.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { protectedRouteMiddleware } from "../../middleware/authMiddleware";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/users")({
|
||||||
|
beforeLoad: protectedRouteMiddleware,
|
||||||
|
component: DashboardUsersComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function DashboardUsersComponent() {
|
||||||
|
return <div>Hello from /dashboard/users!</div>;
|
||||||
|
}
|
||||||
788
src/routes/index.tsx
Normal file
788
src/routes/index.tsx
Normal file
@@ -0,0 +1,788 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
Paper,
|
||||||
|
rem,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Transition,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconApi,
|
||||||
|
IconBolt,
|
||||||
|
IconBrandGithub,
|
||||||
|
IconBrandLinkedin,
|
||||||
|
IconBrandTwitter,
|
||||||
|
IconChevronRight,
|
||||||
|
IconLock,
|
||||||
|
IconMoon,
|
||||||
|
IconRocket,
|
||||||
|
IconShield,
|
||||||
|
IconStack2,
|
||||||
|
IconSun,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/")({
|
||||||
|
component: HomePage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation items
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ label: "Home", link: "/" },
|
||||||
|
{ label: "Features", link: "#features" },
|
||||||
|
{ label: "Testimonials", link: "#testimonials" },
|
||||||
|
{ label: "Pricing", link: "/pricing" },
|
||||||
|
{ label: "Contact", link: "/contact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Features data
|
||||||
|
const FEATURES = [
|
||||||
|
{
|
||||||
|
icon: IconBolt,
|
||||||
|
title: "Lightning Fast",
|
||||||
|
description: "Built on Bun runtime for exceptional performance and speed.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconShield,
|
||||||
|
title: "Secure by Design",
|
||||||
|
description:
|
||||||
|
"Enterprise-grade authentication with Better Auth integration.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconApi,
|
||||||
|
title: "RESTful API",
|
||||||
|
description:
|
||||||
|
"Full-featured API with Elysia.js for seamless backend operations.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconStack2,
|
||||||
|
title: "Modern Stack",
|
||||||
|
description: "React 19, TanStack Router, and Mantine UI for the best DX.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconLock,
|
||||||
|
title: "API Key Auth",
|
||||||
|
description: "Secure API key management for external integrations.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconRocket,
|
||||||
|
title: "Production Ready",
|
||||||
|
description: "Type-safe, tested, and optimized for production deployment.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Testimonials data
|
||||||
|
const TESTIMONIALS = [
|
||||||
|
{
|
||||||
|
id: "testimonial-1",
|
||||||
|
name: "Alex Johnson",
|
||||||
|
role: "Lead Developer",
|
||||||
|
content:
|
||||||
|
"This template saved us weeks of setup time. The architecture is clean and well-thought-out.",
|
||||||
|
avatar:
|
||||||
|
"https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=200&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "testimonial-2",
|
||||||
|
name: "Sarah Williams",
|
||||||
|
role: "CTO",
|
||||||
|
content:
|
||||||
|
"The performance improvements we saw after switching to this stack were remarkable. Highly recommended!",
|
||||||
|
avatar:
|
||||||
|
"https://images.unsplash.com/photo-1494790108377-be9c29b29330?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=200&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "testimonial-3",
|
||||||
|
name: "Michael Chen",
|
||||||
|
role: "Product Manager",
|
||||||
|
content:
|
||||||
|
"The developer experience is top-notch. Everything is well-documented and easy to extend.",
|
||||||
|
avatar:
|
||||||
|
"https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=200&q=80",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function NavigationBar() {
|
||||||
|
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
setScrolled(window.scrollY > 20);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", handleScroll);
|
||||||
|
return () => window.removeEventListener("scroll", handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
h={70}
|
||||||
|
px="md"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
transition: "all 0.3s ease",
|
||||||
|
boxShadow: scrolled ? "0 2px 10px rgba(0,0,0,0.1)" : "none",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group h="100%" justify="space-between">
|
||||||
|
<Group>
|
||||||
|
<Link to="/" style={{ textDecoration: "none" }}>
|
||||||
|
<Title order={3} c="blue">
|
||||||
|
BunStack
|
||||||
|
</Title>
|
||||||
|
</Link>
|
||||||
|
<Group ml={50} visibleFrom="sm" gap="lg">
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const isActive = window.location.pathname === item.link;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={item.label}
|
||||||
|
component={Link}
|
||||||
|
to={item.link}
|
||||||
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
fontSize: rem(16),
|
||||||
|
padding: `${rem(8)} ${rem(12)}`,
|
||||||
|
borderRadius: rem(6),
|
||||||
|
transition: "all 0.2s ease",
|
||||||
|
color: isActive
|
||||||
|
? "var(--mantine-color-blue-6)"
|
||||||
|
: "var(--mantine-color-dimmed)",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
className="nav-item"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
onClick={() => toggleColorScheme()}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{colorScheme === "dark" ? (
|
||||||
|
<IconSun size={18} />
|
||||||
|
) : (
|
||||||
|
<IconMoon size={18} />
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
<Button component={Link} to="/signin" variant="light" size="sm">
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
<Button component={Link} to="/signup" size="sm">
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeroSection() {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Simulate delay for image transition
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setImageLoaded(true);
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
pt={rem(140)} // Adjusted padding for simpler header
|
||||||
|
pb={rem(60)}
|
||||||
|
>
|
||||||
|
<Container size="lg">
|
||||||
|
<Grid gutter={{ base: rem(40), md: rem(80) }} align="center">
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Transition
|
||||||
|
mounted={loaded}
|
||||||
|
transition="slide-up"
|
||||||
|
duration={600}
|
||||||
|
timingFunction="ease"
|
||||||
|
>
|
||||||
|
{(styles) => (
|
||||||
|
<Stack gap="xl" style={styles}>
|
||||||
|
<Title
|
||||||
|
order={1}
|
||||||
|
style={{
|
||||||
|
fontSize: rem(48),
|
||||||
|
fontWeight: 900,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Build Faster with{" "}
|
||||||
|
<Text span c="blue" inherit>
|
||||||
|
Bun Stack
|
||||||
|
</Text>
|
||||||
|
</Title>
|
||||||
|
<Text size="xl" c="dimmed">
|
||||||
|
A modern, full-stack React template powered by Bun,
|
||||||
|
Elysia.js, and TanStack Router. Ship your ideas faster than
|
||||||
|
ever.
|
||||||
|
</Text>
|
||||||
|
<Group gap="md">
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/dashboard"
|
||||||
|
size="lg"
|
||||||
|
variant="filled"
|
||||||
|
rightSection={<IconRocket size="1.25rem" />}
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/docs"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Grid.Col>
|
||||||
|
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||||
|
<Transition
|
||||||
|
mounted={imageLoaded}
|
||||||
|
transition="slide-left"
|
||||||
|
duration={800}
|
||||||
|
timingFunction="ease"
|
||||||
|
>
|
||||||
|
{(styles) => (
|
||||||
|
<Paper shadow="xl" radius="lg" p="md" withBorder style={styles}>
|
||||||
|
<Image
|
||||||
|
src="https://images.unsplash.com/photo-1555066931-4365d14bab8c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80"
|
||||||
|
alt="Code editor showing Bun Stack code"
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedFeatureCard({
|
||||||
|
feature,
|
||||||
|
index,
|
||||||
|
isVisible,
|
||||||
|
}: {
|
||||||
|
feature: (typeof FEATURES)[number];
|
||||||
|
index: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
}) {
|
||||||
|
const [isDelayedVisible, setIsDelayedVisible] = useState(isVisible);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsDelayedVisible(true);
|
||||||
|
}, index * 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isVisible, index]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
mounted={isDelayedVisible}
|
||||||
|
transition="slide-up"
|
||||||
|
duration={500}
|
||||||
|
timingFunction="ease"
|
||||||
|
>
|
||||||
|
{(styles) => (
|
||||||
|
<Card
|
||||||
|
className="feature-card"
|
||||||
|
padding="lg"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
style={styles}
|
||||||
|
>
|
||||||
|
<ThemeIcon variant="light" color="blue" size={60} radius="md">
|
||||||
|
<feature.icon size="1.75rem" />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Stack gap={8} mt="md">
|
||||||
|
<Title order={4}>{feature.title}</Title>
|
||||||
|
<Text size="sm" c="dimmed" lh={1.5}>
|
||||||
|
{feature.description}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeaturesSection() {
|
||||||
|
const [visibleFeatures, setVisibleFeatures] = useState(
|
||||||
|
Array(FEATURES.length).fill(false),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisibleFeatures((prev) => {
|
||||||
|
const newVisible = [...prev];
|
||||||
|
newVisible[index] = true;
|
||||||
|
return newVisible;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const elements = document.querySelectorAll(".feature-card");
|
||||||
|
elements.forEach((el) => {
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="lg" py={rem(80)}>
|
||||||
|
<Stack gap="xl" align="center" mb={rem(50)}>
|
||||||
|
<Transition
|
||||||
|
mounted={true}
|
||||||
|
transition="fade"
|
||||||
|
duration={600}
|
||||||
|
timingFunction="ease"
|
||||||
|
>
|
||||||
|
{(styles) => (
|
||||||
|
<div style={styles}>
|
||||||
|
<Title order={2} ta="center">
|
||||||
|
Everything You Need
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="lg" ta="center" maw={600}>
|
||||||
|
A complete toolkit for building modern web applications with
|
||||||
|
best practices built-in.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Stack>
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
{FEATURES.map((feature, index) => (
|
||||||
|
<AnimatedFeatureCard
|
||||||
|
key={feature.title}
|
||||||
|
feature={feature}
|
||||||
|
index={index}
|
||||||
|
isVisible={visibleFeatures[index]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AnimatedTestimonialCard({
|
||||||
|
testimonial,
|
||||||
|
index,
|
||||||
|
isVisible,
|
||||||
|
}: {
|
||||||
|
testimonial: (typeof TESTIMONIALS)[number];
|
||||||
|
index: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
}) {
|
||||||
|
const [isDelayedVisible, setIsDelayedVisible] = useState(isVisible);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsDelayedVisible(true);
|
||||||
|
}, index * 150);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isVisible, index]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
mounted={isDelayedVisible}
|
||||||
|
transition="slide-up"
|
||||||
|
duration={500}
|
||||||
|
timingFunction="ease"
|
||||||
|
>
|
||||||
|
{(styles) => (
|
||||||
|
<Card
|
||||||
|
padding="lg"
|
||||||
|
radius="md"
|
||||||
|
withBorder
|
||||||
|
shadow="sm"
|
||||||
|
className="testimonial-card"
|
||||||
|
style={styles}
|
||||||
|
>
|
||||||
|
<Text c="dimmed" mb="md">
|
||||||
|
"{testimonial.content}"
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Avatar src={testimonial.avatar} size="md" radius="xl" />
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text fw={600}>{testimonial.name}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{testimonial.role}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestimonialsSection() {
|
||||||
|
const [visibleTestimonials, setVisibleTestimonials] = useState(
|
||||||
|
Array(TESTIMONIALS.length).fill(false),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setVisibleTestimonials((prev) => {
|
||||||
|
const newVisible = [...prev];
|
||||||
|
newVisible[index] = true;
|
||||||
|
return newVisible;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const elements = document.querySelectorAll(".testimonial-card");
|
||||||
|
elements.forEach((el) => {
|
||||||
|
observer.observe(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box py={rem(80)}>
|
||||||
|
<Container size="lg">
|
||||||
|
<Stack gap="xl" align="center" mb={rem(50)}>
|
||||||
|
<Transition
|
||||||
|
mounted={true}
|
||||||
|
transition="fade"
|
||||||
|
duration={600}
|
||||||
|
timingFunction="ease"
|
||||||
|
>
|
||||||
|
{(styles) => (
|
||||||
|
<div style={styles}>
|
||||||
|
<Title order={2} ta="center">
|
||||||
|
Loved by Developers
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="lg" ta="center" maw={600}>
|
||||||
|
Join thousands of satisfied developers who have accelerated
|
||||||
|
their projects with Bun Stack.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="lg">
|
||||||
|
{TESTIMONIALS.map((testimonial, index) => (
|
||||||
|
<AnimatedTestimonialCard
|
||||||
|
key={testimonial.id}
|
||||||
|
testimonial={testimonial}
|
||||||
|
index={index}
|
||||||
|
isVisible={visibleTestimonials[index]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CtaSection() {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoaded(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="lg" py={rem(80)}>
|
||||||
|
<Transition
|
||||||
|
mounted={loaded}
|
||||||
|
transition="slide-up"
|
||||||
|
duration={600}
|
||||||
|
timingFunction="ease"
|
||||||
|
>
|
||||||
|
{(styles) => (
|
||||||
|
<Paper
|
||||||
|
radius="lg"
|
||||||
|
p={rem(60)}
|
||||||
|
bg="blue"
|
||||||
|
style={{
|
||||||
|
...styles,
|
||||||
|
background:
|
||||||
|
"linear-gradient(135deg, var(--mantine-color-blue-6), var(--mantine-color-indigo-6))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack align="center" gap="xl" ta="center">
|
||||||
|
<Title c="white" order={2}>
|
||||||
|
Ready to get started?
|
||||||
|
</Title>
|
||||||
|
<Text c="white" size="lg" maw={600}>
|
||||||
|
Join thousands of developers who are building faster and more
|
||||||
|
reliable applications with Bun Stack.
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/signup"
|
||||||
|
size="lg"
|
||||||
|
variant="white"
|
||||||
|
color="dark"
|
||||||
|
rightSection={<IconChevronRight size="1.125rem" />}
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/docs"
|
||||||
|
size="lg"
|
||||||
|
variant="outline"
|
||||||
|
color="white"
|
||||||
|
>
|
||||||
|
View Documentation
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Footer() {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setLoaded(true);
|
||||||
|
}, 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition
|
||||||
|
mounted={loaded}
|
||||||
|
transition="slide-up"
|
||||||
|
duration={600}
|
||||||
|
timingFunction="ease"
|
||||||
|
>
|
||||||
|
{(styles) => (
|
||||||
|
<Box
|
||||||
|
py={rem(40)}
|
||||||
|
style={{
|
||||||
|
...styles,
|
||||||
|
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Container size="lg">
|
||||||
|
<Grid gutter={{ base: rem(40), md: rem(80) }}>
|
||||||
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Title order={3}>BunStack</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
The ultimate full-stack solution for modern web
|
||||||
|
applications.
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<ActionIcon size="lg" variant="subtle" color="gray">
|
||||||
|
<IconBrandGithub size="1.25rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon size="lg" variant="subtle" color="gray">
|
||||||
|
<IconBrandTwitter size="1.25rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon size="lg" variant="subtle" color="gray">
|
||||||
|
<IconBrandLinkedin size="1.25rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, md: 2 }}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={4}>Product</Title>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component={Link}
|
||||||
|
to="/features"
|
||||||
|
td="none"
|
||||||
|
>
|
||||||
|
Features
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component={Link}
|
||||||
|
to="/pricing"
|
||||||
|
td="none"
|
||||||
|
>
|
||||||
|
Pricing
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component={Link}
|
||||||
|
to="/docs"
|
||||||
|
td="none"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, md: 2 }}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={4}>Company</Title>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component={Link}
|
||||||
|
to="/about"
|
||||||
|
td="none"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component={Link}
|
||||||
|
to="/blog"
|
||||||
|
td="none"
|
||||||
|
>
|
||||||
|
Blog
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
component={Link}
|
||||||
|
to="/careers"
|
||||||
|
td="none"
|
||||||
|
>
|
||||||
|
Careers
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
|
||||||
|
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Title order={4}>Subscribe to our newsletter</Title>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Get the latest news and updates
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Your email"
|
||||||
|
style={{
|
||||||
|
padding: "8px 12px",
|
||||||
|
borderRadius: "4px",
|
||||||
|
border: "1px solid var(--mantine-color-gray-3)",
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button>Subscribe</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
pt={rem(40)}
|
||||||
|
style={{ borderTop: "1px solid var(--mantine-color-gray-2)" }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
© 2024 Bun Stack. Built with Bun, Elysia, and React.
|
||||||
|
</Text>
|
||||||
|
<Group gap="lg">
|
||||||
|
<Text
|
||||||
|
component={Link}
|
||||||
|
to="/privacy"
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
component={Link}
|
||||||
|
to="/terms"
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
style={{ textDecoration: "none" }}
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomePage() {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<NavigationBar />
|
||||||
|
<HeroSection />
|
||||||
|
<FeaturesSection />
|
||||||
|
<TestimonialsSection />
|
||||||
|
<CtaSection />
|
||||||
|
<Footer />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
317
src/routes/profile.tsx
Normal file
317
src/routes/profile.tsx
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Code,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
IconAt,
|
||||||
|
IconCheck,
|
||||||
|
IconCopy,
|
||||||
|
IconDashboard,
|
||||||
|
IconId,
|
||||||
|
IconLogout,
|
||||||
|
IconShield,
|
||||||
|
IconUser,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useSnapshot } from "valtio";
|
||||||
|
import { authClient } from "@/utils/auth-client";
|
||||||
|
import { authStore } from "../store/auth";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/profile")({
|
||||||
|
component: Profile,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Profile() {
|
||||||
|
const snap = useSnapshot(authStore);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await authClient.signOut();
|
||||||
|
navigate({ to: "/signin" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size="lg" py="xl">
|
||||||
|
<Title order={1} mb="lg" ta="center">
|
||||||
|
User Profile
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
{/* Profile Header Card */}
|
||||||
|
<Card
|
||||||
|
withBorder
|
||||||
|
p="xl"
|
||||||
|
radius="md"
|
||||||
|
mb="xl"
|
||||||
|
bg="rgba(251, 240, 223, 0.05)"
|
||||||
|
style={{ border: "1px solid rgba(251, 240, 223, 0.1)" }}
|
||||||
|
>
|
||||||
|
<Group justify="center" align="flex-start" gap="xl">
|
||||||
|
<Avatar
|
||||||
|
src={snap.user?.image}
|
||||||
|
size={120}
|
||||||
|
radius="xl"
|
||||||
|
style={{ border: "2px solid rgba(251, 240, 223, 0.3)" }}
|
||||||
|
>
|
||||||
|
{snap.user?.name?.charAt(0).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Stack gap="xs" justify="center">
|
||||||
|
<Text size="xl" fw={700} c="#fbf0df">
|
||||||
|
{snap.user?.name}
|
||||||
|
</Text>
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconAt size={16} stroke={1.5} color="rgba(255, 255, 255, 0.6)" />
|
||||||
|
<Text c="dimmed">{snap.user?.email}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group gap="sm">
|
||||||
|
<IconShield
|
||||||
|
size={16}
|
||||||
|
stroke={1.5}
|
||||||
|
color="rgba(255, 255, 255, 0.6)"
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="light"
|
||||||
|
color={snap.user?.role === "admin" ? "green" : "blue"}
|
||||||
|
>
|
||||||
|
{snap.user?.role || "user"}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Title order={2} mb="md" ta="center">
|
||||||
|
Account Information
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing="md" mb="xl">
|
||||||
|
<Card withBorder p="lg" radius="md" bg="rgba(251, 240, 223, 0.05)">
|
||||||
|
<Group>
|
||||||
|
<IconId size={24} stroke={1.5} color="#f3d5a3" />
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
User ID
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" mt="xs">
|
||||||
|
<Text fw={500} truncate="end" miw={0} c="#fbf0df">
|
||||||
|
{snap.user?.id || "N/A"}
|
||||||
|
</Text>
|
||||||
|
<Tooltip
|
||||||
|
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<Title order={3}>Session Information</Title>
|
||||||
|
<Group>
|
||||||
|
{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 mt="md" justify="space-between">
|
||||||
|
<div>
|
||||||
|
<Text size="sm" c="dimmed" mb="xs">
|
||||||
|
Session Token
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Code
|
||||||
|
block
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
backgroundColor: "rgba(26, 26, 26, 0.7)",
|
||||||
|
color: "#f3d5a3",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{snap.session?.token
|
||||||
|
? `${snap.session.token.substring(0, 30)}...`
|
||||||
|
: "N/A"}
|
||||||
|
</Code>
|
||||||
|
<Tooltip
|
||||||
|
label={copied ? "Copied!" : "Copy to clipboard"}
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
color="gray"
|
||||||
|
size="md"
|
||||||
|
onClick={() =>
|
||||||
|
snap.session?.token && copyToClipboard(snap.session.token)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={() => setOpened(false)}
|
||||||
|
title="Confirm Sign Out"
|
||||||
|
centered
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Text mb="md">
|
||||||
|
Are you sure you want to sign out? You will need to sign in again to
|
||||||
|
access your account.
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={() => setOpened(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftSection={<IconLogout size={16} />}
|
||||||
|
color="red"
|
||||||
|
onClick={async () => {
|
||||||
|
await logout();
|
||||||
|
setOpened(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/routes/signin.tsx
Normal file
125
src/routes/signin.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
PasswordInput,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconBrandGithub } from "@tabler/icons-react";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { authClient } from "../utils/auth-client";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/signin")({
|
||||||
|
component: SigninComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SigninComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await authClient.signIn.email(
|
||||||
|
{
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onRequest: () => {
|
||||||
|
console.log("Sign in request started");
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
console.log("Sign in successful, navigating to dashboard");
|
||||||
|
navigate({ to: "/profile", replace: true });
|
||||||
|
},
|
||||||
|
onError: (ctx) => {
|
||||||
|
setError(ctx.error.message || "Failed to sign in");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// If using callbacks, result will be undefined
|
||||||
|
if (result?.error) {
|
||||||
|
setError(result.error.message || "Failed to sign in");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("An unexpected error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size={420} my={40}>
|
||||||
|
<Title ta="center" c="dimmed">
|
||||||
|
Welcome back!
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||||
|
Do not have an account yet?{" "}
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
component="button"
|
||||||
|
onClick={() => navigate({ to: "/signup" })}
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
placeholder="Your password"
|
||||||
|
required
|
||||||
|
mt="md"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Checkbox label="Remember me" mt="md" />
|
||||||
|
{error && (
|
||||||
|
<Text c="red" size="sm" mt="md">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button fullWidth mt="xl" type="submit" loading={loading}>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
fullWidth
|
||||||
|
mt="md"
|
||||||
|
leftSection={<IconBrandGithub size={18} />}
|
||||||
|
onClick={async () => {
|
||||||
|
await authClient.signIn.social({
|
||||||
|
provider: "github",
|
||||||
|
callbackURL: "/profile",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Continue with GitHub
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/routes/signup.tsx
Normal file
102
src/routes/signup.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
PasswordInput,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signUp } from "../utils/auth-client";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/signup")({
|
||||||
|
component: SignupComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
function SignupComponent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await signUp.email({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message || "Failed to sign up");
|
||||||
|
} else {
|
||||||
|
navigate({ to: "/dashboard" });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("An unexpected error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container size={420} my={40}>
|
||||||
|
<Title ta="center">Create an account</Title>
|
||||||
|
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Anchor
|
||||||
|
size="sm"
|
||||||
|
component="button"
|
||||||
|
onClick={() => navigate({ to: "/signin" })}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextInput
|
||||||
|
label="Name"
|
||||||
|
placeholder="Your name"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
required
|
||||||
|
mt="md"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
placeholder="Your password"
|
||||||
|
required
|
||||||
|
mt="md"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<Text c="red" size="sm" mt="md">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button fullWidth mt="xl" type="submit" loading={loading}>
|
||||||
|
Create account
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/routes/users/$id.tsx
Normal file
82
src/routes/users/$id.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { createFileRoute, Link, useParams } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/users/$id")({
|
||||||
|
component: UserDetailPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserDetailPage() {
|
||||||
|
const { id } = useParams({ from: "/users/$id" }) as { id: string };
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Simulate fetching user by ID
|
||||||
|
fetch("/api/users")
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const foundUser = data.users.find((u: User) => u.id === Number(id));
|
||||||
|
setUser(foundUser || null);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<h1>User Details</h1>
|
||||||
|
<p className="loading">Loading user...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<h1>User Not Found</h1>
|
||||||
|
<p>User with ID {id} does not exist.</p>
|
||||||
|
<Link to="/users" className="back-link">
|
||||||
|
← Back to Users
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<Link to="/users" className="back-link">
|
||||||
|
← Back to Users
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="user-detail-card">
|
||||||
|
<div className="user-detail-avatar">{user.name.charAt(0)}</div>
|
||||||
|
<h1>{user.name}</h1>
|
||||||
|
<p className="user-email">{user.email}</p>
|
||||||
|
<div className="user-meta">
|
||||||
|
<span className="meta-item">
|
||||||
|
<strong>ID:</strong> {user.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="route-info-box">
|
||||||
|
<strong>🛣️ Dynamic Route:</strong> This page uses the route pattern{" "}
|
||||||
|
<code>/users/$id</code>
|
||||||
|
<br />
|
||||||
|
Current parameter: <code>id = {id}</code>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
Try changing the ID in the URL to see different users!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/routes/users/index.tsx
Normal file
51
src/routes/users/index.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/users/")({
|
||||||
|
component: UsersPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersPage() {
|
||||||
|
const [users, _] = useState<User[]>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container">
|
||||||
|
<h1>Users</h1>
|
||||||
|
<p className="page-description">
|
||||||
|
This page demonstrates fetching data from the API and using dynamic
|
||||||
|
routes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="users-grid">
|
||||||
|
{users.map((user) => (
|
||||||
|
<Link
|
||||||
|
key={user.id}
|
||||||
|
to="/users/$id"
|
||||||
|
params={{ id: String(user.id) }}
|
||||||
|
className="user-card"
|
||||||
|
>
|
||||||
|
<div className="user-avatar">{user.name.charAt(0)}</div>
|
||||||
|
<div className="user-info">
|
||||||
|
<h3>{user.name}</h3>
|
||||||
|
<p>{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="user-arrow">→</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="route-info-box">
|
||||||
|
<strong>💡 Tip:</strong> Click on a user to see dynamic routing in
|
||||||
|
action!
|
||||||
|
<br />
|
||||||
|
Route pattern: <code>/users/$id</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/store/auth.ts
Normal file
12
src/store/auth.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { proxy } from "valtio";
|
||||||
|
import type { authClient } from "../utils/auth-client";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: (typeof authClient.$Infer.Session.user & { role: string }) | null;
|
||||||
|
session: typeof authClient.$Infer.Session.session | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authStore = proxy<AuthState>({
|
||||||
|
user: null,
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
10
src/utils/api-client.ts
Normal file
10
src/utils/api-client.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { edenTreaty } from "@elysiajs/eden";
|
||||||
|
import type { ApiApp } from "../index";
|
||||||
|
|
||||||
|
const baseUrl =
|
||||||
|
import.meta.env.VITE_PUBLIC_URL ||
|
||||||
|
(typeof window !== "undefined"
|
||||||
|
? window.location.origin
|
||||||
|
: "http://localhost:3000");
|
||||||
|
|
||||||
|
export const apiClient = edenTreaty<ApiApp>(baseUrl);
|
||||||
7
src/utils/auth-client.ts
Normal file
7
src/utils/auth-client.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
baseURL: import.meta.env.VITE_PUBLIC_URL || "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { useSession, signIn, signOut, signUp, getSession } = authClient;
|
||||||
52
src/utils/auth.ts
Normal file
52
src/utils/auth.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||||
|
import { PrismaClient } from "../../generated/prisma";
|
||||||
|
import logger from "./logger";
|
||||||
|
|
||||||
|
const baseUrl = process.env.VITE_PUBLIC_URL;
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
logger.error("VITE_PUBLIC_URL is not defined");
|
||||||
|
throw new Error("VITE_PUBLIC_URL is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
// logger.info('Initializing Better Auth with Prisma adapter');
|
||||||
|
export const auth = betterAuth({
|
||||||
|
baseURL: baseUrl,
|
||||||
|
basePath: "/api/auth",
|
||||||
|
database: prismaAdapter(prisma, {
|
||||||
|
provider: "postgresql",
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
socialProviders: {
|
||||||
|
github: {
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID || "CLIENT_ID_MISSING",
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET || "CLIENT_SECRET_MISSING",
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
role: {
|
||||||
|
type: "string",
|
||||||
|
required: false,
|
||||||
|
defaultValue: "user",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
secret: process.env.BETTER_AUTH_SECRET,
|
||||||
|
trustedOrigins: ["http://localhost:5173", "http://localhost:3000"],
|
||||||
|
session: {
|
||||||
|
cookieCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
},
|
||||||
|
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
},
|
||||||
|
advanced: {
|
||||||
|
cookiePrefix: "bun-react",
|
||||||
|
},
|
||||||
|
});
|
||||||
13
src/utils/db.ts
Normal file
13
src/utils/db.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { PrismaClient } from "generated/prisma";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
// logger.info('Prisma client initialized');
|
||||||
18
src/utils/logger.ts
Normal file
18
src/utils/logger.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import pino from "pino";
|
||||||
|
|
||||||
|
const logger = pino({
|
||||||
|
level: process.env.LOG_LEVEL || "info",
|
||||||
|
transport:
|
||||||
|
process.env.NODE_ENV !== "production"
|
||||||
|
? {
|
||||||
|
target: "pino-pretty",
|
||||||
|
options: {
|
||||||
|
colorize: true,
|
||||||
|
translateTime: "SYS:standard",
|
||||||
|
ignore: "pid,hostname",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default logger;
|
||||||
114
src/utils/open-in-editor.ts
Normal file
114
src/utils/open-in-editor.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
// open-in-editor.ts
|
||||||
|
// DEV utility: open source file in local editor
|
||||||
|
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
/* -------------------------------------------------------
|
||||||
|
* Types
|
||||||
|
* ----------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface EditorOptions {
|
||||||
|
line?: number;
|
||||||
|
column?: number;
|
||||||
|
editor?: "vscode" | "cursor" | "windsurf" | "antigravity" | "subl";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------
|
||||||
|
* Editor commands
|
||||||
|
* ----------------------------------------------------- */
|
||||||
|
|
||||||
|
const EDITORS = {
|
||||||
|
vscode: "code",
|
||||||
|
cursor: "cursor",
|
||||||
|
windsurf: "windsurf",
|
||||||
|
antigravity: "antigravity",
|
||||||
|
subl: "subl",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const buildCommand = (
|
||||||
|
editor: keyof typeof EDITORS,
|
||||||
|
file: string,
|
||||||
|
line = 1,
|
||||||
|
column = 1,
|
||||||
|
): [string, ...string[]] => {
|
||||||
|
const cmd = EDITORS[editor];
|
||||||
|
const location = `${file}:${line}:${column}`;
|
||||||
|
|
||||||
|
return editor === "subl" ? [cmd, location] : [cmd, "--goto", location];
|
||||||
|
};
|
||||||
|
|
||||||
|
/* -------------------------------------------------------
|
||||||
|
* Main function
|
||||||
|
* ----------------------------------------------------- */
|
||||||
|
|
||||||
|
export function openInEditor(
|
||||||
|
filePath: string,
|
||||||
|
options: EditorOptions = {},
|
||||||
|
): void {
|
||||||
|
// Resolve path
|
||||||
|
const absolutePath = path.isAbsolute(filePath)
|
||||||
|
? filePath
|
||||||
|
: path.join(process.cwd(), filePath);
|
||||||
|
|
||||||
|
if (!fs.existsSync(absolutePath)) {
|
||||||
|
console.error("[openInEditor] File not found:", absolutePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { line, column, editor } = options;
|
||||||
|
|
||||||
|
// Launch helper
|
||||||
|
const launch = (editorKey: keyof typeof EDITORS) => {
|
||||||
|
const [cmd, ...args] = buildCommand(editorKey, absolutePath, line, column);
|
||||||
|
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Explicit editor
|
||||||
|
if (editor) {
|
||||||
|
launch(editor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ENV detection
|
||||||
|
const envEditor = (
|
||||||
|
process.env.VISUAL ||
|
||||||
|
process.env.EDITOR ||
|
||||||
|
""
|
||||||
|
).toLowerCase();
|
||||||
|
const detectedEditor = Object.keys(EDITORS).find((key) =>
|
||||||
|
envEditor.includes(key),
|
||||||
|
) as keyof typeof EDITORS | undefined;
|
||||||
|
|
||||||
|
if (detectedEditor) {
|
||||||
|
launch(detectedEditor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback priority
|
||||||
|
const fallbackOrder: (keyof typeof EDITORS)[] = [
|
||||||
|
"cursor",
|
||||||
|
"windsurf",
|
||||||
|
"vscode",
|
||||||
|
"antigravity",
|
||||||
|
"subl",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const editorKey of fallbackOrder) {
|
||||||
|
try {
|
||||||
|
launch(editorKey);
|
||||||
|
return;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("[openInEditor] No supported editor detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------
|
||||||
|
* Usage
|
||||||
|
* ----------------------------------------------------- */
|
||||||
|
/*
|
||||||
|
openInEditor("src/pages/dashboard/index.tsx", { line: 31, column: 5 })
|
||||||
|
openInEditor("src/app.tsx", { editor: "cursor" })
|
||||||
|
*/
|
||||||
40
src/vite.ts
Normal file
40
src/vite.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { inspectorServer } from "@react-dev-inspector/vite-plugin";
|
||||||
|
import { tanstackRouter } from "@tanstack/router-vite-plugin";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { createServer as createViteServer } from "vite";
|
||||||
|
|
||||||
|
export async function createVite() {
|
||||||
|
return createViteServer({
|
||||||
|
root: process.cwd(),
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(process.cwd(), "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react({
|
||||||
|
babel: {
|
||||||
|
plugins: [
|
||||||
|
[
|
||||||
|
"@react-dev-inspector/babel-plugin",
|
||||||
|
{
|
||||||
|
relativePath: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
inspectorServer(),
|
||||||
|
tanstackRouter(),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
middlewareMode: true,
|
||||||
|
hmr: true,
|
||||||
|
},
|
||||||
|
appType: "custom",
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ["react", "react-dom", "@mantine/core"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
37
tsconfig.json
Normal file
37
tsconfig.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user