docs(skills): 添加 Better Auth 最佳实践与安全配置指南
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
---
|
||||||
|
name: better-auth-best-practices
|
||||||
|
description: Configure Better Auth server and client, set up database adapters, manage sessions, add plugins, and handle environment variables. Use when users mention Better Auth, betterauth, auth.ts, or need to set up TypeScript authentication with email/password, OAuth, or plugin configuration.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Better Auth Integration Guide
|
||||||
|
|
||||||
|
**Always consult [better-auth.com/docs](https://better-auth.com/docs) for code examples and latest API.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Setup Workflow
|
||||||
|
|
||||||
|
1. Install: `npm install better-auth`
|
||||||
|
2. Set env vars: `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL`
|
||||||
|
3. Create `auth.ts` with database + config
|
||||||
|
4. Create route handler for your framework
|
||||||
|
5. Run migrations:
|
||||||
|
- **Built-in adapter:** `npx @better-auth/cli@latest migrate`
|
||||||
|
- **Drizzle:** `npx @better-auth/cli@latest generate --output src/db/auth-schema.ts` then `npx drizzle-kit push` (dev) or `npx drizzle-kit generate && npx drizzle-kit migrate` (prod)
|
||||||
|
- **Prisma:** `npx @better-auth/cli@latest generate --output prisma/schema.prisma` then `npx prisma migrate dev`
|
||||||
|
6. Verify: call `GET /api/auth/ok` — should return `{ status: "ok" }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32`
|
||||||
|
- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`)
|
||||||
|
|
||||||
|
Only define `baseURL`/`secret` in config if env vars are NOT set.
|
||||||
|
|
||||||
|
### File Location
|
||||||
|
CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path.
|
||||||
|
|
||||||
|
### CLI Commands
|
||||||
|
- `npx @better-auth/cli@latest migrate` - Apply schema (built-in adapter)
|
||||||
|
- `npx @better-auth/cli@latest generate` - Generate schema for Prisma/Drizzle
|
||||||
|
- `npx @better-auth/cli mcp --cursor` - Add MCP to AI tools
|
||||||
|
|
||||||
|
**Re-run after adding/changing plugins.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Config Options
|
||||||
|
|
||||||
|
| Option | Notes |
|
||||||
|
|--------|-------|
|
||||||
|
| `appName` | Optional display name |
|
||||||
|
| `baseURL` | Only if `BETTER_AUTH_URL` not set |
|
||||||
|
| `basePath` | Default `/api/auth`. Set `/` for root. |
|
||||||
|
| `secret` | Only if `BETTER_AUTH_SECRET` not set |
|
||||||
|
| `database` | Required for most features. See adapters docs. |
|
||||||
|
| `secondaryStorage` | Redis/KV for sessions & rate limits |
|
||||||
|
| `emailAndPassword` | `{ enabled: true }` to activate |
|
||||||
|
| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` |
|
||||||
|
| `plugins` | Array of plugins |
|
||||||
|
| `trustedOrigins` | CSRF whitelist |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance. For Postgres, also supports `postgres` (postgres.js) and `@neondatabase/serverless`.
|
||||||
|
|
||||||
|
**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`.
|
||||||
|
|
||||||
|
**Drizzle provider values:** `"pg"` (PostgreSQL), `"mysql"` (MySQL), `"sqlite"` (SQLite). Must match the driver used.
|
||||||
|
|
||||||
|
**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session Management
|
||||||
|
|
||||||
|
**Storage priority:**
|
||||||
|
1. If `secondaryStorage` defined → sessions go there (not DB)
|
||||||
|
2. Set `session.storeSessionInDatabase: true` to also persist to DB
|
||||||
|
3. No database + `cookieCache` → fully stateless mode
|
||||||
|
|
||||||
|
**Cookie cache strategies:**
|
||||||
|
- `compact` (default) - Base64url + HMAC. Smallest.
|
||||||
|
- `jwt` - Standard JWT. Readable but signed.
|
||||||
|
- `jwe` - Encrypted. Maximum security.
|
||||||
|
|
||||||
|
**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User & Account Config
|
||||||
|
|
||||||
|
**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default).
|
||||||
|
|
||||||
|
**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth).
|
||||||
|
|
||||||
|
**Required for registration:** `email` and `name` fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Flows
|
||||||
|
|
||||||
|
- `emailVerification.sendVerificationEmail` - Must be defined for verification to work
|
||||||
|
- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers
|
||||||
|
- `emailAndPassword.sendResetPassword` - Password reset email handler
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
**In `advanced`:**
|
||||||
|
- `useSecureCookies` - Force HTTPS cookies
|
||||||
|
- `disableCSRFCheck` - ⚠️ Security risk
|
||||||
|
- `disableOriginCheck` - ⚠️ Security risk
|
||||||
|
- `crossSubDomainCookies.enabled` - Share cookies across subdomains
|
||||||
|
- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies
|
||||||
|
- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false`
|
||||||
|
|
||||||
|
**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`.
|
||||||
|
|
||||||
|
**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions.
|
||||||
|
|
||||||
|
**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Plugins
|
||||||
|
|
||||||
|
**Import from dedicated paths for tree-shaking:**
|
||||||
|
```
|
||||||
|
import { twoFactor } from "better-auth/plugins/two-factor"
|
||||||
|
```
|
||||||
|
NOT `from "better-auth/plugins"`.
|
||||||
|
|
||||||
|
**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`.
|
||||||
|
|
||||||
|
Client plugins go in `createAuthClient({ plugins: [...] })`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`.
|
||||||
|
|
||||||
|
Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Safety
|
||||||
|
|
||||||
|
Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`.
|
||||||
|
|
||||||
|
For separate client/server projects: `createAuthClient<typeof auth>()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Gotchas
|
||||||
|
|
||||||
|
1. **Model vs table name** - Config uses ORM model name, not DB table name
|
||||||
|
2. **Plugin schema** - Re-run CLI after adding plugins
|
||||||
|
3. **Secondary storage** - Sessions go there by default, not DB
|
||||||
|
4. **Cookie cache** - Custom session fields NOT cached, always re-fetched
|
||||||
|
5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry
|
||||||
|
6. **Change email flow** - Sends to current email first, then new email
|
||||||
|
7. **Drizzle: db not initialized** - `drizzleAdapter(db, ...)` requires a `db` instance from `drizzle()`. See `create-auth` skill for setup examples (node-postgres, postgres.js, Neon).
|
||||||
|
8. **Drizzle: missing drizzle.config.ts** - `drizzle-kit` commands require a `drizzle.config.ts` pointing to the generated schema file and DB credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Docs](https://better-auth.com/docs)
|
||||||
|
- [Options Reference](https://better-auth.com/docs/reference/options)
|
||||||
|
- [LLMs.txt](https://better-auth.com/llms.txt)
|
||||||
|
- [GitHub](https://github.com/better-auth/better-auth)
|
||||||
|
- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts)
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
---
|
||||||
|
name: better-auth-security-best-practices
|
||||||
|
description: Configure rate limiting, manage auth secrets, set up CSRF protection, define trusted origins, secure sessions and cookies, encrypt OAuth tokens, track IP addresses, and implement audit logging for Better Auth. Use when users need to secure their auth setup, prevent brute force attacks, or harden a Better Auth deployment.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secret Management
|
||||||
|
|
||||||
|
### Configuring the Secret
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
secret: process.env.BETTER_AUTH_SECRET, // or via `BETTER_AUTH_SECRET` env
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Better Auth looks for secrets in this order:
|
||||||
|
1. `options.secret` in your config
|
||||||
|
2. `BETTER_AUTH_SECRET` environment variable
|
||||||
|
3. `AUTH_SECRET` environment variable
|
||||||
|
|
||||||
|
### Secret Requirements
|
||||||
|
|
||||||
|
- Rejects default/placeholder secrets in production
|
||||||
|
- Warns if shorter than 32 characters or entropy below 120 bits
|
||||||
|
- Generate: `openssl rand -base64 32`
|
||||||
|
- Never commit secrets to version control
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
Enabled in production by default. Applies to all endpoints. Plugins can override per-endpoint.
|
||||||
|
|
||||||
|
### Default Configuration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true, // Default: true in production
|
||||||
|
window: 10, // Time window in seconds (default: 10)
|
||||||
|
max: 100, // Max requests per window (default: 100)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage Options
|
||||||
|
|
||||||
|
Options: `"memory"` (resets on restart, avoid on serverless), `"database"` (persistent), `"secondary-storage"` (Redis, default when available).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rateLimit: {
|
||||||
|
storage: "database",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Storage
|
||||||
|
|
||||||
|
Implement your own rate limit storage:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rateLimit: {
|
||||||
|
customStorage: {
|
||||||
|
get: async (key) => {
|
||||||
|
// Return { count: number, expiresAt: number } or null
|
||||||
|
},
|
||||||
|
set: async (key, data) => {
|
||||||
|
// Store the rate limit data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Per-Endpoint Rules
|
||||||
|
|
||||||
|
Sensitive endpoints default to 3 requests per 10 seconds (`/sign-in`, `/sign-up`, `/change-password`, `/change-email`). Override:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
rateLimit: {
|
||||||
|
customRules: {
|
||||||
|
"/api/auth/sign-in/email": {
|
||||||
|
window: 60, // 1 minute window
|
||||||
|
max: 5, // 5 attempts
|
||||||
|
},
|
||||||
|
"/api/auth/some-safe-endpoint": false, // Disable rate limiting
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSRF Protection
|
||||||
|
|
||||||
|
Multi-layer protection: origin header validation, Fetch Metadata checks, and first-login protection.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
advanced: {
|
||||||
|
disableCSRFCheck: false, // Default: false (keep enabled)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Only disable for testing or with an alternative CSRF mechanism.
|
||||||
|
|
||||||
|
## Trusted Origins
|
||||||
|
|
||||||
|
### Configuring Trusted Origins
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
trustedOrigins: [
|
||||||
|
"https://app.example.com",
|
||||||
|
"https://admin.example.com",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The `baseURL` origin is automatically trusted. Also configurable via env: `BETTER_AUTH_TRUSTED_ORIGINS=https://app.example.com,https://admin.example.com`
|
||||||
|
|
||||||
|
### Wildcard Patterns
|
||||||
|
|
||||||
|
```ts
|
||||||
|
trustedOrigins: [
|
||||||
|
"*.example.com", // Matches any subdomain
|
||||||
|
"https://*.example.com", // Protocol-specific wildcard
|
||||||
|
"exp://192.168.*.*:*/*", // Custom schemes (e.g., Expo)
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Dynamic Trusted Origins
|
||||||
|
|
||||||
|
Compute trusted origins based on the request:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
trustedOrigins: async (request) => {
|
||||||
|
// Validate against database, header, etc.
|
||||||
|
const tenant = getTenantFromRequest(request);
|
||||||
|
return [`https://${tenant}.myapp.com`];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Validates `callbackURL`, `redirectTo`, `errorCallbackURL`, `newUserCallbackURL`, and `origin` against trusted origins. Invalid URLs receive 403.
|
||||||
|
|
||||||
|
## Session Security
|
||||||
|
|
||||||
|
### Session Expiration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
session: {
|
||||||
|
expiresIn: 60 * 60 * 24 * 7, // 7 days (default)
|
||||||
|
updateAge: 60 * 60 * 24, // Refresh session every 24 hours (default)
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Session Caching Strategies
|
||||||
|
|
||||||
|
Cache session data in cookies to reduce database queries:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
session: {
|
||||||
|
cookieCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxAge: 60 * 5, // 5 minutes
|
||||||
|
strategy: "compact", // Options: "compact", "jwt", "jwe"
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Strategies: `"compact"` (Base64url + HMAC, smallest), `"jwt"` (HS256, standard), `"jwe"` (encrypted, use when session has sensitive data).
|
||||||
|
|
||||||
|
## Cookie Security
|
||||||
|
|
||||||
|
Defaults: `secure: true` (HTTPS/production), `sameSite: "lax"`, `httpOnly: true`, `path: "/"`, prefix `__Secure-`.
|
||||||
|
|
||||||
|
### Custom Cookie Configuration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
advanced: {
|
||||||
|
useSecureCookies: true, // Force secure cookies
|
||||||
|
cookiePrefix: "myapp", // Custom prefix (default: "better-auth")
|
||||||
|
defaultCookieAttributes: {
|
||||||
|
sameSite: "strict", // Stricter CSRF protection
|
||||||
|
path: "/auth", // Limit cookie scope
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Cross-Subdomain Cookies
|
||||||
|
|
||||||
|
```ts
|
||||||
|
advanced: {
|
||||||
|
crossSubDomainCookies: {
|
||||||
|
enabled: true,
|
||||||
|
domain: ".example.com", // Note the leading dot
|
||||||
|
additionalCookies: ["session_token", "session_data"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Only enable if you need authentication sharing and trust all subdomains.
|
||||||
|
|
||||||
|
## OAuth / Social Provider Security
|
||||||
|
|
||||||
|
PKCE is automatic for all OAuth flows. State tokens are 32-char random strings expiring after 10 minutes.
|
||||||
|
|
||||||
|
### State Parameter Storage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
account: {
|
||||||
|
storeStateStrategy: "cookie", // Options: "cookie" (default), "database"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encrypting OAuth Tokens
|
||||||
|
|
||||||
|
```ts
|
||||||
|
account: {
|
||||||
|
encryptOAuthTokens: true, // Uses AES-256-GCM
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable if storing OAuth tokens for API access on behalf of users. Use `skipStateCookieCheck: true` only for mobile apps that cannot maintain cookies.
|
||||||
|
|
||||||
|
## IP-Based Security
|
||||||
|
|
||||||
|
### IP Address Configuration
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
advanced: {
|
||||||
|
ipAddress: {
|
||||||
|
ipAddressHeaders: ["x-forwarded-for", "x-real-ip"], // Headers to check
|
||||||
|
disableIpTracking: false, // Keep enabled for rate limiting
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `ipv6Subnet` (128, 64, 48, 32; default 64) to group IPv6 addresses. Enable `trustedProxyHeaders: true` only if behind a trusted reverse proxy.
|
||||||
|
|
||||||
|
## Database Hooks for Security Auditing
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
databaseHooks: {
|
||||||
|
session: {
|
||||||
|
create: {
|
||||||
|
after: async ({ data, ctx }) => {
|
||||||
|
await auditLog("session.created", {
|
||||||
|
userId: data.userId,
|
||||||
|
ip: ctx?.request?.headers.get("x-forwarded-for"),
|
||||||
|
userAgent: ctx?.request?.headers.get("user-agent"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
before: async ({ data }) => {
|
||||||
|
await auditLog("session.revoked", { sessionId: data.id });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
update: {
|
||||||
|
after: async ({ data, oldData }) => {
|
||||||
|
if (oldData?.email !== data.email) {
|
||||||
|
await auditLog("user.email_changed", {
|
||||||
|
userId: data.id,
|
||||||
|
oldEmail: oldData?.email,
|
||||||
|
newEmail: data.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
create: {
|
||||||
|
after: async ({ data }) => {
|
||||||
|
await auditLog("account.linked", {
|
||||||
|
userId: data.userId,
|
||||||
|
provider: data.providerId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Return `false` from a `before` hook to prevent an operation.
|
||||||
|
|
||||||
|
## Background Tasks
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
advanced: {
|
||||||
|
backgroundTasks: {
|
||||||
|
handler: (promise) => {
|
||||||
|
// Platform-specific handler
|
||||||
|
// Vercel: waitUntil(promise)
|
||||||
|
// Cloudflare: ctx.waitUntil(promise)
|
||||||
|
waitUntil(promise);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Ensures operations like sending emails don't affect response timing.
|
||||||
|
|
||||||
|
## Account Enumeration Prevention
|
||||||
|
|
||||||
|
Built-in: consistent response messages, dummy operations on invalid requests, background email sending. Return generic error messages ("Invalid credentials") rather than specific ones ("User not found").
|
||||||
|
|
||||||
|
## Complete Security Configuration Example
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
secret: process.env.BETTER_AUTH_SECRET,
|
||||||
|
baseURL: "https://api.example.com",
|
||||||
|
trustedOrigins: [
|
||||||
|
"https://app.example.com",
|
||||||
|
"https://*.preview.example.com",
|
||||||
|
],
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
storage: "secondary-storage",
|
||||||
|
customRules: {
|
||||||
|
"/api/auth/sign-in/email": { window: 60, max: 5 },
|
||||||
|
"/api/auth/sign-up/email": { window: 60, max: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Session security
|
||||||
|
session: {
|
||||||
|
expiresIn: 60 * 60 * 24 * 7, // 7 days
|
||||||
|
updateAge: 60 * 60 * 24, // 24 hours
|
||||||
|
freshAge: 60 * 60, // 1 hour for sensitive actions
|
||||||
|
cookieCache: {
|
||||||
|
enabled: true,
|
||||||
|
maxAge: 300,
|
||||||
|
strategy: "jwe", // Encrypted session data
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// OAuth security
|
||||||
|
account: {
|
||||||
|
encryptOAuthTokens: true,
|
||||||
|
storeStateStrategy: "cookie",
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
// Advanced settings
|
||||||
|
advanced: {
|
||||||
|
useSecureCookies: true,
|
||||||
|
cookiePrefix: "myapp",
|
||||||
|
defaultCookieAttributes: {
|
||||||
|
sameSite: "lax",
|
||||||
|
},
|
||||||
|
ipAddress: {
|
||||||
|
ipAddressHeaders: ["x-forwarded-for"],
|
||||||
|
ipv6Subnet: 64,
|
||||||
|
},
|
||||||
|
backgroundTasks: {
|
||||||
|
handler: (promise) => waitUntil(promise),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Security auditing
|
||||||
|
databaseHooks: {
|
||||||
|
session: {
|
||||||
|
create: {
|
||||||
|
after: async ({ data, ctx }) => {
|
||||||
|
console.log(`New session for user ${data.userId}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
update: {
|
||||||
|
after: async ({ data, oldData }) => {
|
||||||
|
if (oldData?.email !== data.email) {
|
||||||
|
console.log(`Email changed for user ${data.id}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
Before deploying to production:
|
||||||
|
|
||||||
|
- [ ] **Secret**: Use a strong, unique secret (32+ characters, high entropy)
|
||||||
|
- [ ] **HTTPS**: Ensure `baseURL` uses HTTPS
|
||||||
|
- [ ] **Trusted Origins**: Configure all valid origins (frontend, mobile apps)
|
||||||
|
- [ ] **Rate Limiting**: Keep enabled with appropriate limits
|
||||||
|
- [ ] **CSRF Protection**: Keep enabled (`disableCSRFCheck: false`)
|
||||||
|
- [ ] **Secure Cookies**: Enabled automatically with HTTPS
|
||||||
|
- [ ] **OAuth Tokens**: Consider `encryptOAuthTokens: true` if storing tokens
|
||||||
|
- [ ] **Background Tasks**: Configure for serverless platforms
|
||||||
|
- [ ] **Audit Logging**: Implement via `databaseHooks` or `hooks`
|
||||||
|
- [ ] **IP Tracking**: Configure headers if behind a proxy
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
---
|
||||||
|
name: create-auth
|
||||||
|
description: Scaffold and implement authentication in TypeScript/JavaScript apps using Better Auth. Detect frameworks, configure database adapters, set up route handlers, add OAuth providers, and create auth UI pages. Use when users want to add login, sign-up, or authentication to a new or existing project with Better Auth.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Create Auth Skill
|
||||||
|
|
||||||
|
Guide for adding authentication to TypeScript/JavaScript applications using Better Auth.
|
||||||
|
|
||||||
|
**For code examples and syntax, see [better-auth.com/docs](https://better-auth.com/docs).**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Planning (REQUIRED before implementation)
|
||||||
|
|
||||||
|
Before writing any code, gather requirements by scanning the project and asking the user structured questions. This ensures the implementation matches their needs.
|
||||||
|
|
||||||
|
### Step 1: Scan the project
|
||||||
|
|
||||||
|
Analyze the codebase to auto-detect:
|
||||||
|
- **Framework** — Look for `next.config`, `svelte.config`, `nuxt.config`, `astro.config`, `vite.config`, or Express/Hono entry files.
|
||||||
|
- **Database/ORM** — Look for `prisma/schema.prisma`, `drizzle.config.ts`, `package.json` deps (`pg`, `postgres`, `@neondatabase/serverless`, `mysql2`, `better-sqlite3`, `mongoose`, `mongodb`). If `drizzle.config.ts` exists, read its `dialect` field to determine the DB type (e.g., `"postgresql"` → Drizzle + Postgres). Also check which Drizzle driver is installed (`drizzle-orm/node-postgres` → `pg`, `drizzle-orm/postgres-js` → `postgres`, `drizzle-orm/neon-http` → Neon).
|
||||||
|
- **Existing auth** — Look for existing auth libraries (`next-auth`, `lucia`, `clerk`, `supabase/auth`, `firebase/auth`) in `package.json` or imports.
|
||||||
|
- **Package manager** — Check for `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`, or `package-lock.json`.
|
||||||
|
|
||||||
|
Use what you find to pre-fill defaults and skip questions you can already answer.
|
||||||
|
|
||||||
|
### Step 2: Ask planning questions
|
||||||
|
|
||||||
|
Use the `AskQuestion` tool to ask the user **all applicable questions in a single call**. Skip any question you already have a confident answer for from the scan. Group them under a title like "Auth Setup Planning".
|
||||||
|
|
||||||
|
**Questions to ask:**
|
||||||
|
|
||||||
|
1. **Project type** (skip if detected)
|
||||||
|
- Prompt: "What type of project is this?"
|
||||||
|
- Options: New project from scratch | Adding auth to existing project | Migrating from another auth library
|
||||||
|
|
||||||
|
2. **Framework** (skip if detected)
|
||||||
|
- Prompt: "Which framework are you using?"
|
||||||
|
- Options: Next.js (App Router) | Next.js (Pages Router) | SvelteKit | Nuxt | Astro | Express | Hono | SolidStart | Other
|
||||||
|
|
||||||
|
3. **Database & ORM** (skip if detected)
|
||||||
|
- Prompt: "Which database setup will you use?"
|
||||||
|
- Options: PostgreSQL (Prisma) | PostgreSQL (Drizzle) | PostgreSQL (pg driver) | MySQL (Prisma) | MySQL (Drizzle) | MySQL (mysql2 driver) | SQLite (Prisma) | SQLite (Drizzle) | SQLite (better-sqlite3 driver) | MongoDB (Mongoose) | MongoDB (native driver)
|
||||||
|
|
||||||
|
4. **Authentication methods** (always ask, allow multiple)
|
||||||
|
- Prompt: "Which sign-in methods do you need?"
|
||||||
|
- Options: Email & password | Social OAuth (Google, GitHub, etc.) | Magic link (passwordless email) | Passkey (WebAuthn) | Phone number
|
||||||
|
- `allow_multiple: true`
|
||||||
|
|
||||||
|
5. **Social providers** (only if they selected Social OAuth above — ask in a follow-up call)
|
||||||
|
- Prompt: "Which social providers do you need?"
|
||||||
|
- Options: Google | GitHub | Apple | Microsoft | Discord | Twitter/X
|
||||||
|
- `allow_multiple: true`
|
||||||
|
|
||||||
|
6. **Email verification** (only if Email & password was selected above — ask in a follow-up call)
|
||||||
|
- Prompt: "Do you want to require email verification?"
|
||||||
|
- Options: Yes | No
|
||||||
|
|
||||||
|
7. **Email provider** (only if email verification is Yes, or if Password reset is selected in features — ask in a follow-up call)
|
||||||
|
- Prompt: "How do you want to send emails?"
|
||||||
|
- Options: Resend | Mock it for now (console.log)
|
||||||
|
|
||||||
|
8. **Features & plugins** (always ask, allow multiple)
|
||||||
|
- Prompt: "Which additional features do you need?"
|
||||||
|
- Options: Two-factor authentication (2FA) | Organizations / teams | Admin dashboard | API bearer tokens | Password reset | None of these
|
||||||
|
- `allow_multiple: true`
|
||||||
|
|
||||||
|
9. **Auth pages** (always ask, allow multiple — pre-select based on earlier answers)
|
||||||
|
- Prompt: "Which auth pages do you need?"
|
||||||
|
- Options vary based on previous answers:
|
||||||
|
- Always available: Sign in | Sign up
|
||||||
|
- If Email & password selected: Forgot password | Reset password
|
||||||
|
- If email verification enabled: Email verification
|
||||||
|
- `allow_multiple: true`
|
||||||
|
|
||||||
|
10. **Auth UI style** (always ask)
|
||||||
|
- Prompt: "What style do you want for the auth pages? Pick one or describe your own."
|
||||||
|
- Options: Minimal & clean | Centered card with background | Split layout (form + hero image) | Floating / glassmorphism | Other (I'll describe)
|
||||||
|
|
||||||
|
### Step 3: Summarize the plan
|
||||||
|
|
||||||
|
After collecting answers, present a concise implementation plan as a markdown checklist. Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
## Auth Implementation Plan
|
||||||
|
|
||||||
|
- **Framework:** Next.js (App Router)
|
||||||
|
- **Database:** PostgreSQL via Prisma
|
||||||
|
- **Auth methods:** Email/password, Google OAuth, GitHub OAuth
|
||||||
|
- **Plugins:** 2FA, Organizations, Email verification
|
||||||
|
- **UI:** Custom forms
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
1. Install `better-auth` and `@better-auth/cli`
|
||||||
|
2. Create `lib/auth.ts` with server config
|
||||||
|
3. Create `lib/auth-client.ts` with React client
|
||||||
|
4. Set up route handler at `app/api/auth/[...all]/route.ts`
|
||||||
|
5. Configure Prisma adapter and generate schema
|
||||||
|
6. Add Google & GitHub OAuth providers
|
||||||
|
7. Enable `twoFactor` and `organization` plugins
|
||||||
|
8. Set up email verification handler
|
||||||
|
9. Run migrations
|
||||||
|
10. Create sign-in / sign-up pages
|
||||||
|
```
|
||||||
|
|
||||||
|
Ask the user to confirm the plan before proceeding to Phase 2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Implementation
|
||||||
|
|
||||||
|
Only proceed here after the user confirms the plan from Phase 1.
|
||||||
|
|
||||||
|
Follow the decision tree below, guided by the answers collected above.
|
||||||
|
|
||||||
|
```
|
||||||
|
Is this a new/empty project?
|
||||||
|
├─ YES → New project setup
|
||||||
|
│ 1. Install better-auth (+ scoped packages per plan)
|
||||||
|
│ 2. Create auth.ts with all planned config
|
||||||
|
│ 3. Create auth-client.ts with framework client
|
||||||
|
│ 4. Set up route handler
|
||||||
|
│ 5. Set up environment variables
|
||||||
|
│ 6. Run CLI migrate/generate
|
||||||
|
│ 7. Add plugins from plan
|
||||||
|
│ 8. Create auth UI pages
|
||||||
|
│
|
||||||
|
├─ MIGRATING → Migration from existing auth
|
||||||
|
│ 1. Audit current auth for gaps
|
||||||
|
│ 2. Plan incremental migration
|
||||||
|
│ 3. Install better-auth alongside existing auth
|
||||||
|
│ 4. Migrate routes, then session logic, then UI
|
||||||
|
│ 5. Remove old auth library
|
||||||
|
│ 6. See migration guides in docs
|
||||||
|
│
|
||||||
|
└─ ADDING → Add auth to existing project
|
||||||
|
1. Analyze project structure
|
||||||
|
2. Install better-auth
|
||||||
|
3. Create auth config matching plan
|
||||||
|
4. Add route handler
|
||||||
|
5. Run schema migrations
|
||||||
|
6. Integrate into existing pages
|
||||||
|
7. Add planned plugins and features
|
||||||
|
```
|
||||||
|
|
||||||
|
At the end of implementation, guide users thoroughly on remaining next steps (e.g., setting up OAuth app credentials, deploying env vars, testing flows).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
**Core:** `npm install better-auth`
|
||||||
|
|
||||||
|
**Scoped packages (as needed):**
|
||||||
|
| Package | Use case |
|
||||||
|
|---------|----------|
|
||||||
|
| `@better-auth/passkey` | WebAuthn/Passkey auth |
|
||||||
|
| `@better-auth/sso` | SAML/OIDC enterprise SSO |
|
||||||
|
| `@better-auth/stripe` | Stripe payments |
|
||||||
|
| `@better-auth/scim` | SCIM user provisioning |
|
||||||
|
| `@better-auth/expo` | React Native/Expo |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
```env
|
||||||
|
BETTER_AUTH_SECRET=<32+ chars, generate with: openssl rand -base64 32>
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
DATABASE_URL=<your database connection string>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add OAuth secrets as needed: `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GOOGLE_CLIENT_ID`, etc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server Config (auth.ts)
|
||||||
|
|
||||||
|
**Location:** `lib/auth.ts` or `src/lib/auth.ts`
|
||||||
|
|
||||||
|
**Minimal config needs:**
|
||||||
|
- `database` - Connection or adapter
|
||||||
|
- `emailAndPassword: { enabled: true }` - For email/password auth
|
||||||
|
|
||||||
|
**Standard config adds:**
|
||||||
|
- `socialProviders` - OAuth providers (google, github, etc.)
|
||||||
|
- `emailVerification.sendVerificationEmail` - Email verification handler
|
||||||
|
- `emailAndPassword.sendResetPassword` - Password reset handler
|
||||||
|
|
||||||
|
**Full config adds:**
|
||||||
|
- `plugins` - Array of feature plugins
|
||||||
|
- `session` - Expiry, cookie cache settings
|
||||||
|
- `account.accountLinking` - Multi-provider linking
|
||||||
|
- `rateLimit` - Rate limiting config
|
||||||
|
|
||||||
|
**Export types:** `export type Session = typeof auth.$Infer.Session`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client Config (auth-client.ts)
|
||||||
|
|
||||||
|
**Import by framework:**
|
||||||
|
| Framework | Import |
|
||||||
|
|-----------|--------|
|
||||||
|
| React/Next.js | `better-auth/react` |
|
||||||
|
| Vue | `better-auth/vue` |
|
||||||
|
| Svelte | `better-auth/svelte` |
|
||||||
|
| Solid | `better-auth/solid` |
|
||||||
|
| Vanilla JS | `better-auth/client` |
|
||||||
|
|
||||||
|
**Client plugins** go in `createAuthClient({ plugins: [...] })`.
|
||||||
|
|
||||||
|
**Common exports:** `signIn`, `signUp`, `signOut`, `useSession`, `getSession`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route Handler Setup
|
||||||
|
|
||||||
|
| Framework | File | Handler |
|
||||||
|
|-----------|------|---------|
|
||||||
|
| Next.js App Router | `app/api/auth/[...all]/route.ts` | `toNextJsHandler(auth)` → export `{ GET, POST }` |
|
||||||
|
| Next.js Pages | `pages/api/auth/[...all].ts` | `toNextJsHandler(auth)` → default export |
|
||||||
|
| Express | Any file | `app.all("/api/auth/*", toNodeHandler(auth))` |
|
||||||
|
| SvelteKit | `src/hooks.server.ts` | `svelteKitHandler(auth)` |
|
||||||
|
| SolidStart | Route file | `solidStartHandler(auth)` |
|
||||||
|
| Hono | Route file | `auth.handler(c.req.raw)` |
|
||||||
|
|
||||||
|
**Next.js Server Components:** Add `nextCookies()` plugin to auth config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
| Adapter | Command |
|
||||||
|
|---------|---------|
|
||||||
|
| Built-in Kysely | `npx @better-auth/cli@latest migrate` (applies directly) |
|
||||||
|
| Prisma | `npx @better-auth/cli@latest generate --output prisma/schema.prisma` then `npx prisma migrate dev` |
|
||||||
|
| Drizzle (dev) | `npx @better-auth/cli@latest generate --output src/db/auth-schema.ts` then `npx drizzle-kit push` |
|
||||||
|
| Drizzle (prod) | `npx @better-auth/cli@latest generate --output src/db/auth-schema.ts` then `npx drizzle-kit generate` then `npx drizzle-kit migrate` |
|
||||||
|
|
||||||
|
> **Note:** `drizzle-kit push` skips migration files and is only safe for development. Use `drizzle-kit generate` + `drizzle-kit migrate` in production.
|
||||||
|
|
||||||
|
**Re-run after adding plugins.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database Adapters
|
||||||
|
|
||||||
|
| Database | Setup |
|
||||||
|
|----------|-------|
|
||||||
|
| SQLite | Pass `better-sqlite3` or `bun:sqlite` instance directly |
|
||||||
|
| PostgreSQL | Pass `pg.Pool` instance directly |
|
||||||
|
| MySQL | Pass `mysql2` pool directly |
|
||||||
|
| Prisma | `prismaAdapter(prisma, { provider: "postgresql" })` from `better-auth/adapters/prisma` |
|
||||||
|
| Drizzle (pg) | `drizzleAdapter(db, { provider: "pg" })` from `better-auth/adapters/drizzle` |
|
||||||
|
| Drizzle (mysql) | `drizzleAdapter(db, { provider: "mysql" })` from `better-auth/adapters/drizzle` |
|
||||||
|
| Drizzle (sqlite) | `drizzleAdapter(db, { provider: "sqlite" })` from `better-auth/adapters/drizzle` |
|
||||||
|
| MongoDB | `mongodbAdapter(db)` from `better-auth/adapters/mongodb` |
|
||||||
|
|
||||||
|
### Drizzle + PostgreSQL Setup
|
||||||
|
|
||||||
|
Before using `drizzleAdapter`, initialize the `db` instance:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Option 1: node-postgres (pg)
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres"
|
||||||
|
import { Pool } from "pg"
|
||||||
|
import * as schema from "./auth-schema"
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||||
|
export const db = drizzle(pool, { schema })
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Option 2: postgres.js
|
||||||
|
import { drizzle } from "drizzle-orm/postgres-js"
|
||||||
|
import postgres from "postgres"
|
||||||
|
import * as schema from "./auth-schema"
|
||||||
|
|
||||||
|
const client = postgres(process.env.DATABASE_URL!)
|
||||||
|
export const db = drizzle(client, { schema })
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Option 3: Neon serverless
|
||||||
|
import { drizzle } from "drizzle-orm/neon-http"
|
||||||
|
import { neon } from "@neondatabase/serverless"
|
||||||
|
import * as schema from "./auth-schema"
|
||||||
|
|
||||||
|
const sql = neon(process.env.DATABASE_URL!)
|
||||||
|
export const db = drizzle(sql, { schema })
|
||||||
|
```
|
||||||
|
|
||||||
|
Then pass to Better Auth:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth"
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle"
|
||||||
|
import { db } from "./db"
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: drizzleAdapter(db, { provider: "pg" }),
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Drizzle Config (`drizzle.config.ts`)
|
||||||
|
|
||||||
|
Required for `drizzle-kit` commands to find your schema:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { defineConfig } from "drizzle-kit"
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "./src/db/auth-schema.ts",
|
||||||
|
out: "./drizzle",
|
||||||
|
dialect: "postgresql",
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Plugins
|
||||||
|
|
||||||
|
| Plugin | Server Import | Client Import | Purpose |
|
||||||
|
|--------|---------------|---------------|---------|
|
||||||
|
| `twoFactor` | `better-auth/plugins` | `twoFactorClient` | 2FA with TOTP/OTP |
|
||||||
|
| `organization` | `better-auth/plugins` | `organizationClient` | Teams/orgs |
|
||||||
|
| `admin` | `better-auth/plugins` | `adminClient` | User management |
|
||||||
|
| `bearer` | `better-auth/plugins` | - | API token auth |
|
||||||
|
| `openAPI` | `better-auth/plugins` | - | API docs |
|
||||||
|
| `passkey` | `@better-auth/passkey` | `passkeyClient` | WebAuthn |
|
||||||
|
| `sso` | `@better-auth/sso` | - | Enterprise SSO |
|
||||||
|
|
||||||
|
**Plugin pattern:** Server plugin + client plugin + run migrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auth UI Implementation
|
||||||
|
|
||||||
|
**Sign in flow:**
|
||||||
|
1. `signIn.email({ email, password })` or `signIn.social({ provider, callbackURL })`
|
||||||
|
2. Handle `error` in response
|
||||||
|
3. Redirect on success
|
||||||
|
|
||||||
|
**Session check (client):** `useSession()` hook returns `{ data: session, isPending }`
|
||||||
|
|
||||||
|
**Session check (server):** `auth.api.getSession({ headers: await headers() })`
|
||||||
|
|
||||||
|
**Protected routes:** Check session, redirect to `/sign-in` if null.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Checklist
|
||||||
|
|
||||||
|
- [ ] `BETTER_AUTH_SECRET` set (32+ chars)
|
||||||
|
- [ ] `advanced.useSecureCookies: true` in production
|
||||||
|
- [ ] `trustedOrigins` configured
|
||||||
|
- [ ] Rate limits enabled
|
||||||
|
- [ ] Email verification enabled
|
||||||
|
- [ ] Password reset implemented
|
||||||
|
- [ ] 2FA for sensitive apps
|
||||||
|
- [ ] CSRF protection NOT disabled
|
||||||
|
- [ ] `account.accountLinking` reviewed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Issue | Fix |
|
||||||
|
|-------|-----|
|
||||||
|
| "Secret not set" | Add `BETTER_AUTH_SECRET` env var |
|
||||||
|
| "Invalid Origin" | Add domain to `trustedOrigins` |
|
||||||
|
| Cookies not setting | Check `baseURL` matches domain; enable secure cookies in prod |
|
||||||
|
| OAuth callback errors | Verify redirect URIs in provider dashboard |
|
||||||
|
| Type errors after adding plugin | Re-run CLI generate/migrate |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resources
|
||||||
|
|
||||||
|
- [Docs](https://better-auth.com/docs)
|
||||||
|
- [Examples](https://github.com/better-auth/examples)
|
||||||
|
- [Plugins](https://better-auth.com/docs/concepts/plugins)
|
||||||
|
- [CLI](https://better-auth.com/docs/concepts/cli)
|
||||||
|
- [Migration Guides](https://better-auth.com/docs/guides)
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
name: email-and-password-best-practices
|
||||||
|
description: Configure email verification, implement password reset flows, set password policies, and customise hashing algorithms for Better Auth email/password authentication. Use when users need to set up login, sign-in, sign-up, credential authentication, or password security with Better Auth.
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
1. Enable email/password: `emailAndPassword: { enabled: true }`
|
||||||
|
2. Configure `emailVerification.sendVerificationEmail`
|
||||||
|
3. Add `sendResetPassword` for password reset flows
|
||||||
|
4. Run `npx @better-auth/cli@latest migrate`
|
||||||
|
5. Verify: attempt sign-up and confirm verification email triggers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Verification Setup
|
||||||
|
|
||||||
|
Configure `emailVerification.sendVerificationEmail` to verify user email addresses.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { sendEmail } from "./email"; // your email sending function
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
emailVerification: {
|
||||||
|
sendVerificationEmail: async ({ user, url, token }, request) => {
|
||||||
|
await sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Verify your email address",
|
||||||
|
text: `Click the link to verify your email: ${url}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: The `url` parameter contains the full verification link. The `token` is available if you need to build a custom verification URL.
|
||||||
|
|
||||||
|
### Requiring Email Verification
|
||||||
|
|
||||||
|
For stricter security, enable `emailAndPassword.requireEmailVerification` to block sign-in until the user verifies their email. When enabled, unverified users will receive a new verification email on each sign-in attempt.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const auth = betterAuth({
|
||||||
|
emailAndPassword: {
|
||||||
|
requireEmailVerification: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: This requires `sendVerificationEmail` to be configured and only applies to email/password sign-ins.
|
||||||
|
|
||||||
|
## Client Side Validation
|
||||||
|
|
||||||
|
Implement client-side validation for immediate user feedback and reduced server load.
|
||||||
|
|
||||||
|
## Callback URLs
|
||||||
|
|
||||||
|
Always use absolute URLs (including the origin) for callback URLs in sign-up and sign-in requests. This prevents Better Auth from needing to infer the origin, which can cause issues when your backend and frontend are on different domains.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { data, error } = await authClient.signUp.email({
|
||||||
|
callbackURL: "https://example.com/callback", // absolute URL with origin
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Password Reset Flows
|
||||||
|
|
||||||
|
Provide `sendResetPassword` in the email and password config to enable password resets.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { sendEmail } from "./email"; // your email sending function
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
// Custom email sending function to send reset-password email
|
||||||
|
sendResetPassword: async ({ user, url, token }, request) => {
|
||||||
|
void sendEmail({
|
||||||
|
to: user.email,
|
||||||
|
subject: "Reset your password",
|
||||||
|
text: `Click the link to reset your password: ${url}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// Optional event hook
|
||||||
|
onPasswordReset: async ({ user }, request) => {
|
||||||
|
// your logic here
|
||||||
|
console.log(`Password for user ${user.email} has been reset.`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
Built-in protections: background email sending (timing attack prevention), dummy operations on invalid requests, constant response messages regardless of user existence.
|
||||||
|
|
||||||
|
On serverless platforms, configure a background task handler:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const auth = betterAuth({
|
||||||
|
advanced: {
|
||||||
|
backgroundTasks: {
|
||||||
|
handler: (promise) => {
|
||||||
|
// Use platform-specific methods like waitUntil
|
||||||
|
waitUntil(promise);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Token Security
|
||||||
|
|
||||||
|
Tokens expire after 1 hour by default. Configure with `resetPasswordTokenExpiresIn` (in seconds):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const auth = betterAuth({
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
resetPasswordTokenExpiresIn: 60 * 30, // 30 minutes
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Tokens are single-use — deleted immediately after successful reset.
|
||||||
|
|
||||||
|
#### Session Revocation
|
||||||
|
|
||||||
|
Enable `revokeSessionsOnPasswordReset` to invalidate all existing sessions on password reset:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const auth = betterAuth({
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
revokeSessionsOnPasswordReset: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Password Requirements
|
||||||
|
|
||||||
|
Password length limits (configurable):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const auth = betterAuth({
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
minPasswordLength: 12,
|
||||||
|
maxPasswordLength: 256,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sending the Password Reset
|
||||||
|
|
||||||
|
Call `requestPasswordReset` to send the reset link. Triggers the `sendResetPassword` function from your config.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const data = await auth.api.requestPasswordReset({
|
||||||
|
body: {
|
||||||
|
email: "john.doe@example.com", // required
|
||||||
|
redirectTo: "https://example.com/reset-password",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Or authClient:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { data, error } = await authClient.requestPasswordReset({
|
||||||
|
email: "john.doe@example.com", // required
|
||||||
|
redirectTo: "https://example.com/reset-password",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: While the `email` is required, we also recommend configuring the `redirectTo` for a smoother user experience.
|
||||||
|
|
||||||
|
## Password Hashing
|
||||||
|
|
||||||
|
Default: `scrypt` (Node.js native, no external dependencies).
|
||||||
|
|
||||||
|
### Custom Hashing Algorithm
|
||||||
|
|
||||||
|
To use Argon2id or another algorithm, provide custom `hash` and `verify` functions:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { hash, verify, type Options } from "@node-rs/argon2";
|
||||||
|
|
||||||
|
const argon2Options: Options = {
|
||||||
|
memoryCost: 65536, // 64 MiB
|
||||||
|
timeCost: 3, // 3 iterations
|
||||||
|
parallelism: 4, // 4 parallel lanes
|
||||||
|
outputLen: 32, // 32 byte output
|
||||||
|
algorithm: 2, // Argon2id variant
|
||||||
|
};
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
emailAndPassword: {
|
||||||
|
enabled: true,
|
||||||
|
password: {
|
||||||
|
hash: (password) => hash(password, argon2Options),
|
||||||
|
verify: ({ password, hash: storedHash }) =>
|
||||||
|
verify(storedHash, password, argon2Options),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note**: If you switch hashing algorithms on an existing system, users with passwords hashed using the old algorithm won't be able to sign in. Plan a migration strategy if needed.
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.sortImports": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": true,
|
||||||
|
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "space",
|
||||||
|
"indentWidth": 2
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"suspicious": {
|
||||||
|
"noUnknownAtRules": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"next": "recommended",
|
||||||
|
"react": "recommended"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": "on"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
out: './drizzle',
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"account_id" text NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"id_token" text,
|
||||||
|
"access_token_expires_at" timestamp,
|
||||||
|
"refresh_token_expires_at" timestamp,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"token" text NOT NULL UNIQUE,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"user_id" text NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL UNIQUE,
|
||||||
|
"email_verified" boolean DEFAULT false NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "users_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"age" integer NOT NULL,
|
||||||
|
"email" varchar(255) NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"created_at" timestamp,
|
||||||
|
"updated_at" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE;
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
{
|
||||||
|
"version": "8",
|
||||||
|
"dialect": "postgres",
|
||||||
|
"id": "6fa3021b-437d-4b43-ab3d-4f7704682cd0",
|
||||||
|
"prevIds": [
|
||||||
|
"00000000-0000-0000-0000-000000000000"
|
||||||
|
],
|
||||||
|
"ddl": [
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "account",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "session",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "user",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "users",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "verification",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "account_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "provider_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "access_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "refresh_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "scope",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "password",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "ip_address",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_agent",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "false",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email_verified",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "image",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": {
|
||||||
|
"type": "always",
|
||||||
|
"name": "users_id_seq",
|
||||||
|
"increment": "1",
|
||||||
|
"startWith": "1",
|
||||||
|
"minValue": "1",
|
||||||
|
"maxValue": "2147483647",
|
||||||
|
"cache": 1,
|
||||||
|
"cycle": false
|
||||||
|
},
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "age",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "identifier",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "value",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "public",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"name": "account_user_id_user_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "public",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"name": "session_user_id_user_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "account_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "session_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "user_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "users_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "verification_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "session_token_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session",
|
||||||
|
"entityType": "uniques"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "user_email_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user",
|
||||||
|
"entityType": "uniques"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "users_email_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users",
|
||||||
|
"entityType": "uniques"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"renames": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
ALTER TABLE "account" ALTER COLUMN "access_token_expires_at" SET DATA TYPE timestamp with time zone USING "access_token_expires_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ALTER COLUMN "refresh_token_expires_at" SET DATA TYPE timestamp with time zone USING "refresh_token_expires_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING "created_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING "updated_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ALTER COLUMN "expires_at" SET DATA TYPE timestamp with time zone USING "expires_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING "created_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING "updated_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING "created_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING "updated_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "verification" ALTER COLUMN "expires_at" SET DATA TYPE timestamp with time zone USING "expires_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "verification" ALTER COLUMN "created_at" SET DATA TYPE timestamp with time zone USING "created_at"::timestamp with time zone;--> statement-breakpoint
|
||||||
|
ALTER TABLE "verification" ALTER COLUMN "updated_at" SET DATA TYPE timestamp with time zone USING "updated_at"::timestamp with time zone;
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
{
|
||||||
|
"version": "8",
|
||||||
|
"dialect": "postgres",
|
||||||
|
"id": "56dfe996-2f67-4ad1-adf6-9ebccb899ecc",
|
||||||
|
"prevIds": [
|
||||||
|
"6fa3021b-437d-4b43-ab3d-4f7704682cd0"
|
||||||
|
],
|
||||||
|
"ddl": [
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "account",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "session",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "user",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "users",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "verification",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "account_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "provider_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "access_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "refresh_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "scope",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "password",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "ip_address",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_agent",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "false",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email_verified",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "image",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": {
|
||||||
|
"type": "always",
|
||||||
|
"name": "users_id_seq",
|
||||||
|
"increment": "1",
|
||||||
|
"startWith": "1",
|
||||||
|
"minValue": "1",
|
||||||
|
"maxValue": "2147483647",
|
||||||
|
"cache": 1,
|
||||||
|
"cycle": false
|
||||||
|
},
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "age",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "identifier",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "value",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "public",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"name": "account_user_id_user_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "public",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"name": "session_user_id_user_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "account_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "session_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "user_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "users_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "verification_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "session_token_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session",
|
||||||
|
"entityType": "uniques"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "user_email_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user",
|
||||||
|
"entityType": "uniques"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "users_email_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users",
|
||||||
|
"entityType": "uniques"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"renames": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "session" ADD COLUMN "impersonated_by" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD COLUMN "role" text DEFAULT 'user' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD COLUMN "banned" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD COLUMN "ban_reason" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user" ADD COLUMN "ban_expires" timestamp with time zone;
|
||||||
@@ -0,0 +1,726 @@
|
|||||||
|
{
|
||||||
|
"version": "8",
|
||||||
|
"dialect": "postgres",
|
||||||
|
"id": "3f95a7dd-45cb-4d4f-a904-dc74267e2c80",
|
||||||
|
"prevIds": [
|
||||||
|
"56dfe996-2f67-4ad1-adf6-9ebccb899ecc"
|
||||||
|
],
|
||||||
|
"ddl": [
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "account",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "session",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "user",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "users",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "verification",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "account_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "provider_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "access_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "refresh_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "scope",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "password",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "ip_address",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_agent",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "impersonated_by",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "false",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email_verified",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "image",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "'user'",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "role",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "false",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "banned",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "ban_reason",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "ban_expires",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": {
|
||||||
|
"type": "always",
|
||||||
|
"name": "users_id_seq",
|
||||||
|
"increment": "1",
|
||||||
|
"startWith": "1",
|
||||||
|
"minValue": "1",
|
||||||
|
"maxValue": "2147483647",
|
||||||
|
"cache": 1,
|
||||||
|
"cycle": false
|
||||||
|
},
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "age",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "identifier",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "value",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "public",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"name": "account_user_id_user_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "public",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"name": "session_user_id_user_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "account_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "session_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "user_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "users_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "verification_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "session_token_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session",
|
||||||
|
"entityType": "uniques"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "user_email_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user",
|
||||||
|
"entityType": "uniques"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "users_email_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users",
|
||||||
|
"entityType": "uniques"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"renames": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "upload" (
|
||||||
|
"id" text PRIMARY KEY,
|
||||||
|
"original_name" text NOT NULL,
|
||||||
|
"oss_key" text NOT NULL UNIQUE,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"mime_type" text NOT NULL,
|
||||||
|
"size" integer NOT NULL,
|
||||||
|
"bucket" text NOT NULL,
|
||||||
|
"user_id" text,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "upload" ADD CONSTRAINT "upload_user_id_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE SET NULL;
|
||||||
@@ -0,0 +1,887 @@
|
|||||||
|
{
|
||||||
|
"version": "8",
|
||||||
|
"dialect": "postgres",
|
||||||
|
"id": "dd8f5a3e-96c7-4281-b823-f1c2df219021",
|
||||||
|
"prevIds": [
|
||||||
|
"3f95a7dd-45cb-4d4f-a904-dc74267e2c80"
|
||||||
|
],
|
||||||
|
"ddl": [
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "account",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "session",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "upload",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "user",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "users",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"isRlsEnabled": false,
|
||||||
|
"name": "verification",
|
||||||
|
"entityType": "tables",
|
||||||
|
"schema": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "account_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "provider_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "access_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "refresh_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id_token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "scope",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "password",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "token",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "ip_address",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_agent",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "impersonated_by",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "original_name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "oss_key",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "url",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "mime_type",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "size",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "bucket",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "user_id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "false",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email_verified",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "image",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "'user'",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "role",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "false",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "banned",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "ban_reason",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "ban_expires",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": "now()",
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": {
|
||||||
|
"type": "always",
|
||||||
|
"name": "users_id_seq",
|
||||||
|
"increment": "1",
|
||||||
|
"startWith": "1",
|
||||||
|
"minValue": "1",
|
||||||
|
"maxValue": "2147483647",
|
||||||
|
"cache": 1,
|
||||||
|
"cycle": false
|
||||||
|
},
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "name",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "age",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "email",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "id",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "identifier",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "value",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": true,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "expires_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "created_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"typeSchema": null,
|
||||||
|
"notNull": false,
|
||||||
|
"dimensions": 0,
|
||||||
|
"default": null,
|
||||||
|
"generated": null,
|
||||||
|
"identity": null,
|
||||||
|
"name": "updated_at",
|
||||||
|
"entityType": "columns",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "public",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"name": "account_user_id_user_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "public",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"name": "session_user_id_user_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"schemaTo": "public",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"onDelete": "SET NULL",
|
||||||
|
"name": "upload_user_id_user_id_fkey",
|
||||||
|
"entityType": "fks",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "account_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "account",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "session_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "upload_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "user_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "users_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"nameExplicit": false,
|
||||||
|
"name": "verification_pkey",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "verification",
|
||||||
|
"entityType": "pks"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "session_token_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "session",
|
||||||
|
"entityType": "uniques"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"oss_key"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "upload_oss_key_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "upload",
|
||||||
|
"entityType": "uniques"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "user_email_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "user",
|
||||||
|
"entityType": "uniques"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nameExplicit": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"name": "users_email_key",
|
||||||
|
"schema": "public",
|
||||||
|
"table": "users",
|
||||||
|
"entityType": "uniques"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"renames": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
reactCompiler: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "dingxing",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "biome check",
|
||||||
|
"format": "biome format --write"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.6.0",
|
||||||
|
"ali-oss": "^6.23.0",
|
||||||
|
"better-auth": "^1.6.23",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"drizzle-orm": "1.0.0-rc.4",
|
||||||
|
"lucide-react": "^1.24.0",
|
||||||
|
"next": "16.2.10",
|
||||||
|
"pg": "^8.22.0",
|
||||||
|
"proxy-agent": "^8.0.2",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"shadcn": "^4.13.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.2.0",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/ali-oss": "^6.23.3",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
|
"drizzle-kit": "1.0.0-rc.4",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.23.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5487
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
sharp: true
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"better-auth-best-practices": {
|
||||||
|
"source": "better-auth/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "better-auth/best-practices/SKILL.md",
|
||||||
|
"computedHash": "61ba0ef64ed2e7c424401cc848ca33dd6d790a720c44727717dc0c5cba5fc122"
|
||||||
|
},
|
||||||
|
"better-auth-security-best-practices": {
|
||||||
|
"source": "better-auth/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "security/SKILL.md",
|
||||||
|
"computedHash": "7a230edf3a24ce18162cb4379f59b6a19b8d24ca04db530609d0f15cede556dc"
|
||||||
|
},
|
||||||
|
"create-auth": {
|
||||||
|
"source": "better-auth/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "better-auth/create-auth/SKILL.md",
|
||||||
|
"computedHash": "4e261efac2ae9a2321c4495c846ef97cf239818fc8bd2ab7e92ed1f9d25470f7"
|
||||||
|
},
|
||||||
|
"email-and-password-best-practices": {
|
||||||
|
"source": "better-auth/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "better-auth/emailAndPassword/SKILL.md",
|
||||||
|
"computedHash": "747d7f73bd9e2ec5317b8ef88dc70f97df716d00e3d02de4051feebaa48b062d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
DollarSign,
|
||||||
|
Users,
|
||||||
|
TrendingUp,
|
||||||
|
ArrowUpRight,
|
||||||
|
ArrowDownRight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{
|
||||||
|
title: "总收入",
|
||||||
|
value: "¥ 128,430",
|
||||||
|
change: "+12.5%",
|
||||||
|
trend: "up" as const,
|
||||||
|
icon: DollarSign,
|
||||||
|
desc: "较上月",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "活跃用户",
|
||||||
|
value: "2,840",
|
||||||
|
change: "+8.2%",
|
||||||
|
trend: "up" as const,
|
||||||
|
icon: Users,
|
||||||
|
desc: "较上周",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "转化率",
|
||||||
|
value: "3.6%",
|
||||||
|
change: "-0.4%",
|
||||||
|
trend: "down" as const,
|
||||||
|
icon: TrendingUp,
|
||||||
|
desc: "较上月",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "实时会话",
|
||||||
|
value: "147",
|
||||||
|
change: "+23",
|
||||||
|
trend: "up" as const,
|
||||||
|
icon: Activity,
|
||||||
|
desc: "当前在线",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const recentOrders = [
|
||||||
|
{
|
||||||
|
id: "ORD-7821",
|
||||||
|
customer: "张三",
|
||||||
|
email: "zhangsan@example.com",
|
||||||
|
amount: "¥ 1,299",
|
||||||
|
status: "已完成",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ORD-7820",
|
||||||
|
customer: "李四",
|
||||||
|
email: "lisi@example.com",
|
||||||
|
amount: "¥ 899",
|
||||||
|
status: "处理中",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ORD-7819",
|
||||||
|
customer: "王五",
|
||||||
|
email: "wangwu@example.com",
|
||||||
|
amount: "¥ 2,499",
|
||||||
|
status: "已完成",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "ORD-7818",
|
||||||
|
customer: "赵六",
|
||||||
|
email: "zhaoliu@example.com",
|
||||||
|
amount: "¥ 599",
|
||||||
|
status: "已取消",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusVariant(status: string) {
|
||||||
|
if (status === "已完成") return "default" as const;
|
||||||
|
if (status === "处理中") return "secondary" as const;
|
||||||
|
return "destructive" as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">仪表盘</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
欢迎回来,这是你的管理后台概览。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<Card key={stat.title} className="gap-2">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardDescription>{stat.title}</CardDescription>
|
||||||
|
<stat.icon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-semibold tabular-nums">
|
||||||
|
{stat.value}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 font-medium ${
|
||||||
|
stat.trend === "up"
|
||||||
|
? "text-emerald-600"
|
||||||
|
: "text-destructive"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{stat.trend === "up" ? (
|
||||||
|
<ArrowUpRight className="size-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDownRight className="size-3" />
|
||||||
|
)}
|
||||||
|
{stat.change}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1">{stat.desc}</span>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-7">
|
||||||
|
<Card className="lg:col-span-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近订单</CardTitle>
|
||||||
|
<CardDescription>最近 5 笔订单记录</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
查看全部
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="divide-y">
|
||||||
|
{recentOrders.map((order) => (
|
||||||
|
<div
|
||||||
|
key={order.id}
|
||||||
|
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{order.customer}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{order.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Badge variant={statusVariant(order.status)}>
|
||||||
|
{order.status}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm font-medium tabular-nums">
|
||||||
|
{order.amount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="lg:col-span-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>快捷操作</CardTitle>
|
||||||
|
<CardDescription>常用管理入口</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-2">
|
||||||
|
<Button variant="outline" className="justify-start">
|
||||||
|
<Users className="size-4" />
|
||||||
|
添加新用户
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="justify-start">
|
||||||
|
<DollarSign className="size-4" />
|
||||||
|
创建订单
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="justify-start">
|
||||||
|
<Activity className="size-4" />
|
||||||
|
查看日志
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="justify-start">
|
||||||
|
<TrendingUp className="size-4" />
|
||||||
|
数据导出
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">设置</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
管理系统偏好与账户信息。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>站点信息</CardTitle>
|
||||||
|
<CardDescription>配置站点名称与基础信息。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="site-name">站点名称</Label>
|
||||||
|
<Input id="site-name" defaultValue="鼎星管理后台" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="site-url">站点地址</Label>
|
||||||
|
<Input
|
||||||
|
id="site-url"
|
||||||
|
type="url"
|
||||||
|
defaultValue="https://admin.dingxing.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-end gap-2">
|
||||||
|
<Button variant="ghost">取消</Button>
|
||||||
|
<Button>保存</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>通知设置</CardTitle>
|
||||||
|
<CardDescription>配置邮件与系统通知偏好。</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="notify-email">通知邮箱</Label>
|
||||||
|
<Input
|
||||||
|
id="notify-email"
|
||||||
|
type="email"
|
||||||
|
defaultValue="admin@dingxing.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="justify-end gap-2">
|
||||||
|
<Button variant="ghost">取消</Button>
|
||||||
|
<Button>保存</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-destructive">危险操作</CardTitle>
|
||||||
|
<CardDescription>以下操作不可撤销,请谨慎处理。</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
删除站点
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { desc } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { upload } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { FileUpload } from "@/components/file-upload";
|
||||||
|
import { UploadDeleteButton } from "@/components/upload-delete-button";
|
||||||
|
import { FileIcon, Upload as UploadIcon } from "lucide-react";
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: Date) {
|
||||||
|
return new Date(date).toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UploadsPage() {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploads = await db
|
||||||
|
.select()
|
||||||
|
.from(upload)
|
||||||
|
.orderBy(desc(upload.createdAt))
|
||||||
|
.limit(100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">文件管理</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
上传文件到阿里云 OSS,获取 CDN 加速链接。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传区 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>上传文件</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
文件将上传到 OSS 并返回 CDN 加速 URL
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<FileUpload />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 文件列表 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>文件列表</CardTitle>
|
||||||
|
<CardDescription>共 {uploads.length} 个文件</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>文件名</TableHead>
|
||||||
|
<TableHead>类型</TableHead>
|
||||||
|
<TableHead className="text-right">大小</TableHead>
|
||||||
|
<TableHead>上传时间</TableHead>
|
||||||
|
<TableHead>CDN 链接</TableHead>
|
||||||
|
<TableHead className="w-10 text-right"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{uploads.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={6}
|
||||||
|
className="py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无文件,上传一个试试吧
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
uploads.map((file) => (
|
||||||
|
<TableRow key={file.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{file.originalName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">{file.mimeType}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right tabular-nums">
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{formatDate(file.createdAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<a
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{file.url.length > 40
|
||||||
|
? `${file.url.slice(0, 37)}...`
|
||||||
|
: file.url}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<UploadDeleteButton id={file.id} name={file.originalName} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardAction,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/components/ui/avatar";
|
||||||
|
import { UserActions } from "@/components/user-actions";
|
||||||
|
import { Plus, Search, Filter } from "lucide-react";
|
||||||
|
|
||||||
|
export default async function UsersPage() {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await auth.api.listUsers({
|
||||||
|
headers: await headers(),
|
||||||
|
query: {
|
||||||
|
limit: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = result?.users ?? [];
|
||||||
|
const total = result?.total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">用户管理</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
管理系统中的用户、角色和权限。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>用户列表</CardTitle>
|
||||||
|
<CardDescription>共 {total} 位用户</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Button size="sm">
|
||||||
|
<Plus className="size-4" />
|
||||||
|
添加用户
|
||||||
|
</Button>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<div className="relative max-w-sm flex-1">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input placeholder="搜索用户..." className="pl-8" />
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
<Filter className="size-4" />
|
||||||
|
筛选
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>用户</TableHead>
|
||||||
|
<TableHead>邮箱</TableHead>
|
||||||
|
<TableHead>角色</TableHead>
|
||||||
|
<TableHead>状态</TableHead>
|
||||||
|
<TableHead className="w-10 text-right"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={5}
|
||||||
|
className="py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无用户数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="size-8">
|
||||||
|
<AvatarImage
|
||||||
|
src={user.image ?? ""}
|
||||||
|
alt={user.name}
|
||||||
|
/>
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{user.name.charAt(0)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{user.name}</span>
|
||||||
|
{user.id === session?.user.id && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
(你)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
user.role === "admin" ? "default" : "outline"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{user.role === "admin" ? "管理员" : "普通用户"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.banned ? (
|
||||||
|
<Badge variant="destructive">已封禁</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="bg-emerald-600">正常</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<UserActions
|
||||||
|
user={{
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role ?? "user",
|
||||||
|
banned: user.banned ?? false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { AppSidebar } from "@/components/app-sidebar";
|
||||||
|
import { SiteHeader } from "@/components/site-header";
|
||||||
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有 admin 角色才能访问管理端
|
||||||
|
if (session.user.role !== "admin") {
|
||||||
|
redirect("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<SidebarProvider>
|
||||||
|
<AppSidebar user={session.user} />
|
||||||
|
<SidebarInset>
|
||||||
|
<SiteHeader user={session.user} />
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SidebarInset>
|
||||||
|
</SidebarProvider>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Mail, Loader2, CheckCircle2, ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const { error } = await authClient.signIn.magicLink({
|
||||||
|
email,
|
||||||
|
name: name || undefined,
|
||||||
|
callbackURL: "/admin",
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message ?? "发送失败,请稍后重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSent(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center bg-muted/50 p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-2 flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<Mail className="size-5" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">检查你的邮箱</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
我们已向 <strong>{email}</strong> 发送了一封登录邮件
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
|
<CheckCircle2 className="size-10 text-emerald-500" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
点击邮件中的链接即可登录到管理后台。链接 5 分钟内有效。
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => {
|
||||||
|
setSent(false);
|
||||||
|
setEmail("");
|
||||||
|
setName("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
返回重新输入
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh items-center justify-center bg-muted/50 p-4">
|
||||||
|
<Card className="w-full max-w-sm">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<div className="mx-auto mb-2 flex size-10 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<span className="text-sm font-bold">DX</span>
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl">登录</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
输入邮箱,我们将发送登录链接到你的邮箱
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<CardContent className="grid gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="name">姓名(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="首次登录时用于注册"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoComplete="name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="email">邮箱</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="admin@dingxing.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-3">
|
||||||
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
发送中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Mail className="size-4" />
|
||||||
|
发送登录链接
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-center text-xs text-muted-foreground">
|
||||||
|
无需密码,点击邮件中的链接即可登录
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { upload } from "@/db/schema";
|
||||||
|
import {
|
||||||
|
getOssClient,
|
||||||
|
getOssBucket,
|
||||||
|
buildCdnUrl,
|
||||||
|
generateOssKey,
|
||||||
|
} from "@/lib/oss";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
// 鉴权:必须登录且是 admin
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get("file");
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return NextResponse.json({ error: "请上传文件" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 限制文件大小 50MB
|
||||||
|
const MAX_SIZE = 50 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "文件大小不能超过 50MB" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalName = file.name;
|
||||||
|
const mimeType = file.type || "application/octet-stream";
|
||||||
|
const ossKey = generateOssKey(originalName);
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ossClient = getOssClient();
|
||||||
|
|
||||||
|
// 上传到 OSS
|
||||||
|
await ossClient.put(ossKey, buffer, {
|
||||||
|
mime: mimeType,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生成 CDN URL
|
||||||
|
const cdnUrl = buildCdnUrl(ossKey);
|
||||||
|
|
||||||
|
// 生成记录 ID
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
|
||||||
|
// 写入数据库记录
|
||||||
|
await db.insert(upload).values({
|
||||||
|
id,
|
||||||
|
originalName,
|
||||||
|
ossKey,
|
||||||
|
url: cdnUrl,
|
||||||
|
mimeType,
|
||||||
|
size: file.size,
|
||||||
|
bucket: getOssBucket(),
|
||||||
|
userId: session.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
id,
|
||||||
|
url: cdnUrl,
|
||||||
|
originalName,
|
||||||
|
size: file.size,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[上传失败]", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "上传失败,请稍后重试" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { upload } from "@/db/schema";
|
||||||
|
import { getOssClient } from "@/lib/oss";
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
// 鉴权:必须登录且是 admin
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: await headers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// 查询上传记录
|
||||||
|
const [record] = await db
|
||||||
|
.select()
|
||||||
|
.from(upload)
|
||||||
|
.where(eq(upload.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return NextResponse.json({ error: "记录不存在" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ossClient = getOssClient();
|
||||||
|
|
||||||
|
// 先从 OSS 删除文件
|
||||||
|
await ossClient.delete(record.ossKey);
|
||||||
|
|
||||||
|
// 再从数据库删除记录
|
||||||
|
await db.delete(upload).where(eq(upload.id, id));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[删除文件失败]", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "删除失败,请稍后重试" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,130 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
--font-heading: var(--font-sans);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.87 0 0);
|
||||||
|
--chart-2: oklch(0.556 0 0);
|
||||||
|
--chart-3: oklch(0.439 0 0);
|
||||||
|
--chart-4: oklch(0.371 0 0);
|
||||||
|
--chart-5: oklch(0.269 0 0);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "鼎星",
|
||||||
|
description: "鼎星平台",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||||
|
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||||
|
<Image
|
||||||
|
className="dark:invert"
|
||||||
|
src="/next.svg"
|
||||||
|
alt="Next.js logo"
|
||||||
|
width={100}
|
||||||
|
height={20}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||||
|
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||||
|
To get started, edit the page.tsx file.
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||||
|
Looking for a starting point or more instructions? Head over to{" "}
|
||||||
|
<a
|
||||||
|
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
Templates
|
||||||
|
</a>{" "}
|
||||||
|
or the{" "}
|
||||||
|
<a
|
||||||
|
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||||
|
>
|
||||||
|
Learning
|
||||||
|
</a>{" "}
|
||||||
|
center.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||||
|
<a
|
||||||
|
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||||
|
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
className="dark:invert"
|
||||||
|
src="/vercel.svg"
|
||||||
|
alt="Vercel logomark"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
/>
|
||||||
|
Deploy Now
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||||
|
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
ChevronRight,
|
||||||
|
LogOut,
|
||||||
|
ChevronsUpDown,
|
||||||
|
CloudUpload,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarRail,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/components/ui/avatar";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
items?: { title: string; url: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const navData: NavItem[] = [
|
||||||
|
{ title: "仪表盘", url: "/admin", icon: LayoutDashboard },
|
||||||
|
{
|
||||||
|
title: "用户管理",
|
||||||
|
url: "/admin/users",
|
||||||
|
icon: Users,
|
||||||
|
items: [
|
||||||
|
{ title: "用户列表", url: "/admin/users" },
|
||||||
|
{ title: "角色管理", url: "/admin/users/roles" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ title: "文件管理", url: "/admin/uploads", icon: CloudUpload },
|
||||||
|
{ title: "设置", url: "/admin/settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
type SidebarUser = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
image?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AppSidebar({
|
||||||
|
user,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Sidebar> & { user?: SidebarUser }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const isActive = (url: string) =>
|
||||||
|
url === "/admin" ? pathname === url : pathname.startsWith(url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar collapsible="icon" {...props}>
|
||||||
|
<SidebarHeader>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
tooltip="鼎星管理后台"
|
||||||
|
render={<Link href="/admin" />}
|
||||||
|
>
|
||||||
|
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||||
|
<span className="text-sm font-bold">DX</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">鼎星管理后台</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
Admin Console
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarHeader>
|
||||||
|
|
||||||
|
<SidebarContent>
|
||||||
|
<SidebarGroup>
|
||||||
|
<SidebarGroupLabel>管理</SidebarGroupLabel>
|
||||||
|
<SidebarMenu>
|
||||||
|
{navData.map((item) =>
|
||||||
|
item.items ? (
|
||||||
|
<Collapsible
|
||||||
|
key={item.title}
|
||||||
|
defaultOpen={item.items.some((sub) => isActive(sub.url))}
|
||||||
|
className="group/collapsible [&[data-open]>li>button>svg:last-child]:rotate-90"
|
||||||
|
>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<CollapsibleTrigger
|
||||||
|
render={<SidebarMenuButton tooltip={item.title} />}
|
||||||
|
>
|
||||||
|
<item.icon className="size-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
<ChevronRight className="ml-auto transition-transform duration-200" />
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
<CollapsibleContent>
|
||||||
|
<SidebarMenuSub>
|
||||||
|
{item.items.map((sub) => (
|
||||||
|
<SidebarMenuSubItem key={sub.url}>
|
||||||
|
<SidebarMenuSubButton
|
||||||
|
render={<Link href={sub.url} />}
|
||||||
|
isActive={isActive(sub.url)}
|
||||||
|
>
|
||||||
|
<span>{sub.title}</span>
|
||||||
|
</SidebarMenuSubButton>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
))}
|
||||||
|
</SidebarMenuSub>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</Collapsible>
|
||||||
|
) : (
|
||||||
|
<SidebarMenuItem key={item.title}>
|
||||||
|
<SidebarMenuButton
|
||||||
|
isActive={isActive(item.url)}
|
||||||
|
tooltip={item.title}
|
||||||
|
render={<Link href={item.url} />}
|
||||||
|
>
|
||||||
|
<item.icon className="size-4" />
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroup>
|
||||||
|
</SidebarContent>
|
||||||
|
|
||||||
|
<SidebarFooter>
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<SidebarMenuButton size="lg" />}
|
||||||
|
>
|
||||||
|
<Avatar className="size-8">
|
||||||
|
<AvatarImage src={user?.image ?? ""} alt={user?.name ?? ""} />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{user?.name?.charAt(0) ?? "U"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
|
<span className="truncate font-semibold">
|
||||||
|
{user?.name ?? "用户"}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{user?.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-auto size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
className="w-[--radix-dropdown-menu-trigger-width] min-w-56"
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user?.name ?? "用户"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user?.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={async () => {
|
||||||
|
await authClient.signOut();
|
||||||
|
router.push("/login");
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
退出登录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarFooter>
|
||||||
|
|
||||||
|
<SidebarRail />
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from "react";
|
||||||
|
import { Upload, Loader2, X, CheckCircle2, FileIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type UploadedFile = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
originalName: string;
|
||||||
|
size: number;
|
||||||
|
mimeType: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FileUpload({
|
||||||
|
onUploaded,
|
||||||
|
}: {
|
||||||
|
onUploaded?: (file: UploadedFile) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
|
||||||
|
const uploadFile = useCallback(async (file: File) => {
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/upload", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? "上传失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadedFiles((prev) => [data, ...prev]);
|
||||||
|
onUploaded?.(data);
|
||||||
|
} catch {
|
||||||
|
setError("网络错误,上传失败");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}, [onUploaded]);
|
||||||
|
|
||||||
|
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
// 重置 input 以便重复上传同一文件
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e: React.DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyUrl(url: string) {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* 拖拽上传区 */}
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-8 transition-colors",
|
||||||
|
dragging
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-muted-foreground/25 hover:border-primary/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className="size-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
将文件拖拽到此处,或
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
上传中...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
选择文件
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
支持任意类型文件,最大 50MB
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 已上传文件列表 */}
|
||||||
|
{uploadedFiles.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{uploadedFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="size-5 shrink-0 text-emerald-500" />
|
||||||
|
<FileIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm font-medium">
|
||||||
|
{file.originalName}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatSize(file.size)} · {file.mimeType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copyUrl(file.url)}
|
||||||
|
>
|
||||||
|
复制链接
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() =>
|
||||||
|
setUploadedFiles((prev) =>
|
||||||
|
prev.filter((f) => f.id !== file.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@/components/ui/avatar";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { Bell, ChevronsUpDown, LogOut, Settings, User } from "lucide-react";
|
||||||
|
|
||||||
|
type HeaderUser = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
image?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function deriveBreadcrumbs(pathname: string) {
|
||||||
|
const segments = pathname.split("/").filter(Boolean);
|
||||||
|
const crumbs = segments.map((seg, i) => ({
|
||||||
|
title:
|
||||||
|
seg === "admin"
|
||||||
|
? "首页"
|
||||||
|
: seg.charAt(0).toUpperCase() + seg.slice(1),
|
||||||
|
url: "/" + segments.slice(0, i + 1).join("/"),
|
||||||
|
}));
|
||||||
|
return crumbs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteHeader({ user }: { user?: HeaderUser }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const crumbs = deriveBreadcrumbs(pathname);
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
await authClient.signOut();
|
||||||
|
router.push("/login");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 flex h-16 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60 md:px-6">
|
||||||
|
<SidebarTrigger className="-ml-1" />
|
||||||
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
|
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
{crumbs.map((crumb, i) => (
|
||||||
|
<React.Fragment key={crumb.url}>
|
||||||
|
<BreadcrumbItem>
|
||||||
|
{i === crumbs.length - 1 ? (
|
||||||
|
<BreadcrumbPage>{crumb.title}</BreadcrumbPage>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbLink render={<Link href={crumb.url} />}>
|
||||||
|
{crumb.title}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{i < crumbs.length - 1 && <BreadcrumbSeparator />}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<Bell className="size-4" />
|
||||||
|
<span className="sr-only">通知</span>
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute -right-0.5 -top-0.5 size-4 rounded-full p-0 text-[9px] leading-none"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</Badge>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={<Button variant="ghost" className="h-9 gap-2 px-1.5" />}
|
||||||
|
>
|
||||||
|
<Avatar className="size-7">
|
||||||
|
<AvatarImage src={user?.image ?? ""} alt="头像" />
|
||||||
|
<AvatarFallback className="text-xs">
|
||||||
|
{user?.name?.charAt(0) ?? "U"}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="hidden text-sm font-medium md:inline">
|
||||||
|
{user?.name ?? "用户"}
|
||||||
|
</span>
|
||||||
|
<ChevronsUpDown className="size-3.5 text-muted-foreground" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{user?.name ?? "用户"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user?.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User className="size-4" />
|
||||||
|
个人资料
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
账号设置
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem variant="destructive" onClick={handleSignOut}>
|
||||||
|
<LogOut className="size-4" />
|
||||||
|
退出登录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Root.Props & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"aspect-square size-full rounded-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Fallback.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, MoreHorizontalIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="breadcrumb"
|
||||||
|
data-slot="breadcrumb"
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||||
|
return (
|
||||||
|
<ol
|
||||||
|
data-slot="breadcrumb-list"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap items-center gap-1.5 text-sm wrap-break-word text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-item"
|
||||||
|
className={cn("inline-flex items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbLink({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"a">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "a",
|
||||||
|
props: mergeProps<"a">(
|
||||||
|
{
|
||||||
|
className: cn("transition-colors hover:text-foreground", className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "breadcrumb-link",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-page"
|
||||||
|
role="link"
|
||||||
|
aria-disabled="true"
|
||||||
|
aria-current="page"
|
||||||
|
className={cn("font-normal text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbSeparator({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="breadcrumb-separator"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children ?? (
|
||||||
|
<ChevronRightIcon />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="breadcrumb-ellipsis"
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center [&>svg]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">More</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
BreadcrumbEllipsis,
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||||
|
return (
|
||||||
|
<ButtonPrimitive
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground ring-1 ring-foreground/10 [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-(--card-spacing)", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
|
||||||
|
|
||||||
|
function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
|
||||||
|
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { PanelLeftIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||||
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||||
|
const SIDEBAR_WIDTH = "16rem"
|
||||||
|
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||||
|
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||||
|
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||||
|
|
||||||
|
type SidebarContextProps = {
|
||||||
|
state: "expanded" | "collapsed"
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
openMobile: boolean
|
||||||
|
setOpenMobile: (open: boolean) => void
|
||||||
|
isMobile: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||||
|
|
||||||
|
function useSidebar() {
|
||||||
|
const context = React.useContext(SidebarContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSidebar must be used within a SidebarProvider.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarProvider({
|
||||||
|
defaultOpen = true,
|
||||||
|
open: openProp,
|
||||||
|
onOpenChange: setOpenProp,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
defaultOpen?: boolean
|
||||||
|
open?: boolean
|
||||||
|
onOpenChange?: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [openMobile, setOpenMobile] = React.useState(false)
|
||||||
|
|
||||||
|
// This is the internal state of the sidebar.
|
||||||
|
// We use openProp and setOpenProp for control from outside the component.
|
||||||
|
const [_open, _setOpen] = React.useState(defaultOpen)
|
||||||
|
const open = openProp ?? _open
|
||||||
|
const setOpen = React.useCallback(
|
||||||
|
(value: boolean | ((value: boolean) => boolean)) => {
|
||||||
|
const openState = typeof value === "function" ? value(open) : value
|
||||||
|
if (setOpenProp) {
|
||||||
|
setOpenProp(openState)
|
||||||
|
} else {
|
||||||
|
_setOpen(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This sets the cookie to keep the sidebar state.
|
||||||
|
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||||
|
},
|
||||||
|
[setOpenProp, open]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper to toggle the sidebar.
|
||||||
|
const toggleSidebar = React.useCallback(() => {
|
||||||
|
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
|
||||||
|
}, [isMobile, setOpen, setOpenMobile])
|
||||||
|
|
||||||
|
// Adds a keyboard shortcut to toggle the sidebar.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (
|
||||||
|
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||||
|
(event.metaKey || event.ctrlKey)
|
||||||
|
) {
|
||||||
|
event.preventDefault()
|
||||||
|
toggleSidebar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleKeyDown)
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||||
|
}, [toggleSidebar])
|
||||||
|
|
||||||
|
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||||
|
// This makes it easier to style the sidebar with Tailwind classes.
|
||||||
|
const state = open ? "expanded" : "collapsed"
|
||||||
|
|
||||||
|
const contextValue = React.useMemo<SidebarContextProps>(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
open,
|
||||||
|
setOpen,
|
||||||
|
isMobile,
|
||||||
|
openMobile,
|
||||||
|
setOpenMobile,
|
||||||
|
toggleSidebar,
|
||||||
|
}),
|
||||||
|
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarContext.Provider value={contextValue}>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-wrapper"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH,
|
||||||
|
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||||
|
...style,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</SidebarContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sidebar({
|
||||||
|
side = "left",
|
||||||
|
variant = "sidebar",
|
||||||
|
collapsible = "offcanvas",
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
dir,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
side?: "left" | "right"
|
||||||
|
variant?: "sidebar" | "floating" | "inset"
|
||||||
|
collapsible?: "offcanvas" | "icon" | "none"
|
||||||
|
}) {
|
||||||
|
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||||
|
|
||||||
|
if (collapsible === "none") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
|
<SheetContent
|
||||||
|
dir={dir}
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar"
|
||||||
|
data-mobile="true"
|
||||||
|
className="w-(--sidebar-width) bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<SheetHeader className="sr-only">
|
||||||
|
<SheetTitle>Sidebar</SheetTitle>
|
||||||
|
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group peer hidden text-sidebar-foreground md:block"
|
||||||
|
data-state={state}
|
||||||
|
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||||
|
data-variant={variant}
|
||||||
|
data-side={side}
|
||||||
|
data-slot="sidebar"
|
||||||
|
>
|
||||||
|
{/* This is what handles the sidebar gap on desktop */}
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-gap"
|
||||||
|
className={cn(
|
||||||
|
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||||
|
"group-data-[collapsible=offcanvas]:w-0",
|
||||||
|
"group-data-[side=right]:rotate-180",
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-container"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)] md:flex",
|
||||||
|
// Adjust the padding for floating and inset variants.
|
||||||
|
variant === "floating" || variant === "inset"
|
||||||
|
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||||
|
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
data-sidebar="sidebar"
|
||||||
|
data-slot="sidebar-inner"
|
||||||
|
className="flex size-full flex-col bg-sidebar group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:shadow-sm group-data-[variant=floating]:ring-1 group-data-[variant=floating]:ring-sidebar-border"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarTrigger({
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Button>) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
data-sidebar="trigger"
|
||||||
|
data-slot="sidebar-trigger"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className={cn(className)}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event)
|
||||||
|
toggleSidebar()
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<PanelLeftIcon />
|
||||||
|
<span className="sr-only">Toggle Sidebar</span>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||||
|
const { toggleSidebar } = useSidebar()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
data-sidebar="rail"
|
||||||
|
data-slot="sidebar-rail"
|
||||||
|
aria-label="Toggle Sidebar"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle Sidebar"
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||||
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||||
|
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||||
|
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||||
|
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
data-slot="sidebar-inset"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full flex-1 flex-col bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Input>) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
data-slot="sidebar-input"
|
||||||
|
data-sidebar="input"
|
||||||
|
className={cn("h-8 w-full bg-background shadow-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-header"
|
||||||
|
data-sidebar="header"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-footer"
|
||||||
|
data-sidebar="footer"
|
||||||
|
className={cn("flex flex-col gap-2 p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Separator>) {
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
data-slot="sidebar-separator"
|
||||||
|
data-sidebar="separator"
|
||||||
|
className={cn("mx-2 w-auto bg-sidebar-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-content"
|
||||||
|
data-sidebar="content"
|
||||||
|
className={cn(
|
||||||
|
"no-scrollbar flex min-h-0 flex-1 flex-col gap-0 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group"
|
||||||
|
data-sidebar="group"
|
||||||
|
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupLabel({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"div"> & React.ComponentProps<"div">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "div",
|
||||||
|
props: mergeProps<"div">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-group-label",
|
||||||
|
sidebar: "group-label",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupAction({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"button"> & React.ComponentProps<"button">) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
props: mergeProps<"button">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-group-action",
|
||||||
|
sidebar: "group-action",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarGroupContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-group-content"
|
||||||
|
data-sidebar="group-content"
|
||||||
|
className={cn("w-full text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu"
|
||||||
|
data-sidebar="menu"
|
||||||
|
className={cn("flex w-full min-w-0 flex-col gap-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-item"
|
||||||
|
data-sidebar="menu-item"
|
||||||
|
className={cn("group/menu-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarMenuButtonVariants = cva(
|
||||||
|
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm ring-sidebar-ring outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pr-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground data-active:bg-sidebar-accent data-active:font-medium data-active:text-sidebar-accent-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&>span:last-child]:truncate",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
|
outline:
|
||||||
|
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-8 text-sm",
|
||||||
|
sm: "h-7 text-xs",
|
||||||
|
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function SidebarMenuButton({
|
||||||
|
render,
|
||||||
|
isActive = false,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
tooltip,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"button"> &
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
isActive?: boolean
|
||||||
|
tooltip?: string | React.ComponentProps<typeof TooltipContent>
|
||||||
|
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||||
|
const { isMobile, state } = useSidebar()
|
||||||
|
const comp = useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
props: mergeProps<"button">(
|
||||||
|
{
|
||||||
|
className: cn(sidebarMenuButtonVariants({ variant, size }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render: !tooltip ? render : <TooltipTrigger render={render} />,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-menu-button",
|
||||||
|
sidebar: "menu-button",
|
||||||
|
size,
|
||||||
|
active: isActive,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tooltip) {
|
||||||
|
return comp
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof tooltip === "string") {
|
||||||
|
tooltip = {
|
||||||
|
children: tooltip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
{comp}
|
||||||
|
<TooltipContent
|
||||||
|
side="right"
|
||||||
|
align="center"
|
||||||
|
hidden={state !== "collapsed" || isMobile}
|
||||||
|
{...tooltip}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuAction({
|
||||||
|
className,
|
||||||
|
render,
|
||||||
|
showOnHover = false,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"button"> &
|
||||||
|
React.ComponentProps<"button"> & {
|
||||||
|
showOnHover?: boolean
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "button",
|
||||||
|
props: mergeProps<"button">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground ring-sidebar-ring outline-hidden transition-transform group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0",
|
||||||
|
showOnHover &&
|
||||||
|
"group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground aria-expanded:opacity-100 md:opacity-0",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-menu-action",
|
||||||
|
sidebar: "menu-action",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuBadge({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-badge"
|
||||||
|
data-sidebar="menu-badge"
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium text-sidebar-foreground tabular-nums select-none group-data-[collapsible=icon]:hidden peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 peer-data-active/menu-button:text-sidebar-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSkeleton({
|
||||||
|
className,
|
||||||
|
showIcon = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showIcon?: boolean
|
||||||
|
}) {
|
||||||
|
// Random width between 50 to 90%.
|
||||||
|
const [width] = React.useState(() => {
|
||||||
|
return `${Math.floor(Math.random() * 40) + 50}%`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sidebar-menu-skeleton"
|
||||||
|
data-sidebar="menu-skeleton"
|
||||||
|
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{showIcon && (
|
||||||
|
<Skeleton
|
||||||
|
className="size-4 rounded-md"
|
||||||
|
data-sidebar="menu-skeleton-icon"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Skeleton
|
||||||
|
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||||
|
data-sidebar="menu-skeleton-text"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--skeleton-width": width,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="sidebar-menu-sub"
|
||||||
|
data-sidebar="menu-sub"
|
||||||
|
className={cn(
|
||||||
|
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5 group-data-[collapsible=icon]:hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"li">) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
data-slot="sidebar-menu-sub-item"
|
||||||
|
data-sidebar="menu-sub-item"
|
||||||
|
className={cn("group/menu-sub-item relative", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SidebarMenuSubButton({
|
||||||
|
render,
|
||||||
|
size = "md",
|
||||||
|
isActive = false,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"a"> &
|
||||||
|
React.ComponentProps<"a"> & {
|
||||||
|
size?: "sm" | "md"
|
||||||
|
isActive?: boolean
|
||||||
|
}) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "a",
|
||||||
|
props: mergeProps<"a">(
|
||||||
|
{
|
||||||
|
className: cn(
|
||||||
|
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground ring-sidebar-ring outline-hidden group-data-[collapsible=icon]:hidden hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[size=md]:text-sm data-[size=sm]:text-xs data-active:bg-sidebar-accent data-active:text-sidebar-accent-foreground [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground",
|
||||||
|
className
|
||||||
|
),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "sidebar-menu-sub-button",
|
||||||
|
sidebar: "menu-sub-button",
|
||||||
|
size,
|
||||||
|
active: isActive,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sidebar,
|
||||||
|
SidebarContent,
|
||||||
|
SidebarFooter,
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupAction,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarHeader,
|
||||||
|
SidebarInput,
|
||||||
|
SidebarInset,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuAction,
|
||||||
|
SidebarMenuBadge,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
SidebarMenuSkeleton,
|
||||||
|
SidebarMenuSub,
|
||||||
|
SidebarMenuSubButton,
|
||||||
|
SidebarMenuSubItem,
|
||||||
|
SidebarProvider,
|
||||||
|
SidebarRail,
|
||||||
|
SidebarSeparator,
|
||||||
|
SidebarTrigger,
|
||||||
|
useSidebar,
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Trash2, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function UploadDeleteButton({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/uploads/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
console.error("删除失败:", data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
console.error("网络错误");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger render={<Button variant="ghost" size="icon-sm" />}>
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
<span className="sr-only">删除</span>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
确定要删除文件「{name}」吗?该操作会同时删除 OSS 中的文件和数据库记录,不可撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => setOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
import { MoreHorizontal, ShieldCheck, User, Ban, CircleCheck, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
type UserRow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
banned: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserActions({ user }: { user: UserRow }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function setRole(role: "admin" | "user") {
|
||||||
|
setLoading(true);
|
||||||
|
const { error } = await authClient.admin.setRole({
|
||||||
|
userId: user.id,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (error) {
|
||||||
|
console.error("设置角色失败:", error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleBan() {
|
||||||
|
setLoading(true);
|
||||||
|
if (user.banned) {
|
||||||
|
const { error } = await authClient.admin.unbanUser({ userId: user.id });
|
||||||
|
setLoading(false);
|
||||||
|
if (error) {
|
||||||
|
console.error("解封失败:", error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error } = await authClient.admin.banUser({
|
||||||
|
userId: user.id,
|
||||||
|
banReason: "管理员手动封禁",
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
if (error) {
|
||||||
|
console.error("封禁失败:", error.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="icon-sm" disabled={loading} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">操作菜单</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuLabel>设置角色</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={() => setRole("admin")}>
|
||||||
|
<ShieldCheck className="size-4" />
|
||||||
|
管理员
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setRole("user")}>
|
||||||
|
<User className="size-4" />
|
||||||
|
普通用户
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={toggleBan}>
|
||||||
|
{user.banned ? (
|
||||||
|
<>
|
||||||
|
<CircleCheck className="size-4" />
|
||||||
|
解除封禁
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ban className="size-4" />
|
||||||
|
封禁用户
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
|
||||||
|
const db = drizzle(process.env.DATABASE_URL!);
|
||||||
|
|
||||||
|
export { db };
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
boolean,
|
||||||
|
integer,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
timestamp,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
// ---- better-auth 标准表 ----
|
||||||
|
|
||||||
|
export const user = pgTable("user", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
email: text("email").notNull().unique(),
|
||||||
|
emailVerified: boolean("email_verified").notNull().default(false),
|
||||||
|
image: text("image"),
|
||||||
|
// admin 插件字段
|
||||||
|
role: text("role").notNull().default("user"),
|
||||||
|
banned: boolean("banned").notNull().default(false),
|
||||||
|
banReason: text("ban_reason"),
|
||||||
|
banExpires: timestamp("ban_expires", { withTimezone: true }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const session = pgTable("session", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
token: text("token").notNull().unique(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
ipAddress: text("ip_address"),
|
||||||
|
userAgent: text("user_agent"),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
// admin 插件字段
|
||||||
|
impersonatedBy: text("impersonated_by"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const account = pgTable("account", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
accountId: text("account_id").notNull(),
|
||||||
|
providerId: text("provider_id").notNull(),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
accessToken: text("access_token"),
|
||||||
|
refreshToken: text("refresh_token"),
|
||||||
|
idToken: text("id_token"),
|
||||||
|
accessTokenExpiresAt: timestamp("access_token_expires_at", {
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
|
||||||
|
withTimezone: true,
|
||||||
|
}),
|
||||||
|
scope: text("scope"),
|
||||||
|
password: text("password"),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const verification = pgTable("verification", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
identifier: text("identifier").notNull(),
|
||||||
|
value: text("value").notNull(),
|
||||||
|
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 业务表 ----
|
||||||
|
|
||||||
|
export const usersTable = pgTable("users", {
|
||||||
|
id: integer().primaryKey().generatedAlwaysAsIdentity(),
|
||||||
|
name: varchar({ length: 255 }).notNull(),
|
||||||
|
age: integer().notNull(),
|
||||||
|
email: varchar({ length: 255 }).notNull().unique(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 文件上传记录表
|
||||||
|
export const upload = pgTable("upload", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
// 原始文件名
|
||||||
|
originalName: text("original_name").notNull(),
|
||||||
|
// OSS 中的存储路径(key)
|
||||||
|
ossKey: text("oss_key").notNull().unique(),
|
||||||
|
// CDN 加速后的访问 URL
|
||||||
|
url: text("url").notNull(),
|
||||||
|
// 文件 MIME 类型
|
||||||
|
mimeType: text("mime_type").notNull(),
|
||||||
|
// 文件大小(字节)
|
||||||
|
size: integer("size").notNull(),
|
||||||
|
// OSS bucket 名称
|
||||||
|
bucket: text("bucket").notNull(),
|
||||||
|
// 上传者用户 ID
|
||||||
|
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
.notNull()
|
||||||
|
.defaultNow(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
const MOBILE_BREAKPOINT = 768
|
||||||
|
|
||||||
|
export function useIsMobile() {
|
||||||
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||||
|
const onChange = () => {
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
}
|
||||||
|
mql.addEventListener("change", onChange)
|
||||||
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||||
|
return () => mql.removeEventListener("change", onChange)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return !!isMobile
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { createAuthClient } from "better-auth/react";
|
||||||
|
import { adminClient } from "better-auth/client/plugins";
|
||||||
|
import { magicLinkClient } from "better-auth/client/plugins";
|
||||||
|
import { ac, adminRole, userRole } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export const authClient = createAuthClient({
|
||||||
|
plugins: [
|
||||||
|
adminClient({
|
||||||
|
ac,
|
||||||
|
roles: {
|
||||||
|
admin: adminRole,
|
||||||
|
user: userRole,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
magicLinkClient(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { signIn, signOut, useSession } = authClient;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { db } from "@/db";
|
||||||
|
import * as schema from "@/db/schema";
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { admin } from "better-auth/plugins";
|
||||||
|
import { magicLink } from "better-auth/plugins";
|
||||||
|
import { ac, adminRole, userRole } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export const auth = betterAuth({
|
||||||
|
database: drizzleAdapter(db, {
|
||||||
|
provider: "pg",
|
||||||
|
schema: {
|
||||||
|
user: schema.user,
|
||||||
|
session: schema.session,
|
||||||
|
account: schema.account,
|
||||||
|
verification: schema.verification,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
plugins: [
|
||||||
|
admin({
|
||||||
|
defaultRole: "user",
|
||||||
|
adminRoles: ["admin"],
|
||||||
|
ac,
|
||||||
|
roles: {
|
||||||
|
admin: adminRole,
|
||||||
|
user: userRole,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
magicLink({
|
||||||
|
sendMagicLink: async ({ email, url, token }, ctx) => {
|
||||||
|
// TODO: 接入邮件服务(如 Resend)后替换为真实发信
|
||||||
|
console.log(`[Magic Link] 发送至 ${email},链接: ${url}`);
|
||||||
|
},
|
||||||
|
expiresIn: 60 * 5, // 5 分钟过期
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
storage: "memory",
|
||||||
|
rules: {
|
||||||
|
// magic link 发送限制:每 5 秒最多 5 次
|
||||||
|
magicLink: { limit: 5, window: 5 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Session = typeof auth.$Infer.Session;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import OSS from "ali-oss";
|
||||||
|
|
||||||
|
let _client: OSS | null = null;
|
||||||
|
|
||||||
|
function getOssConfig() {
|
||||||
|
const region = process.env.OSS_REGION;
|
||||||
|
const accessKeyId = process.env.OSS_ACCESS_KEY_ID;
|
||||||
|
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET;
|
||||||
|
const bucket = process.env.OSS_BUCKET;
|
||||||
|
const cdnDomain = process.env.OSS_CDN_DOMAIN;
|
||||||
|
|
||||||
|
if (!region || !accessKeyId || !accessKeySecret || !bucket) {
|
||||||
|
throw new Error(
|
||||||
|
"[OSS] 缺少环境变量配置,请检查 OSS_REGION / OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { region, accessKeyId, accessKeySecret, bucket, cdnDomain };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 OSS 客户端(懒加载,避免构建时因缺少环境变量报错)
|
||||||
|
*/
|
||||||
|
export function getOssClient(): OSS {
|
||||||
|
if (!_client) {
|
||||||
|
const { region, accessKeyId, accessKeySecret, bucket } = getOssConfig();
|
||||||
|
_client = new OSS({
|
||||||
|
region,
|
||||||
|
accessKeyId,
|
||||||
|
accessKeySecret,
|
||||||
|
bucket,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 OSS bucket 名称
|
||||||
|
*/
|
||||||
|
export function getOssBucket(): string {
|
||||||
|
return getOssConfig().bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 OSS key 生成 CDN 加速访问 URL
|
||||||
|
*/
|
||||||
|
export function buildCdnUrl(ossKey: string): string {
|
||||||
|
const { cdnDomain, bucket, region } = getOssConfig();
|
||||||
|
if (cdnDomain) {
|
||||||
|
return `${cdnDomain.replace(/\/$/, "")}/${ossKey}`;
|
||||||
|
}
|
||||||
|
// 如果没有配置 CDN,回退到 OSS 默认域名
|
||||||
|
return `https://${bucket}.${region.replace("oss-", "oss-")}.aliyuncs.com/${ossKey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 OSS 存储路径(key)
|
||||||
|
* 格式:uploads/2026/07/17/<uuid>.<ext>
|
||||||
|
*/
|
||||||
|
export function generateOssKey(originalName: string): string {
|
||||||
|
const ext = originalName.split(".").pop() ?? "";
|
||||||
|
const now = new Date();
|
||||||
|
const y = now.getFullYear();
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(now.getDate()).padStart(2, "0");
|
||||||
|
const uuid = crypto.randomUUID();
|
||||||
|
const suffix = ext ? `.${ext}` : "";
|
||||||
|
return `uploads/${y}/${m}/${d}/${uuid}${suffix}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createAccessControl } from "better-auth/plugins/access";
|
||||||
|
import {
|
||||||
|
defaultStatements,
|
||||||
|
adminAc,
|
||||||
|
} from "better-auth/plugins/admin/access";
|
||||||
|
|
||||||
|
// 合并默认权限语句与自定义业务权限语句
|
||||||
|
const statements = {
|
||||||
|
...defaultStatements,
|
||||||
|
// 自定义业务权限资源,按需扩展
|
||||||
|
// project: ["create", "share", "update", "delete"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ac = createAccessControl(statements);
|
||||||
|
|
||||||
|
// admin 角色:拥有全部管理权限
|
||||||
|
export const adminRole = ac.newRole({
|
||||||
|
...adminAc.statements,
|
||||||
|
});
|
||||||
|
|
||||||
|
// user 角色:无管理权限(仅拥有默认业务权限)
|
||||||
|
export const userRole = ac.newRole({
|
||||||
|
// project: ["create"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Roles = {
|
||||||
|
admin: typeof adminRole;
|
||||||
|
user: typeof userRole;
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user