Compare commits
15 Commits
1e33b87c96
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d552d31f4a | ||
|
|
a22fed0f9d | ||
|
|
f6621200cd | ||
|
|
54abb81a07 | ||
|
|
1045aea277 | ||
|
|
d470eb2cd5 | ||
|
|
cbd0cdced4 | ||
|
|
e8269bb2fe | ||
|
|
ba8a46fcd3 | ||
|
|
0bea7bc3f4 | ||
|
|
10859a4e64 | ||
|
|
2b78d4e332 | ||
|
|
448721f0a0 | ||
|
|
eb7c7a7221 | ||
|
|
4c8a6e9fd3 |
16
.cta.json
Normal file
16
.cta.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"projectName": "dofus-manager2",
|
||||
"mode": "file-router",
|
||||
"typescript": true,
|
||||
"tailwind": false,
|
||||
"packageManager": "pnpm",
|
||||
"git": true,
|
||||
"install": true,
|
||||
"addOnOptions": {},
|
||||
"version": 1,
|
||||
"framework": "react-cra",
|
||||
"chosenAddOns": [
|
||||
"start",
|
||||
"nitro"
|
||||
]
|
||||
}
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Database
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dofus_manager?schema=public"
|
||||
|
||||
# App
|
||||
APP_URL="http://localhost:3000"
|
||||
NODE_ENV="development"
|
||||
|
||||
# Session
|
||||
SESSION_SECRET="change-me-to-a-random-string-min-32-chars"
|
||||
146
.gitea/workflows/ci.yml
Normal file
146
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,146 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
environment:
|
||||
description: 'Environment to deploy'
|
||||
required: true
|
||||
default: 'staging'
|
||||
type: choice
|
||||
options:
|
||||
- staging
|
||||
- production
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: dofus_manager
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: pnpm prisma generate
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm prisma migrate deploy
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/dofus_manager
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/dofus_manager
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY_URL }}
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ vars.REGISTRY_URL }}/theo/dofus-manager:${{ github.sha }}
|
||||
${{ vars.REGISTRY_URL }}/theo/dofus-manager:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy-staging:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/develop'
|
||||
environment: staging
|
||||
|
||||
steps:
|
||||
- name: Deploy to staging
|
||||
run: |
|
||||
echo "Deploying to staging..."
|
||||
curl -X POST "${{ secrets.STAGING_WEBHOOK_URL }}" || true
|
||||
|
||||
deploy-production:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main'
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
- name: Deploy to production
|
||||
run: |
|
||||
echo "Deploying to production..."
|
||||
curl -X POST "${{ secrets.PRODUCTION_WEBHOOK_URL }}" || true
|
||||
|
||||
deploy-manual:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
environment: ${{ github.event.inputs.environment }}
|
||||
|
||||
steps:
|
||||
- name: Deploy to ${{ github.event.inputs.environment }}
|
||||
run: |
|
||||
echo "Manual deploy to ${{ github.event.inputs.environment }}"
|
||||
# Ajoutez votre logique de déploiement ici
|
||||
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
count.txt
|
||||
.env
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
.output
|
||||
.vinxi
|
||||
todos.json
|
||||
|
||||
/generated/prisma
|
||||
11
.vscode/settings.json
vendored
Normal file
11
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files.watcherExclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
},
|
||||
"files.readonlyInclude": {
|
||||
"**/routeTree.gen.ts": true
|
||||
}
|
||||
}
|
||||
93
README.md
Normal file
93
README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Dofus Manager
|
||||
|
||||
Application de gestion de personnages, comptes et équipes pour Dofus.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
- Docker & Docker Compose
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Cloner le repo
|
||||
git clone <repo-url>
|
||||
cd dofus-manager2
|
||||
|
||||
# Installer les dépendances
|
||||
pnpm install
|
||||
|
||||
# Copier les variables d'environnement
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
## Développement local
|
||||
|
||||
```bash
|
||||
# Lancer PostgreSQL
|
||||
docker compose -f docker/docker-compose.dev.yml up -d
|
||||
|
||||
# Appliquer les migrations
|
||||
pnpm prisma migrate dev
|
||||
|
||||
# Lancer le serveur de développement
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
L'application est accessible sur `http://localhost:3000`
|
||||
|
||||
## Scripts disponibles
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `pnpm dev` | Lancer le serveur de développement |
|
||||
| `pnpm build` | Build de production |
|
||||
| `pnpm start` | Lancer le build de production |
|
||||
| `pnpm lint` | Vérifier le code avec Biome |
|
||||
| `pnpm lint:fix` | Corriger les erreurs de lint |
|
||||
| `pnpm format` | Formater le code |
|
||||
| `pnpm typecheck` | Vérifier les types TypeScript |
|
||||
| `pnpm test` | Lancer les tests |
|
||||
| `pnpm prisma studio` | Ouvrir Prisma Studio |
|
||||
| `pnpm prisma migrate dev` | Créer/appliquer les migrations |
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
| Variable | Description | Exemple |
|
||||
|----------|-------------|---------|
|
||||
| `DATABASE_URL` | URL de connexion PostgreSQL | `postgresql://postgres:postgres@localhost:5432/dofus_manager` |
|
||||
| `APP_URL` | URL de l'application | `http://localhost:3000` |
|
||||
| `NODE_ENV` | Environnement | `development` / `production` |
|
||||
| `SESSION_SECRET` | Clé secrète pour les sessions (min 32 chars) | `change-me-to-a-random-string` |
|
||||
|
||||
## Structure du projet
|
||||
|
||||
```
|
||||
├── .gitea/workflows/ # CI/CD Gitea Actions
|
||||
├── docker/ # Dockerfile et docker-compose
|
||||
├── prisma/ # Schema et migrations Prisma
|
||||
├── src/
|
||||
│ ├── components/ # Composants React
|
||||
│ ├── lib/ # Utilitaires et services
|
||||
│ ├── routes/ # Pages et API (TanStack Router)
|
||||
│ └── styles/ # CSS global
|
||||
└── tests/ # Tests unitaires et E2E
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Build l'image
|
||||
docker build -f docker/Dockerfile -t dofus-manager .
|
||||
|
||||
# Lancer en production
|
||||
docker compose -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend:** React 19, TanStack Router, TanStack Query, Tailwind CSS, shadcn/ui
|
||||
- **Backend:** TanStack Start, Prisma
|
||||
- **Database:** PostgreSQL 16
|
||||
- **DevOps:** Docker, Gitea Actions
|
||||
37
biome.json
Normal file
37
biome.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"includes": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
components.json
Normal file
22
components.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "stone",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
45
docker/Dockerfile
Normal file
45
docker/Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# docker/Dockerfile
|
||||
|
||||
# ============ Build stage ============
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN pnpm prisma generate
|
||||
RUN pnpm build
|
||||
|
||||
# ============ Production stage ============
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 app
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder --chown=app:nodejs /app/.output ./.output
|
||||
COPY --from=builder --chown=app:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=app:nodejs /app/package.json ./package.json
|
||||
COPY --from=builder --chown=app:nodejs /app/prisma ./prisma
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
20
docker/docker-compose.dev.yml
Normal file
20
docker/docker-compose.dev.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=dofus_manager
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_dev_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d dofus_manager"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_dev_data:
|
||||
47
docker/docker-compose.yml
Normal file
47
docker/docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/dofus_manager
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- internal
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=dofus_manager
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d dofus_manager"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- internal
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
networks:
|
||||
internal:
|
||||
@@ -22,10 +22,10 @@ src/server/
|
||||
|
||||
```typescript
|
||||
// src/server/functions/characters.ts
|
||||
import { createServerFn } from '@tanstack/react-start/server';
|
||||
import { z } from 'zod';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { requireAuth } from '@/server/middleware/auth';
|
||||
import { createServerFn } from "@tanstack/react-start/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/server/db";
|
||||
import { requireAuth } from "@/server/middleware/auth";
|
||||
|
||||
// Schemas
|
||||
const createCharacterSchema = z.object({
|
||||
@@ -48,7 +48,7 @@ const characterFiltersSchema = z.object({
|
||||
});
|
||||
|
||||
// Functions
|
||||
export const getCharacters = createServerFn({ method: 'GET' })
|
||||
export const getCharacters = createServerFn({ method: "GET" })
|
||||
.validator((data: unknown) => characterFiltersSchema.parse(data))
|
||||
.handler(async ({ data }) => {
|
||||
const session = await requireAuth();
|
||||
@@ -57,7 +57,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
|
||||
const where = {
|
||||
account: { userId: session.userId },
|
||||
...(search && {
|
||||
name: { contains: search, mode: 'insensitive' as const },
|
||||
name: { contains: search, mode: "insensitive" as const },
|
||||
}),
|
||||
...(classIds?.length && { classId: { in: classIds } }),
|
||||
...(serverIds?.length && { serverId: { in: serverIds } }),
|
||||
@@ -70,7 +70,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
|
||||
include: { account: { select: { id: true, name: true } } },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { name: 'asc' },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
db.character.count({ where }),
|
||||
]);
|
||||
@@ -86,7 +86,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
|
||||
};
|
||||
});
|
||||
|
||||
export const createCharacter = createServerFn({ method: 'POST' })
|
||||
export const createCharacter = createServerFn({ method: "POST" })
|
||||
.validator((data: unknown) => createCharacterSchema.parse(data))
|
||||
.handler(async ({ data }) => {
|
||||
const session = await requireAuth();
|
||||
@@ -97,14 +97,16 @@ export const createCharacter = createServerFn({ method: 'POST' })
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new Error('Account not found');
|
||||
throw new Error("Account not found");
|
||||
}
|
||||
|
||||
return db.character.create({ data });
|
||||
});
|
||||
|
||||
export const bulkDeleteCharacters = createServerFn({ method: 'POST' })
|
||||
.validator((data: unknown) => z.object({ ids: z.array(z.string().cuid()) }).parse(data))
|
||||
export const bulkDeleteCharacters = createServerFn({ method: "POST" })
|
||||
.validator((data: unknown) =>
|
||||
z.object({ ids: z.array(z.string().cuid()) }).parse(data),
|
||||
)
|
||||
.handler(async ({ data }) => {
|
||||
const session = await requireAuth();
|
||||
|
||||
@@ -123,8 +125,8 @@ export const bulkDeleteCharacters = createServerFn({ method: 'POST' })
|
||||
|
||||
```typescript
|
||||
// src/server/middleware/auth.ts
|
||||
import { getWebRequest } from '@tanstack/react-start/server';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { getWebRequest } from "@tanstack/react-start/server";
|
||||
import { db } from "@/lib/server/db";
|
||||
|
||||
interface Session {
|
||||
userId: string;
|
||||
@@ -133,13 +135,14 @@ interface Session {
|
||||
|
||||
export async function requireAuth(): Promise<Session> {
|
||||
const request = getWebRequest();
|
||||
const sessionId = request.headers.get('cookie')
|
||||
?.split(';')
|
||||
.find(c => c.trim().startsWith('session='))
|
||||
?.split('=')[1];
|
||||
const sessionId = request.headers
|
||||
.get("cookie")
|
||||
?.split(";")
|
||||
.find((c) => c.trim().startsWith("session="))
|
||||
?.split("=")[1];
|
||||
|
||||
if (!sessionId) {
|
||||
throw new Error('Unauthorized');
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const session = await db.session.findUnique({
|
||||
@@ -148,7 +151,7 @@ export async function requireAuth(): Promise<Session> {
|
||||
});
|
||||
|
||||
if (!session || session.expiresAt < new Date()) {
|
||||
throw new Error('Session expired');
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -170,7 +173,7 @@ export async function getOptionalAuth(): Promise<Session | null> {
|
||||
|
||||
```typescript
|
||||
// src/lib/server/cache.ts
|
||||
import NodeCache from 'node-cache';
|
||||
import NodeCache from "node-cache";
|
||||
|
||||
// Different TTLs for different data types
|
||||
const caches = {
|
||||
@@ -196,7 +199,9 @@ export const userCache = {
|
||||
|
||||
// Helper to invalidate all cache for a user
|
||||
invalidateUser: (userId: string): void => {
|
||||
const keys = caches.user.keys().filter(k => k.startsWith(`user:${userId}:`));
|
||||
const keys = caches.user
|
||||
.keys()
|
||||
.filter((k) => k.startsWith(`user:${userId}:`));
|
||||
caches.user.del(keys);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,77 +16,84 @@ Draft
|
||||
2. Docker Compose configuration with app service and PostgreSQL 16
|
||||
3. Prisma configured and connected to PostgreSQL
|
||||
4. shadcn/ui installed with base components (Button, Input, Card, Table)
|
||||
5. ESLint + Prettier configured with recommended rules
|
||||
6. GitLab CI pipeline: build, lint, test stages
|
||||
5. Biome configured for linting and formatting
|
||||
6. Gitea Actions workflow: build, lint, test stages
|
||||
7. Dockerfile multi-stage pour production build
|
||||
8. README avec instructions de setup local
|
||||
9. Application démarre et affiche une page d'accueil "Dofus Manager"
|
||||
10. Health check endpoint `/api/health` pour Docker healthcheck
|
||||
|
||||
## Tasks / Subtasks
|
||||
|
||||
- [ ] Task 1: Initialize TanStack Start project (AC: 1)
|
||||
- [ ] Create new TanStack Start project with `pnpm create @tanstack/start`
|
||||
- [ ] Configure `tsconfig.json` with strict mode enabled
|
||||
- [ ] Configure path aliases (`@/` pointing to `src/`)
|
||||
- [ ] Verify TypeScript strict compilation works
|
||||
- [x] Task 1: Initialize TanStack Start project (AC: 1)
|
||||
- [x] Create new TanStack Start project with `pnpm create @tanstack/start`
|
||||
- [x] Configure `tsconfig.json` with strict mode enabled
|
||||
- [x] Configure path aliases (`@/` pointing to `src/`)
|
||||
- [x] Verify TypeScript strict compilation works
|
||||
|
||||
- [ ] Task 2: Setup Docker environment (AC: 2, 7)
|
||||
- [ ] Create `docker/` directory
|
||||
- [ ] Create `docker/Dockerfile` with multi-stage build (builder + runner)
|
||||
- [ ] Create `docker/docker-compose.yml` with app and postgres services
|
||||
- [ ] Create `docker/docker-compose.dev.yml` for local development (postgres only)
|
||||
- [ ] Configure PostgreSQL 16-alpine with healthcheck
|
||||
- [ ] Test database connectivity
|
||||
- [x] Task 2: Setup Docker environment (AC: 2, 7)
|
||||
- [x] Create `docker/` directory
|
||||
- [x] Create `docker/Dockerfile` with multi-stage build (builder + runner)
|
||||
- [x] Create `docker/docker-compose.yml` with app and postgres services
|
||||
- [x] Create `docker/docker-compose.dev.yml` for local development (postgres only)
|
||||
- [x] Configure PostgreSQL 16-alpine with healthcheck
|
||||
- [x] Test database connectivity
|
||||
|
||||
- [ ] Task 3: Configure Prisma ORM (AC: 3)
|
||||
- [ ] Install Prisma dependencies (`prisma`, `@prisma/client`)
|
||||
- [ ] Initialize Prisma with `pnpm prisma init`
|
||||
- [ ] Configure `prisma/schema.prisma` with PostgreSQL provider
|
||||
- [ ] Create `src/lib/server/db.ts` for Prisma client singleton
|
||||
- [ ] Create `.env.example` with DATABASE_URL template
|
||||
- [ ] Verify Prisma connects to database
|
||||
- [x] Task 3: Configure Prisma ORM (AC: 3)
|
||||
- [x] Install Prisma dependencies (`prisma`, `@prisma/client`)
|
||||
- [x] Initialize Prisma with `pnpm prisma init`
|
||||
- [x] Configure `prisma/schema.prisma` with PostgreSQL provider
|
||||
- [x] Create `src/lib/server/db.ts` for Prisma client singleton
|
||||
- [x] Create `.env.example` with DATABASE_URL template
|
||||
- [x] Verify Prisma connects to database
|
||||
|
||||
- [ ] Task 4: Install and configure shadcn/ui (AC: 4)
|
||||
- [ ] Install Tailwind CSS 4.x
|
||||
- [ ] Initialize shadcn/ui with `pnpm dlx shadcn@latest init`
|
||||
- [ ] Configure `components.json` for path aliases
|
||||
- [ ] Install base components: Button, Input, Card, Table
|
||||
- [ ] Create `src/lib/utils.ts` with `cn` utility function
|
||||
- [ ] Install Lucide React for icons
|
||||
- [x] Task 4: Install and configure shadcn/ui (AC: 4)
|
||||
- [x] Install Tailwind CSS 4.x
|
||||
- [x] Initialize shadcn/ui with `pnpm dlx shadcn@latest init`
|
||||
- [x] Configure `components.json` for path aliases
|
||||
- [x] Install base components: Button, Input, Card, Table
|
||||
- [x] Create `src/lib/utils.ts` with `cn` utility function
|
||||
- [x] Install Lucide React for icons
|
||||
|
||||
- [ ] Task 5: Configure linting and formatting (AC: 5)
|
||||
- [ ] Install Biome (as specified in tech stack, not ESLint+Prettier)
|
||||
- [ ] Create `biome.json` with recommended rules
|
||||
- [ ] Add lint and format scripts to `package.json`
|
||||
- [ ] Verify linting works on project files
|
||||
- [x] Task 5: Configure linting and formatting (AC: 5)
|
||||
- [x] Install Biome (as specified in tech stack, not ESLint+Prettier)
|
||||
- [x] Create `biome.json` with recommended rules
|
||||
- [x] Add lint and format scripts to `package.json`
|
||||
- [x] Verify linting works on project files
|
||||
|
||||
- [ ] Task 6: Setup GitLab CI/CD pipeline (AC: 6)
|
||||
- [ ] Create `.gitlab-ci.yml` with stages: test, build, deploy
|
||||
- [ ] Configure test stage: lint, typecheck, test
|
||||
- [ ] Configure build stage: Docker image build and push
|
||||
- [ ] Configure deploy stages (staging/production) with manual triggers
|
||||
- [ ] Add caching for node_modules
|
||||
- [x] Task 6: Setup Gitea Actions workflow (AC: 6)
|
||||
- [x] Create `.gitea/workflows/ci.yml`
|
||||
- [x] Configure test job: lint, typecheck, test
|
||||
- [x] Configure build job: Docker image build and push
|
||||
- [x] Configure deploy jobs (staging/production) with manual triggers
|
||||
- [x] Add caching for pnpm store
|
||||
|
||||
- [ ] Task 7: Create README documentation (AC: 8)
|
||||
- [ ] Document project overview
|
||||
- [ ] Document prerequisites (Node 20, pnpm, Docker)
|
||||
- [ ] Document local development setup steps
|
||||
- [ ] Document available npm scripts
|
||||
- [ ] Document environment variables
|
||||
- [x] Task 7: Create README documentation (AC: 8)
|
||||
- [x] Document project overview
|
||||
- [x] Document prerequisites (Node 20, pnpm, Docker)
|
||||
- [x] Document local development setup steps
|
||||
- [x] Document available npm scripts
|
||||
- [x] Document environment variables
|
||||
|
||||
- [ ] Task 8: Create home page (AC: 9)
|
||||
- [ ] Create `src/routes/index.tsx` as home page
|
||||
- [ ] Display "Dofus Manager" title
|
||||
- [ ] Add basic layout structure
|
||||
- [ ] Create `src/styles/globals.css` with Tailwind imports
|
||||
- [ ] Verify application starts and renders correctly
|
||||
- [x] Task 8: Create home page (AC: 9)
|
||||
- [x] Create `src/routes/index.tsx` as home page
|
||||
- [x] Display "Dofus Manager" title
|
||||
- [x] Add basic layout structure
|
||||
- [x] Create `src/styles/globals.css` with Tailwind imports
|
||||
- [x] Verify application starts and renders correctly
|
||||
|
||||
- [ ] Task 9: Final verification
|
||||
- [ ] Run `pnpm dev` and verify app starts
|
||||
- [ ] Run `pnpm lint` and verify no errors
|
||||
- [ ] Run `pnpm typecheck` and verify no errors
|
||||
- [ ] Test Docker build locally
|
||||
- [ ] Verify PostgreSQL connection via Prisma
|
||||
- [x] Task 9: Create health check endpoint (AC: 10)
|
||||
- [x] Create `src/routes/api/health.ts` server function
|
||||
- [x] Return JSON `{ status: "ok", timestamp: Date }`
|
||||
- [x] Optionally check database connectivity
|
||||
- [x] Verify endpoint responds at `GET /api/health`
|
||||
|
||||
- [x] Task 10: Final verification
|
||||
- [x] Run `pnpm dev` and verify app starts
|
||||
- [x] Run `pnpm lint` and verify no errors
|
||||
- [x] Run `pnpm typecheck` and verify no errors
|
||||
- [x] Test Docker build locally
|
||||
- [x] Verify PostgreSQL connection via Prisma
|
||||
|
||||
## Dev Notes
|
||||
|
||||
@@ -118,7 +125,7 @@ Draft
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- GitLab CI
|
||||
- Gitea Actions
|
||||
|
||||
**Dev Tools:**
|
||||
|
||||
@@ -131,9 +138,13 @@ Draft
|
||||
|
||||
```
|
||||
dofus-manager/
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ └── ci.yml
|
||||
├── docker/
|
||||
│ ├── Dockerfile
|
||||
│ └── docker-compose.yml
|
||||
│ ├── docker-compose.yml
|
||||
│ └── docker-compose.dev.yml
|
||||
├── prisma/
|
||||
│ ├── schema.prisma
|
||||
│ └── migrations/
|
||||
@@ -156,7 +167,9 @@ dofus-manager/
|
||||
│ │ └── logger.ts
|
||||
│ ├── routes/
|
||||
│ │ ├── __root.tsx
|
||||
│ │ └── index.tsx
|
||||
│ │ ├── index.tsx
|
||||
│ │ └── api/
|
||||
│ │ └── health.ts
|
||||
│ ├── styles/
|
||||
│ │ └── globals.css
|
||||
│ └── app.tsx
|
||||
@@ -187,22 +200,66 @@ dofus-manager/
|
||||
- PostgreSQL 16-alpine with healthcheck
|
||||
- Traefik labels for reverse proxy (production)
|
||||
|
||||
### GitLab CI/CD [Source: architecture/14-deployment-architecture.md#gitlab-cicd-pipeline]
|
||||
### Gitea Actions [Adapted from architecture]
|
||||
|
||||
**Stages:** test, build, deploy
|
||||
**Workflow file:** `.gitea/workflows/ci.yml`
|
||||
|
||||
**Test stage:**
|
||||
**Jobs:** test, build, deploy
|
||||
|
||||
- image: node:20-alpine
|
||||
- Commands: pnpm lint, pnpm typecheck, pnpm test
|
||||
- Cache node_modules
|
||||
**Test job:**
|
||||
|
||||
**Build stage:**
|
||||
- runs-on: ubuntu-latest
|
||||
- Steps: checkout, setup pnpm, setup node, install, lint, typecheck, test
|
||||
- Cache pnpm store
|
||||
|
||||
- image: docker:24
|
||||
- Build and push Docker image to registry
|
||||
**Build job:**
|
||||
|
||||
- needs: test
|
||||
- Build and push Docker image
|
||||
- Only on main/develop branches
|
||||
|
||||
**Exemple de workflow:**
|
||||
|
||||
```yaml
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "pnpm"
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test
|
||||
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
tags: registry.example.com/dofus-manager:${{ github.sha }}
|
||||
```
|
||||
|
||||
### Environment Variables [Source: architecture/13-development-workflow.md#environment-variables]
|
||||
|
||||
```bash
|
||||
@@ -234,9 +291,45 @@ SESSION_SECRET="your-secret-key-min-32-chars"
|
||||
- Types/Interfaces: PascalCase
|
||||
- Constants: SCREAMING_SNAKE_CASE
|
||||
|
||||
### Important Discrepancy Note
|
||||
### Health Check Endpoint [Source: architecture/19-monitoring-observability.md]
|
||||
|
||||
AC #5 specifies "ESLint + Prettier" but the architecture documents (3-technology-stack.md) specify **Biome** for linting and formatting. Recommend following the architecture document and using Biome instead, as it's the project standard.
|
||||
**Endpoint:** `GET /api/health`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"timestamp": "2026-01-19T10:00:00.000Z",
|
||||
"database": "connected"
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation avec TanStack Start:**
|
||||
|
||||
```typescript
|
||||
// src/routes/api/health.ts
|
||||
import { createAPIFileRoute } from "@tanstack/start/api";
|
||||
import { prisma } from "@/lib/server/db";
|
||||
|
||||
export const Route = createAPIFileRoute("/api/health")({
|
||||
GET: async () => {
|
||||
let dbStatus = "disconnected";
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
dbStatus = "connected";
|
||||
} catch {
|
||||
dbStatus = "error";
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
status: "ok",
|
||||
timestamp: new Date().toISOString(),
|
||||
database: dbStatus,
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -264,9 +357,10 @@ AC #5 specifies "ESLint + Prettier" but the architecture documents (3-technology
|
||||
|
||||
## Change Log
|
||||
|
||||
| Date | Version | Description | Author |
|
||||
| ---------- | ------- | ---------------------- | -------- |
|
||||
| 2026-01-19 | 1.0 | Initial story creation | SM Agent |
|
||||
| Date | Version | Description | Author |
|
||||
| ---------- | ------- | --------------------------------------------- | -------- |
|
||||
| 2026-01-19 | 1.0 | Initial story creation | SM Agent |
|
||||
| 2026-01-19 | 1.1 | Gitea Actions, Biome, Health endpoint ajoutés | SM Agent |
|
||||
|
||||
---
|
||||
|
||||
|
||||
65
package.json
Normal file
65
package.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "dofus-manager2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev --port 3000",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"format": "biome format --write .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.2.0",
|
||||
"@prisma/client": "^7.2.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-devtools": "^0.7.0",
|
||||
"@tanstack/react-router": "^1.132.0",
|
||||
"@tanstack/react-router-devtools": "^1.132.0",
|
||||
"@tanstack/react-router-ssr-query": "^1.131.7",
|
||||
"@tanstack/react-start": "^1.132.0",
|
||||
"@tanstack/router-plugin": "^1.132.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.561.0",
|
||||
"nitro": "npm:nitro-nightly@latest",
|
||||
"pg": "^8.17.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"vite-tsconfig-paths": "^6.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.11",
|
||||
"@tanstack/devtools-vite": "^0.3.11",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsdom": "^27.0.0",
|
||||
"prisma": "^7.2.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
"vitest": "^3.0.5",
|
||||
"web-vitals": "^5.1.0"
|
||||
}
|
||||
}
|
||||
6175
pnpm-lock.yaml
generated
Normal file
6175
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@prisma/engines'
|
||||
- esbuild
|
||||
- prisma
|
||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
187
prisma/migrations/20260119121104_init/migration.sql
Normal file
187
prisma/migrations/20260119121104_init/migration.sql
Normal file
@@ -0,0 +1,187 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TeamType" AS ENUM ('MAIN', 'SECONDARY', 'CUSTOM');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProgressionType" AS ENUM ('QUEST', 'DUNGEON', 'ACHIEVEMENT', 'DOFUS');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sessions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "accounts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "characters" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"level" INTEGER NOT NULL DEFAULT 1,
|
||||
"class_id" INTEGER NOT NULL,
|
||||
"class_name" TEXT NOT NULL,
|
||||
"server_id" INTEGER NOT NULL,
|
||||
"server_name" TEXT NOT NULL,
|
||||
"account_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "characters_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "teams" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" "TeamType" NOT NULL DEFAULT 'CUSTOM',
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "teams_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "team_members" (
|
||||
"id" TEXT NOT NULL,
|
||||
"team_id" TEXT NOT NULL,
|
||||
"character_id" TEXT NOT NULL,
|
||||
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "team_members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "progressions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" "ProgressionType" NOT NULL,
|
||||
"category" TEXT NOT NULL,
|
||||
"dofusdb_id" INTEGER,
|
||||
|
||||
CONSTRAINT "progressions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "character_progressions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"character_id" TEXT NOT NULL,
|
||||
"progression_id" TEXT NOT NULL,
|
||||
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "character_progressions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "accounts_user_id_idx" ON "accounts"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "accounts_user_id_name_key" ON "accounts"("user_id", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "characters_account_id_idx" ON "characters"("account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "characters_class_id_idx" ON "characters"("class_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "characters_server_id_idx" ON "characters"("server_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "characters_level_idx" ON "characters"("level");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "characters_account_id_name_key" ON "characters"("account_id", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "teams_user_id_idx" ON "teams"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "teams_user_id_name_key" ON "teams"("user_id", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "team_members_team_id_idx" ON "team_members"("team_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "team_members_character_id_idx" ON "team_members"("character_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "team_members_team_id_character_id_key" ON "team_members"("team_id", "character_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "progressions_dofusdb_id_key" ON "progressions"("dofusdb_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "progressions_type_idx" ON "progressions"("type");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "progressions_category_idx" ON "progressions"("category");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "character_progressions_character_id_idx" ON "character_progressions"("character_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "character_progressions_progression_id_idx" ON "character_progressions"("progression_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "character_progressions_completed_idx" ON "character_progressions"("completed");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "character_progressions_character_id_progression_id_key" ON "character_progressions"("character_id", "progression_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "characters" ADD CONSTRAINT "characters_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_character_id_fkey" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_progressions" ADD CONSTRAINT "character_progressions_character_id_fkey" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "character_progressions" ADD CONSTRAINT "character_progressions_progression_id_fkey" FOREIGN KEY ("progression_id") REFERENCES "progressions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
140
prisma/schema.prisma
Normal file
140
prisma/schema.prisma
Normal file
@@ -0,0 +1,140 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
teams Team[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("sessions")
|
||||
}
|
||||
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
characters Character[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
@@map("accounts")
|
||||
}
|
||||
|
||||
model Character {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
level Int @default(1)
|
||||
classId Int @map("class_id")
|
||||
className String @map("class_name")
|
||||
serverId Int @map("server_id")
|
||||
serverName String @map("server_name")
|
||||
accountId String @map("account_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
progressions CharacterProgression[]
|
||||
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
|
||||
teamMembers TeamMember[]
|
||||
|
||||
@@unique([accountId, name])
|
||||
@@index([accountId])
|
||||
@@index([classId])
|
||||
@@index([serverId])
|
||||
@@index([level])
|
||||
@@map("characters")
|
||||
}
|
||||
|
||||
model Team {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
type TeamType @default(CUSTOM)
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
members TeamMember[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
@@map("teams")
|
||||
}
|
||||
|
||||
model TeamMember {
|
||||
id String @id @default(cuid())
|
||||
teamId String @map("team_id")
|
||||
characterId String @map("character_id")
|
||||
joinedAt DateTime @default(now()) @map("joined_at")
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([teamId, characterId])
|
||||
@@index([teamId])
|
||||
@@index([characterId])
|
||||
@@map("team_members")
|
||||
}
|
||||
|
||||
model Progression {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
type ProgressionType
|
||||
category String
|
||||
dofusDbId Int? @unique @map("dofusdb_id")
|
||||
characterProgressions CharacterProgression[]
|
||||
|
||||
@@index([type])
|
||||
@@index([category])
|
||||
@@map("progressions")
|
||||
}
|
||||
|
||||
model CharacterProgression {
|
||||
id String @id @default(cuid())
|
||||
characterId String @map("character_id")
|
||||
progressionId String @map("progression_id")
|
||||
completed Boolean @default(false)
|
||||
completedAt DateTime? @map("completed_at")
|
||||
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
|
||||
progression Progression @relation(fields: [progressionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([characterId, progressionId])
|
||||
@@index([characterId])
|
||||
@@index([progressionId])
|
||||
@@index([completed])
|
||||
@@map("character_progressions")
|
||||
}
|
||||
|
||||
enum TeamType {
|
||||
MAIN
|
||||
SECONDARY
|
||||
CUSTOM
|
||||
}
|
||||
|
||||
enum ProgressionType {
|
||||
QUEST
|
||||
DUNGEON
|
||||
ACHIEVEMENT
|
||||
DOFUS
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/logo192.png
Normal file
BIN
public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
BIN
public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "TanStack App",
|
||||
"name": "Create TanStack App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
BIN
public/tanstack-circle-logo.png
Normal file
BIN
public/tanstack-circle-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 259 KiB |
1
public/tanstack-word-logo-white.svg
Normal file
1
public/tanstack-word-logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
38
src/App.css
Normal file
38
src/App.css
Normal file
@@ -0,0 +1,38 @@
|
||||
.App {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.App-logo {
|
||||
height: 40vmin;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.App-logo {
|
||||
animation: App-logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.App-header {
|
||||
background-color: #282c34;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: calc(10px + 2vmin);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.App-link {
|
||||
color: #61dafb;
|
||||
}
|
||||
|
||||
@keyframes App-logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -5,75 +5,75 @@
|
||||
|
||||
/* Updated to Dofus Manager dark gaming theme */
|
||||
:root {
|
||||
--background: #0f172a;
|
||||
--foreground: #f8fafc;
|
||||
--card: #1e293b;
|
||||
--card-foreground: #f8fafc;
|
||||
--popover: #1e293b;
|
||||
--popover-foreground: #f8fafc;
|
||||
--primary: #60a5fa;
|
||||
--primary-foreground: #0f172a;
|
||||
--secondary: #334155;
|
||||
--secondary-foreground: #f8fafc;
|
||||
--muted: #334155;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: #334155;
|
||||
--accent-foreground: #f8fafc;
|
||||
--destructive: #ef4444;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.147 0.004 49.25);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.147 0.004 49.25);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||
--primary: oklch(0.216 0.006 56.043);
|
||||
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--secondary: oklch(0.97 0.001 106.424);
|
||||
--secondary-foreground: oklch(0.216 0.006 56.043);
|
||||
--muted: oklch(0.97 0.001 106.424);
|
||||
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||
--accent: oklch(0.97 0.001 106.424);
|
||||
--accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: #f8fafc;
|
||||
--border: #334155;
|
||||
--input: #334155;
|
||||
--ring: #60a5fa;
|
||||
--chart-1: #60a5fa;
|
||||
--chart-2: #4ade80;
|
||||
--chart-3: #f87171;
|
||||
--chart-4: #fbbf24;
|
||||
--chart-5: #a78bfa;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: #1e293b;
|
||||
--sidebar-foreground: #f8fafc;
|
||||
--sidebar-primary: #60a5fa;
|
||||
--sidebar-primary-foreground: #0f172a;
|
||||
--sidebar-accent: #334155;
|
||||
--sidebar-accent-foreground: #f8fafc;
|
||||
--sidebar-border: #334155;
|
||||
--sidebar-ring: #60a5fa;
|
||||
--border: oklch(0.923 0.003 48.717);
|
||||
--input: oklch(0.923 0.003 48.717);
|
||||
--ring: oklch(0.709 0.01 56.259);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0.001 106.423);
|
||||
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||
--sidebar-primary: oklch(0.216 0.006 56.043);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.97 0.001 106.424);
|
||||
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||
--sidebar-ring: oklch(0.709 0.01 56.259);
|
||||
}
|
||||
|
||||
/* Keep dark class same as root for dark-first approach */
|
||||
.dark {
|
||||
--background: #0f172a;
|
||||
--foreground: #f8fafc;
|
||||
--card: #1e293b;
|
||||
--card-foreground: #f8fafc;
|
||||
--popover: #1e293b;
|
||||
--popover-foreground: #f8fafc;
|
||||
--primary: #60a5fa;
|
||||
--primary-foreground: #0f172a;
|
||||
--secondary: #334155;
|
||||
--secondary-foreground: #f8fafc;
|
||||
--muted: #334155;
|
||||
--muted-foreground: #94a3b8;
|
||||
--accent: #334155;
|
||||
--accent-foreground: #f8fafc;
|
||||
--destructive: #ef4444;
|
||||
--background: oklch(0.147 0.004 49.25);
|
||||
--foreground: oklch(0.985 0.001 106.423);
|
||||
--card: oklch(0.216 0.006 56.043);
|
||||
--card-foreground: oklch(0.985 0.001 106.423);
|
||||
--popover: oklch(0.216 0.006 56.043);
|
||||
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||
--primary: oklch(0.923 0.003 48.717);
|
||||
--primary-foreground: oklch(0.216 0.006 56.043);
|
||||
--secondary: oklch(0.268 0.007 34.298);
|
||||
--secondary-foreground: oklch(0.985 0.001 106.423);
|
||||
--muted: oklch(0.268 0.007 34.298);
|
||||
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||
--accent: oklch(0.268 0.007 34.298);
|
||||
--accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: #f8fafc;
|
||||
--border: #334155;
|
||||
--input: #334155;
|
||||
--ring: #60a5fa;
|
||||
--chart-1: #60a5fa;
|
||||
--chart-2: #4ade80;
|
||||
--chart-3: #f87171;
|
||||
--chart-4: #fbbf24;
|
||||
--chart-5: #a78bfa;
|
||||
--sidebar: #1e293b;
|
||||
--sidebar-foreground: #f8fafc;
|
||||
--sidebar-primary: #60a5fa;
|
||||
--sidebar-primary-foreground: #0f172a;
|
||||
--sidebar-accent: #334155;
|
||||
--sidebar-accent-foreground: #f8fafc;
|
||||
--sidebar-border: #334155;
|
||||
--sidebar-ring: #60a5fa;
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.553 0.013 58.071);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.216 0.006 56.043);
|
||||
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.268 0.007 34.298);
|
||||
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.553 0.013 58.071);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -115,6 +115,9 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
18
src/components/Header.css
Normal file
18
src/components/Header.css
Normal file
@@ -0,0 +1,18 @@
|
||||
.header {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background-color: #fff;
|
||||
color: #000;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 0 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
27
src/components/Header.tsx
Normal file
27
src/components/Header.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Link } from '@tanstack/react-router';
|
||||
|
||||
import './Header.css';
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="header">
|
||||
<nav className="nav">
|
||||
<div className="nav-item">
|
||||
<Link to="/">Home</Link>
|
||||
</div>
|
||||
|
||||
<div className="px-2 font-bold">
|
||||
<Link to="/demo/start/server-funcs">Start - Server Functions</Link>
|
||||
</div>
|
||||
|
||||
<div className="px-2 font-bold">
|
||||
<Link to="/demo/start/api-request">Start - API Request</Link>
|
||||
</div>
|
||||
|
||||
<div className="px-2 font-bold">
|
||||
<Link to="/demo/start/ssr">Start - SSR Demos</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,437 +1,437 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowUpDown,
|
||||
} from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Plus,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Sample data
|
||||
const characters = [
|
||||
{
|
||||
id: 1,
|
||||
nom: "Krosmaster",
|
||||
classe: "Cra",
|
||||
niveau: 200,
|
||||
serveur: "Imagiro",
|
||||
compte: "Compte1",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nom: "TankMaster",
|
||||
classe: "Iop",
|
||||
niveau: 200,
|
||||
serveur: "Imagiro",
|
||||
compte: "Compte1",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nom: "MoneyMaker",
|
||||
classe: "Enu",
|
||||
niveau: 200,
|
||||
serveur: "Imagiro",
|
||||
compte: "Compte2",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
nom: "HealBot",
|
||||
classe: "Eni",
|
||||
niveau: 195,
|
||||
serveur: "Tylezia",
|
||||
compte: "Compte2",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
nom: "ShadowKill",
|
||||
classe: "Sram",
|
||||
niveau: 200,
|
||||
serveur: "Draconiros",
|
||||
compte: "Compte3",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
nom: "TimeWarp",
|
||||
classe: "Elio",
|
||||
niveau: 180,
|
||||
serveur: "Imagiro",
|
||||
compte: "Compte1",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
nom: "ArrowStorm",
|
||||
classe: "Cra",
|
||||
niveau: 200,
|
||||
serveur: "Tylezia",
|
||||
compte: "Compte4",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
nom: "BerserkerX",
|
||||
classe: "Iop",
|
||||
niveau: 175,
|
||||
serveur: "Draconiros",
|
||||
compte: "Compte3",
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
nom: 'Krosmaster',
|
||||
classe: 'Cra',
|
||||
niveau: 200,
|
||||
serveur: 'Imagiro',
|
||||
compte: 'Compte1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
nom: 'TankMaster',
|
||||
classe: 'Iop',
|
||||
niveau: 200,
|
||||
serveur: 'Imagiro',
|
||||
compte: 'Compte1',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
nom: 'MoneyMaker',
|
||||
classe: 'Enu',
|
||||
niveau: 200,
|
||||
serveur: 'Imagiro',
|
||||
compte: 'Compte2',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
nom: 'HealBot',
|
||||
classe: 'Eni',
|
||||
niveau: 195,
|
||||
serveur: 'Tylezia',
|
||||
compte: 'Compte2',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
nom: 'ShadowKill',
|
||||
classe: 'Sram',
|
||||
niveau: 200,
|
||||
serveur: 'Draconiros',
|
||||
compte: 'Compte3',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
nom: 'TimeWarp',
|
||||
classe: 'Elio',
|
||||
niveau: 180,
|
||||
serveur: 'Imagiro',
|
||||
compte: 'Compte1',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
nom: 'ArrowStorm',
|
||||
classe: 'Cra',
|
||||
niveau: 200,
|
||||
serveur: 'Tylezia',
|
||||
compte: 'Compte4',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
nom: 'BerserkerX',
|
||||
classe: 'Iop',
|
||||
niveau: 175,
|
||||
serveur: 'Draconiros',
|
||||
compte: 'Compte3',
|
||||
},
|
||||
];
|
||||
|
||||
const classes = [
|
||||
{ name: "Cra", count: 12 },
|
||||
{ name: "Iop", count: 8 },
|
||||
{ name: "Enu", count: 6 },
|
||||
{ name: "Eni", count: 5 },
|
||||
{ name: "Elio", count: 4 },
|
||||
{ name: "Sram", count: 6 },
|
||||
{ name: 'Cra', count: 12 },
|
||||
{ name: 'Iop', count: 8 },
|
||||
{ name: 'Enu', count: 6 },
|
||||
{ name: 'Eni', count: 5 },
|
||||
{ name: 'Elio', count: 4 },
|
||||
{ name: 'Sram', count: 6 },
|
||||
];
|
||||
|
||||
const serveurs = ["Imagiro", "Tylezia", "Draconiros"];
|
||||
const serveurs = ['Imagiro', 'Tylezia', 'Draconiros'];
|
||||
|
||||
export function CharacterList() {
|
||||
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
||||
const [classeOpen, setClasseOpen] = React.useState(true);
|
||||
const [serveurOpen, setServeurOpen] = React.useState(true);
|
||||
const [progressionOpen, setProgressionOpen] = React.useState(true);
|
||||
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
||||
const [classeOpen, setClasseOpen] = React.useState(true);
|
||||
const [serveurOpen, setServeurOpen] = React.useState(true);
|
||||
const [progressionOpen, setProgressionOpen] = React.useState(true);
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.length === characters.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(characters.map((c) => c.id));
|
||||
}
|
||||
};
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.length === characters.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(characters.map((c) => c.id));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Filter Sidebar */}
|
||||
<aside className="w-[250px] border-r border-[#334155] bg-[#1E293B] p-4 sticky top-0 h-screen overflow-y-auto">
|
||||
<h2 className="text-[#F8FAFC] font-semibold text-lg mb-6">Filtres</h2>
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
{/* Filter Sidebar */}
|
||||
<aside className="w-[250px] border-r border-[#334155] bg-[#1E293B] p-4 sticky top-0 h-screen overflow-y-auto">
|
||||
<h2 className="text-[#F8FAFC] font-semibold text-lg mb-6">Filtres</h2>
|
||||
|
||||
{/* Classe Section */}
|
||||
<Collapsible open={classeOpen} onOpenChange={setClasseOpen}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full mb-3">
|
||||
<span className="text-xs uppercase tracking-wider text-[#94A3B8]">
|
||||
Classe
|
||||
</span>
|
||||
{classeOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-[#94A3B8]" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-[#94A3B8]" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 mb-6">
|
||||
{classes.map((classe) => (
|
||||
<label
|
||||
key={classe.name}
|
||||
className="flex items-center gap-3 cursor-pointer group"
|
||||
>
|
||||
<Checkbox className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]" />
|
||||
<span className="text-[#F8FAFC] text-sm group-hover:text-[#60A5FA] transition-colors">
|
||||
{classe.name}
|
||||
</span>
|
||||
<span className="text-[#64748B] text-sm ml-auto">
|
||||
({classe.count})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{/* Classe Section */}
|
||||
<Collapsible open={classeOpen} onOpenChange={setClasseOpen}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full mb-3">
|
||||
<span className="text-xs uppercase tracking-wider text-[#94A3B8]">
|
||||
Classe
|
||||
</span>
|
||||
{classeOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-[#94A3B8]" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-[#94A3B8]" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 mb-6">
|
||||
{classes.map((classe) => (
|
||||
<label
|
||||
key={classe.name}
|
||||
className="flex items-center gap-3 cursor-pointer group"
|
||||
>
|
||||
<Checkbox className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]" />
|
||||
<span className="text-[#F8FAFC] text-sm group-hover:text-[#60A5FA] transition-colors">
|
||||
{classe.name}
|
||||
</span>
|
||||
<span className="text-[#64748B] text-sm ml-auto">
|
||||
({classe.count})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Serveur Section */}
|
||||
<Collapsible open={serveurOpen} onOpenChange={setServeurOpen}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full mb-3">
|
||||
<span className="text-xs uppercase tracking-wider text-[#94A3B8]">
|
||||
Serveur
|
||||
</span>
|
||||
{serveurOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-[#94A3B8]" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-[#94A3B8]" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 mb-6">
|
||||
{serveurs.map((serveur) => (
|
||||
<label
|
||||
key={serveur}
|
||||
className="flex items-center gap-3 cursor-pointer group"
|
||||
>
|
||||
<Checkbox className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]" />
|
||||
<span className="text-[#F8FAFC] text-sm group-hover:text-[#60A5FA] transition-colors">
|
||||
{serveur}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{/* Serveur Section */}
|
||||
<Collapsible open={serveurOpen} onOpenChange={setServeurOpen}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full mb-3">
|
||||
<span className="text-xs uppercase tracking-wider text-[#94A3B8]">
|
||||
Serveur
|
||||
</span>
|
||||
{serveurOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-[#94A3B8]" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-[#94A3B8]" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 mb-6">
|
||||
{serveurs.map((serveur) => (
|
||||
<label
|
||||
key={serveur}
|
||||
className="flex items-center gap-3 cursor-pointer group"
|
||||
>
|
||||
<Checkbox className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]" />
|
||||
<span className="text-[#F8FAFC] text-sm group-hover:text-[#60A5FA] transition-colors">
|
||||
{serveur}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Progression Section */}
|
||||
<Collapsible open={progressionOpen} onOpenChange={setProgressionOpen}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full mb-3">
|
||||
<span className="text-xs uppercase tracking-wider text-[#94A3B8]">
|
||||
Progression
|
||||
</span>
|
||||
{progressionOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-[#94A3B8]" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-[#94A3B8]" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="text-xs text-[#94A3B8] mb-2 block">Type</label>
|
||||
<Select>
|
||||
<SelectTrigger className="w-full bg-[#0F172A] border-[#475569] text-[#F8FAFC] h-9 rounded-[6px]">
|
||||
<SelectValue placeholder="Sélectionner..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1E293B] border-[#475569]">
|
||||
<SelectItem
|
||||
value="dofus"
|
||||
className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"
|
||||
>
|
||||
Dofus
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="donjons"
|
||||
className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"
|
||||
>
|
||||
Donjons
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="recherches"
|
||||
className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"
|
||||
>
|
||||
Recherchés
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<RadioGroup defaultValue="a-fait" className="gap-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<RadioGroupItem
|
||||
value="a-fait"
|
||||
className="border-[#475569] text-[#60A5FA]"
|
||||
/>
|
||||
<span className="text-[#F8FAFC] text-sm">A fait</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<RadioGroupItem
|
||||
value="na-pas-fait"
|
||||
className="border-[#475569] text-[#60A5FA]"
|
||||
/>
|
||||
<span className="text-[#F8FAFC] text-sm">{"N'a pas fait"}</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
{/* Progression Section */}
|
||||
<Collapsible open={progressionOpen} onOpenChange={setProgressionOpen}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full mb-3">
|
||||
<span className="text-xs uppercase tracking-wider text-[#94A3B8]">
|
||||
Progression
|
||||
</span>
|
||||
{progressionOpen ? (
|
||||
<ChevronUp className="h-4 w-4 text-[#94A3B8]" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-[#94A3B8]" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="text-xs text-[#94A3B8] mb-2 block">Type</label>
|
||||
<Select>
|
||||
<SelectTrigger className="w-full bg-[#0F172A] border-[#475569] text-[#F8FAFC] h-9 rounded-[6px]">
|
||||
<SelectValue placeholder="Sélectionner..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[#1E293B] border-[#475569]">
|
||||
<SelectItem
|
||||
value="dofus"
|
||||
className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"
|
||||
>
|
||||
Dofus
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="donjons"
|
||||
className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"
|
||||
>
|
||||
Donjons
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="recherches"
|
||||
className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"
|
||||
>
|
||||
Recherchés
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<RadioGroup defaultValue="a-fait" className="gap-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<RadioGroupItem
|
||||
value="a-fait"
|
||||
className="border-[#475569] text-[#60A5FA]"
|
||||
/>
|
||||
<span className="text-[#F8FAFC] text-sm">A fait</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<RadioGroupItem
|
||||
value="na-pas-fait"
|
||||
className="border-[#475569] text-[#60A5FA]"
|
||||
/>
|
||||
<span className="text-[#F8FAFC] text-sm">{"N'a pas fait"}</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Reset Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-4 border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
</aside>
|
||||
{/* Reset Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-4 border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
Réinitialiser
|
||||
</Button>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#334155] bg-[#0F172A]">
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#64748B]" />
|
||||
<Input
|
||||
placeholder="Rechercher..."
|
||||
className="pl-10 bg-[#1E293B] border-[#475569] text-[#F8FAFC] placeholder:text-[#64748B] h-9 rounded-[6px]"
|
||||
/>
|
||||
</div>
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#334155] bg-[#0F172A]">
|
||||
<div className="relative w-72">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#64748B]" />
|
||||
<Input
|
||||
placeholder="Rechercher..."
|
||||
className="pl-10 bg-[#1E293B] border-[#475569] text-[#F8FAFC] placeholder:text-[#64748B] h-9 rounded-[6px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{selectedIds.length > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-[#94A3B8]">
|
||||
{selectedIds.length} sélectionné
|
||||
{selectedIds.length > 1 ? "s" : ""}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-[#475569] text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
Bulk Actions
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-[#1E293B] border-[#475569]">
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Exporter
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Supprimer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<Button className="bg-[#60A5FA] text-[#0F172A] hover:bg-[#3B82F6] rounded-[6px]">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{selectedIds.length > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-[#94A3B8]">
|
||||
{selectedIds.length} sélectionné
|
||||
{selectedIds.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-[#475569] text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
Bulk Actions
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-[#1E293B] border-[#475569]">
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Exporter
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Supprimer
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
<Button className="bg-[#60A5FA] text-[#0F172A] hover:bg-[#3B82F6] rounded-[6px]">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Ajouter
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-[#1E293B] z-10">
|
||||
<TableRow className="border-[#334155] hover:bg-[#1E293B]">
|
||||
<TableHead className="w-[40px] text-[#94A3B8]">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === characters.length &&
|
||||
characters.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">
|
||||
<button className="flex items-center gap-1 hover:text-[#F8FAFC] transition-colors">
|
||||
Nom
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">Classe</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">
|
||||
<button className="flex items-center gap-1 hover:text-[#F8FAFC] transition-colors">
|
||||
Niveau
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">Serveur</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">Compte</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{characters.map((character) => {
|
||||
const isSelected = selectedIds.includes(character.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={character.id}
|
||||
className={cn(
|
||||
"border-[#334155] h-12 transition-colors",
|
||||
isSelected
|
||||
? "bg-[#1E293B] border-l-2 border-l-[#60A5FA]"
|
||||
: "hover:bg-[#1E293B]/50",
|
||||
)}
|
||||
>
|
||||
<TableCell className="w-[40px]">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(character.id)}
|
||||
className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC] font-medium">
|
||||
{character.nom}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC]">
|
||||
{character.classe}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC]">
|
||||
{character.niveau}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC]">
|
||||
{character.serveur}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC]">
|
||||
{character.compte}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-[#1E293B] z-10">
|
||||
<TableRow className="border-[#334155] hover:bg-[#1E293B]">
|
||||
<TableHead className="w-[40px] text-[#94A3B8]">
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedIds.length === characters.length &&
|
||||
characters.length > 0
|
||||
}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">
|
||||
<button className="flex items-center gap-1 hover:text-[#F8FAFC] transition-colors">
|
||||
Nom
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">Classe</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">
|
||||
<button className="flex items-center gap-1 hover:text-[#F8FAFC] transition-colors">
|
||||
Niveau
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">Serveur</TableHead>
|
||||
<TableHead className="text-[#94A3B8]">Compte</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{characters.map((character) => {
|
||||
const isSelected = selectedIds.includes(character.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={character.id}
|
||||
className={cn(
|
||||
'border-[#334155] h-12 transition-colors',
|
||||
isSelected
|
||||
? 'bg-[#1E293B] border-l-2 border-l-[#60A5FA]'
|
||||
: 'hover:bg-[#1E293B]/50',
|
||||
)}
|
||||
>
|
||||
<TableCell className="w-[40px]">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(character.id)}
|
||||
className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC] font-medium">
|
||||
{character.nom}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC]">
|
||||
{character.classe}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC]">
|
||||
{character.niveau}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC]">
|
||||
{character.serveur}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#F8FAFC]">
|
||||
{character.compte}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-[#334155] bg-[#0F172A]">
|
||||
<span className="text-sm text-[#94A3B8]">Showing 1-8 of 64</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
{"<"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#60A5FA] bg-[#60A5FA]/10 text-[#60A5FA] hover:bg-[#60A5FA]/20 rounded-[6px]"
|
||||
>
|
||||
1
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
2
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
3
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
{">"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Pagination Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-[#334155] bg-[#0F172A]">
|
||||
<span className="text-sm text-[#94A3B8]">Showing 1-8 of 64</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
{'<'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#60A5FA] bg-[#60A5FA]/10 text-[#60A5FA] hover:bg-[#60A5FA]/20 rounded-[6px]"
|
||||
>
|
||||
1
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
2
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
3
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
|
||||
>
|
||||
{'>'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
progressionName: string;
|
||||
incompleteCount: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
progressionName: string;
|
||||
incompleteCount: number;
|
||||
}
|
||||
|
||||
export function ConfirmationModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
progressionName,
|
||||
incompleteCount,
|
||||
open,
|
||||
onOpenChange,
|
||||
progressionName,
|
||||
incompleteCount,
|
||||
}: ConfirmationModalProps) {
|
||||
const handleConfirm = () => {
|
||||
// Do NOT implement actual update logic
|
||||
onOpenChange(false);
|
||||
};
|
||||
const handleConfirm = () => {
|
||||
// Do NOT implement actual update logic
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[400px] rounded-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmer la mise à jour</DialogTitle>
|
||||
<DialogDescription>
|
||||
Marquer {progressionName} comme fait pour {incompleteCount}{" "}
|
||||
personnages ?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-md"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} className="rounded-md">
|
||||
Confirmer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[400px] rounded-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Confirmer la mise à jour</DialogTitle>
|
||||
<DialogDescription>
|
||||
Marquer {progressionName} comme fait pour {incompleteCount}{' '}
|
||||
personnages ?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-md"
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button onClick={handleConfirm} className="rounded-md">
|
||||
Confirmer
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Moon, Sun, ChevronRight } from "lucide-react";
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
import { ChevronRight, Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const routeLabels: Record<string, string> = {
|
||||
"/": "Dashboard",
|
||||
"/characters": "Personnages",
|
||||
"/accounts": "Comptes",
|
||||
"/teams": "Teams",
|
||||
"/settings": "Paramètres",
|
||||
'/': 'Dashboard',
|
||||
'/characters': 'Personnages',
|
||||
'/accounts': 'Comptes',
|
||||
'/teams': 'Teams',
|
||||
'/settings': 'Paramètres',
|
||||
};
|
||||
|
||||
interface AppHeaderProps {
|
||||
theme: "dark" | "light";
|
||||
onToggleTheme: () => void;
|
||||
theme: 'dark' | 'light';
|
||||
onToggleTheme: () => void;
|
||||
}
|
||||
|
||||
export function AppHeader({ theme, onToggleTheme }: AppHeaderProps) {
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// Generate breadcrumb from pathname
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const breadcrumbs =
|
||||
segments.length === 0
|
||||
? [{ label: "Dashboard", href: "/" }]
|
||||
: segments.map((segment, index) => {
|
||||
const href = "/" + segments.slice(0, index + 1).join("/");
|
||||
const label =
|
||||
routeLabels[href] ||
|
||||
segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
return { label, href };
|
||||
});
|
||||
// Generate breadcrumb from pathname
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const breadcrumbs =
|
||||
segments.length === 0
|
||||
? [{ label: 'Dashboard', href: '/' }]
|
||||
: segments.map((segment, index) => {
|
||||
const href = '/' + segments.slice(0, index + 1).join('/');
|
||||
const label =
|
||||
routeLabels[href] ||
|
||||
segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
return { label, href };
|
||||
});
|
||||
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b border-border bg-card px-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1 text-sm">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.href} className="flex items-center gap-1">
|
||||
{index > 0 && (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
index === breadcrumbs.length - 1
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{crumb.label}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
return (
|
||||
<header className="flex h-16 items-center justify-between border-b border-border bg-card px-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-1 text-sm">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={crumb.href} className="flex items-center gap-1">
|
||||
{index > 0 && (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span
|
||||
className={
|
||||
index === breadcrumbs.length - 1
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}
|
||||
>
|
||||
{crumb.label}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleTheme}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
{/* Theme toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggleTheme}
|
||||
className="h-9 w-9"
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import type React from "react";
|
||||
import type React from 'react';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||
import { AppHeader } from "@/components/layout/app-header";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppHeader } from '@/components/layout/app-header';
|
||||
import { AppSidebar } from '@/components/layout/app-sidebar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [theme, setTheme] = useState<"dark" | "light">("dark");
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
|
||||
|
||||
// Apply theme class to html element
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === "light") {
|
||||
root.classList.add("light");
|
||||
} else {
|
||||
root.classList.remove("light");
|
||||
}
|
||||
}, [theme]);
|
||||
// Apply theme class to html element
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (theme === 'light') {
|
||||
root.classList.add('light');
|
||||
} else {
|
||||
root.classList.remove('light');
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
|
||||
};
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppSidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppSidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-screen flex-col transition-all duration-200",
|
||||
sidebarCollapsed ? "pl-16" : "pl-60",
|
||||
)}
|
||||
>
|
||||
<AppHeader theme={theme} onToggleTheme={toggleTheme} />
|
||||
{/* Main content area */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-screen flex-col transition-all duration-200',
|
||||
sidebarCollapsed ? 'pl-16' : 'pl-60',
|
||||
)}
|
||||
>
|
||||
<AppHeader theme={theme} onToggleTheme={toggleTheme} />
|
||||
|
||||
{/* Scrollable main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="mx-auto max-w-[1280px] p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{/* Scrollable main content */}
|
||||
<main className="flex-1 overflow-auto">
|
||||
<div className="mx-auto max-w-[1280px] p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,107 +1,107 @@
|
||||
import { Link, useLocation } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link, useLocation } from '@tanstack/react-router';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
Home,
|
||||
Settings,
|
||||
Swords,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Home,
|
||||
Users,
|
||||
Folder,
|
||||
Swords,
|
||||
Settings,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const navItems = [
|
||||
{ icon: Home, label: "Dashboard", href: "/" },
|
||||
{ icon: Users, label: "Personnages", href: "/characters" },
|
||||
{ icon: Folder, label: "Comptes", href: "/accounts" },
|
||||
{ icon: Swords, label: "Teams", href: "/teams" },
|
||||
{ icon: Settings, label: "Paramètres", href: "/settings" },
|
||||
{ icon: Home, label: 'Dashboard', href: '/' },
|
||||
{ icon: Users, label: 'Personnages', href: '/characters' },
|
||||
{ icon: Folder, label: 'Comptes', href: '/accounts' },
|
||||
{ icon: Swords, label: 'Teams', href: '/teams' },
|
||||
{ icon: Settings, label: 'Paramètres', href: '/settings' },
|
||||
];
|
||||
|
||||
interface AppSidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function AppSidebar({ collapsed, onToggle }: AppSidebarProps) {
|
||||
const { pathname } = useLocation();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200",
|
||||
collapsed ? "w-16" : "w-60",
|
||||
)}
|
||||
>
|
||||
{/* Header with logo and toggle */}
|
||||
<div className="flex h-16 items-center justify-between border-b border-sidebar-border px-3">
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
|
||||
DOFUS MANAGER
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent",
|
||||
collapsed && "mx-auto",
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
</Button>
|
||||
</div>
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200',
|
||||
collapsed ? 'w-16' : 'w-60',
|
||||
)}
|
||||
>
|
||||
{/* Header with logo and toggle */}
|
||||
<div className="flex h-16 items-center justify-between border-b border-sidebar-border px-3">
|
||||
{!collapsed && (
|
||||
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
|
||||
DOFUS MANAGER
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent',
|
||||
collapsed && 'mx-auto',
|
||||
)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
<span className="sr-only">Toggle sidebar</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 p-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 p-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
|
||||
const linkContent = (
|
||||
<Link
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
const linkContent = (
|
||||
<Link
|
||||
to={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-sidebar-primary text-sidebar-primary-foreground'
|
||||
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="font-medium">
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.href}>
|
||||
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="font-medium">
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={item.href}>{linkContent}</div>;
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
return <div key={item.href}>{linkContent}</div>;
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,273 +1,273 @@
|
||||
import type React from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
Users,
|
||||
Swords,
|
||||
Coins,
|
||||
BarChart3,
|
||||
Plus,
|
||||
ChevronDown,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
ChevronDown,
|
||||
Coins,
|
||||
FolderOpen,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Swords,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import type React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
// Custom progress bar with color support
|
||||
function ColoredProgress({
|
||||
value,
|
||||
color,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
value: number;
|
||||
color: "success" | "warning" | "info";
|
||||
value: number;
|
||||
color: 'success' | 'warning' | 'info';
|
||||
}) {
|
||||
const colorClasses = {
|
||||
success: "bg-[#4ADE80]",
|
||||
warning: "bg-[#FBBF24]",
|
||||
info: "bg-[#60A5FA]",
|
||||
};
|
||||
const colorClasses = {
|
||||
success: 'bg-[#4ADE80]',
|
||||
warning: 'bg-[#FBBF24]',
|
||||
info: 'bg-[#60A5FA]',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-[#334155]">
|
||||
<div
|
||||
className={`h-full transition-all ${colorClasses[color]}`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative h-2 w-full overflow-hidden rounded-full bg-[#334155]">
|
||||
<div
|
||||
className={`h-full transition-all ${colorClasses[color]}`}
|
||||
style={{ width: `${value}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Stat card component with hover effect
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
title,
|
||||
mainStat,
|
||||
secondary,
|
||||
linkText,
|
||||
children,
|
||||
className = "",
|
||||
icon: Icon,
|
||||
title,
|
||||
mainStat,
|
||||
secondary,
|
||||
linkText,
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
mainStat?: string;
|
||||
secondary?: React.ReactNode;
|
||||
linkText: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
mainStat?: string;
|
||||
secondary?: React.ReactNode;
|
||||
linkText: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
className={`border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01] ${className}`}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10">
|
||||
<Icon className="size-5 text-[#60A5FA]" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-semibold text-[#F8FAFC]">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{mainStat && (
|
||||
<p className="text-2xl font-bold text-[#F8FAFC]">{mainStat}</p>
|
||||
)}
|
||||
{secondary && <div className="text-sm text-[#94A3B8]">{secondary}</div>}
|
||||
{children}
|
||||
</CardContent>
|
||||
<CardFooter className="pt-2">
|
||||
<button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline">
|
||||
{linkText}
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card
|
||||
className={`border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01] ${className}`}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10">
|
||||
<Icon className="size-5 text-[#60A5FA]" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-semibold text-[#F8FAFC]">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
{mainStat && (
|
||||
<p className="text-2xl font-bold text-[#F8FAFC]">{mainStat}</p>
|
||||
)}
|
||||
{secondary && <div className="text-sm text-[#94A3B8]">{secondary}</div>}
|
||||
{children}
|
||||
</CardContent>
|
||||
<CardFooter className="pt-2">
|
||||
<button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline">
|
||||
{linkText}
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
// Mock data
|
||||
const currencies = [
|
||||
{ name: "Doplons", amount: "12,450" },
|
||||
{ name: "Orichor", amount: "3,200" },
|
||||
{ name: "Kamas glace", amount: "8,100" },
|
||||
{ name: "Nuggets", amount: "2,340" },
|
||||
];
|
||||
// Mock data
|
||||
const currencies = [
|
||||
{ name: 'Doplons', amount: '12,450' },
|
||||
{ name: 'Orichor', amount: '3,200' },
|
||||
{ name: 'Kamas glace', amount: '8,100' },
|
||||
{ name: 'Nuggets', amount: '2,340' },
|
||||
];
|
||||
|
||||
const progressions = [
|
||||
{ label: "Dofus", value: 72, color: "success" as const },
|
||||
{ label: "Donjons", value: 45, color: "warning" as const },
|
||||
{ label: "Recherchés", value: 61, color: "info" as const },
|
||||
];
|
||||
const progressions = [
|
||||
{ label: 'Dofus', value: 72, color: 'success' as const },
|
||||
{ label: 'Donjons', value: 45, color: 'warning' as const },
|
||||
{ label: 'Recherchés', value: 61, color: 'info' as const },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0F172A] p-6">
|
||||
{/* Header */}
|
||||
<header className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-[32px] font-bold text-[#F8FAFC]">DASHBOARD</h1>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90">
|
||||
<Plus className="size-4" />
|
||||
Nouveau
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="border-[#334155] bg-[#1E293B]">
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Personnage
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Compte
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Team
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0F172A] p-6">
|
||||
{/* Header */}
|
||||
<header className="mb-8 flex items-center justify-between">
|
||||
<h1 className="text-[32px] font-bold text-[#F8FAFC]">DASHBOARD</h1>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90">
|
||||
<Plus className="size-4" />
|
||||
Nouveau
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="border-[#334155] bg-[#1E293B]">
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Personnage
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Compte
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
|
||||
Team
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</header>
|
||||
|
||||
{/* Widget Grid */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{/* Comptes Card */}
|
||||
<StatCard
|
||||
icon={FolderOpen}
|
||||
title="Comptes"
|
||||
mainStat="12 comptes"
|
||||
secondary="45,230 ogrines"
|
||||
linkText="Voir tout"
|
||||
/>
|
||||
{/* Widget Grid */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{/* Comptes Card */}
|
||||
<StatCard
|
||||
icon={FolderOpen}
|
||||
title="Comptes"
|
||||
mainStat="12 comptes"
|
||||
secondary="45,230 ogrines"
|
||||
linkText="Voir tout"
|
||||
/>
|
||||
|
||||
{/* Personnages Card */}
|
||||
<StatCard
|
||||
icon={Users}
|
||||
title="Personnages"
|
||||
mainStat="64 personnages"
|
||||
secondary="Niv. moy: 198"
|
||||
linkText="Voir tout"
|
||||
/>
|
||||
{/* Personnages Card */}
|
||||
<StatCard
|
||||
icon={Users}
|
||||
title="Personnages"
|
||||
mainStat="64 personnages"
|
||||
secondary="Niv. moy: 198"
|
||||
linkText="Voir tout"
|
||||
/>
|
||||
|
||||
{/* Teams Card */}
|
||||
<StatCard
|
||||
icon={Swords}
|
||||
title="Teams"
|
||||
mainStat="3 actives"
|
||||
secondary={
|
||||
<div className="space-y-1">
|
||||
<span>87% complete</span>
|
||||
<ColoredProgress value={87} color="info" />
|
||||
</div>
|
||||
}
|
||||
linkText="Voir tout"
|
||||
/>
|
||||
{/* Teams Card */}
|
||||
<StatCard
|
||||
icon={Swords}
|
||||
title="Teams"
|
||||
mainStat="3 actives"
|
||||
secondary={
|
||||
<div className="space-y-1">
|
||||
<span>87% complete</span>
|
||||
<ColoredProgress value={87} color="info" />
|
||||
</div>
|
||||
}
|
||||
linkText="Voir tout"
|
||||
/>
|
||||
|
||||
{/* Monnaies Card - spans 2 columns on desktop */}
|
||||
<Card className="border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01] xl:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10">
|
||||
<Coins className="size-5 text-[#60A5FA]" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-semibold text-[#F8FAFC]">
|
||||
Monnaies
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{currencies.map((currency) => (
|
||||
<div key={currency.name} className="space-y-1">
|
||||
<p className="text-sm text-[#94A3B8]">{currency.name}</p>
|
||||
<p className="text-xl font-semibold text-[#F8FAFC]">
|
||||
{currency.amount}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-2">
|
||||
<button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline">
|
||||
Détail par compte
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
{/* Monnaies Card - spans 2 columns on desktop */}
|
||||
<Card className="border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01] xl:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10">
|
||||
<Coins className="size-5 text-[#60A5FA]" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-semibold text-[#F8FAFC]">
|
||||
Monnaies
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{currencies.map((currency) => (
|
||||
<div key={currency.name} className="space-y-1">
|
||||
<p className="text-sm text-[#94A3B8]">{currency.name}</p>
|
||||
<p className="text-xl font-semibold text-[#F8FAFC]">
|
||||
{currency.amount}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-2">
|
||||
<button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline">
|
||||
Détail par compte
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Progressions Card */}
|
||||
<Card className="border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01]">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10">
|
||||
<BarChart3 className="size-5 text-[#60A5FA]" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-semibold text-[#F8FAFC]">
|
||||
Progressions
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{progressions.map((prog) => (
|
||||
<div key={prog.label} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[#F8FAFC]">{prog.label}</span>
|
||||
<span className="text-[#94A3B8]">{prog.value}%</span>
|
||||
</div>
|
||||
<ColoredProgress value={prog.value} color={prog.color} />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
<CardFooter className="pt-2">
|
||||
<button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline">
|
||||
Bulk Update
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Progressions Card */}
|
||||
<Card className="border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01]">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10">
|
||||
<BarChart3 className="size-5 text-[#60A5FA]" />
|
||||
</div>
|
||||
<CardTitle className="text-base font-semibold text-[#F8FAFC]">
|
||||
Progressions
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{progressions.map((prog) => (
|
||||
<div key={prog.label} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-[#F8FAFC]">{prog.label}</span>
|
||||
<span className="text-[#94A3B8]">{prog.value}%</span>
|
||||
</div>
|
||||
<ColoredProgress value={prog.value} color={prog.color} />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
<CardFooter className="pt-2">
|
||||
<button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline">
|
||||
Bulk Update
|
||||
<ArrowRight className="size-4" />
|
||||
</button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions Section */}
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-[#F8FAFC]">
|
||||
Actions Rapides
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90">
|
||||
<Plus className="size-4" />
|
||||
Personnage
|
||||
</Button>
|
||||
<Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90">
|
||||
<Plus className="size-4" />
|
||||
Team
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="gap-2 rounded-[6px] border-[#334155] bg-[#1E293B] text-[#F8FAFC] hover:bg-[#334155]"
|
||||
>
|
||||
<BarChart3 className="size-4" />
|
||||
Bulk Progressions
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="gap-2 rounded-[6px] border-[#334155] bg-[#1E293B] text-[#F8FAFC] hover:bg-[#334155]"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Sync DofusDB
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
{/* Quick Actions Section */}
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-4 text-lg font-semibold text-[#F8FAFC]">
|
||||
Actions Rapides
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90">
|
||||
<Plus className="size-4" />
|
||||
Personnage
|
||||
</Button>
|
||||
<Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90">
|
||||
<Plus className="size-4" />
|
||||
Team
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="gap-2 rounded-[6px] border-[#334155] bg-[#1E293B] text-[#F8FAFC] hover:bg-[#334155]"
|
||||
>
|
||||
<BarChart3 className="size-4" />
|
||||
Bulk Progressions
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="gap-2 rounded-[6px] border-[#334155] bg-[#1E293B] text-[#F8FAFC] hover:bg-[#334155]"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Sync DofusDB
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Check } from "lucide-react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProgressionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
completed: boolean;
|
||||
completedDate?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
completed: boolean;
|
||||
completedDate?: string;
|
||||
}
|
||||
|
||||
interface ProgressionSectionProps {
|
||||
title: string;
|
||||
items: ProgressionItem[];
|
||||
filter: "all" | "done" | "not-done";
|
||||
title: string;
|
||||
items: ProgressionItem[];
|
||||
filter: 'all' | 'done' | 'not-done';
|
||||
}
|
||||
|
||||
export function ProgressionSection({
|
||||
title,
|
||||
items,
|
||||
filter,
|
||||
title,
|
||||
items,
|
||||
filter,
|
||||
}: ProgressionSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [localItems, setLocalItems] = useState(items);
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [localItems, setLocalItems] = useState(items);
|
||||
|
||||
const filteredItems = localItems.filter((item) => {
|
||||
if (filter === "done") return item.completed;
|
||||
if (filter === "not-done") return !item.completed;
|
||||
return true;
|
||||
});
|
||||
const filteredItems = localItems.filter((item) => {
|
||||
if (filter === 'done') return item.completed;
|
||||
if (filter === 'not-done') return !item.completed;
|
||||
return true;
|
||||
});
|
||||
|
||||
const completedCount = localItems.filter((item) => item.completed).length;
|
||||
const totalCount = localItems.length;
|
||||
const completedCount = localItems.filter((item) => item.completed).length;
|
||||
const totalCount = localItems.length;
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
setLocalItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
completed: !item.completed,
|
||||
completedDate: !item.completed
|
||||
? new Date().toISOString().split("T")[0]
|
||||
: undefined,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
const handleToggle = (id: string) => {
|
||||
setLocalItems((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === id
|
||||
? {
|
||||
...item,
|
||||
completed: !item.completed,
|
||||
completedDate: !item.completed
|
||||
? new Date().toISOString().split('T')[0]
|
||||
: undefined,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
if (filteredItems.length === 0) return null;
|
||||
if (filteredItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="space-y-2">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 py-2 text-left hover:bg-secondary/50 rounded-md px-2 transition-colors">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium text-foreground">{title}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({completedCount}/{totalCount})
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-1 pl-6">
|
||||
{filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 py-2 px-2 rounded-md hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={item.completed}
|
||||
onCheckedChange={() => handleToggle(item.id)}
|
||||
className="border-muted-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className={cn(
|
||||
"flex-1 text-sm cursor-pointer",
|
||||
item.completed ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</label>
|
||||
{item.completed ? (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Check className="h-3.5 w-3.5" style={{ color: "#4ADE80" }} />
|
||||
<span style={{ color: "#4ADE80" }}>{item.completedDate}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="space-y-2">
|
||||
<CollapsibleTrigger className="flex w-full items-center gap-2 py-2 text-left hover:bg-secondary/50 rounded-md px-2 transition-colors">
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="font-medium text-foreground">{title}</span>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
({completedCount}/{totalCount})
|
||||
</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-1 pl-6">
|
||||
{filteredItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center gap-3 py-2 px-2 rounded-md hover:bg-secondary/30 transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
id={item.id}
|
||||
checked={item.completed}
|
||||
onCheckedChange={() => handleToggle(item.id)}
|
||||
className="border-muted-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<label
|
||||
htmlFor={item.id}
|
||||
className={cn(
|
||||
'flex-1 text-sm cursor-pointer',
|
||||
item.completed ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</label>
|
||||
{item.completed ? (
|
||||
<div className="flex items-center gap-1.5 text-sm">
|
||||
<Check className="h-3.5 w-3.5" style={{ color: '#4ADE80' }} />
|
||||
<span style={{ color: '#4ADE80' }}>{item.completedDate}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">—</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,225 +1,225 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
CheckCircle,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ConfirmationModal } from '@/components/confirmation-modal';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ConfirmationModal } from "@/components/confirmation-modal";
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
// Mock data
|
||||
const teamMembers = [
|
||||
{ id: 1, name: "Krosmaster", completed: true, date: "2026-01-10" },
|
||||
{ id: 2, name: "TankMaster", completed: true, date: "2026-01-10" },
|
||||
{ id: 3, name: "HealBot", completed: false, date: null },
|
||||
{ id: 4, name: "SramKiller", completed: false, date: null },
|
||||
{ id: 5, name: "Eniripsa", completed: true, date: "2026-01-09" },
|
||||
{ id: 6, name: "Sacrieur", completed: true, date: "2026-01-08" },
|
||||
{ id: 7, name: "Pandawa", completed: true, date: "2026-01-10" },
|
||||
{ id: 8, name: "Eliotrope", completed: true, date: "2026-01-07" },
|
||||
{ id: 1, name: 'Krosmaster', completed: true, date: '2026-01-10' },
|
||||
{ id: 2, name: 'TankMaster', completed: true, date: '2026-01-10' },
|
||||
{ id: 3, name: 'HealBot', completed: false, date: null },
|
||||
{ id: 4, name: 'SramKiller', completed: false, date: null },
|
||||
{ id: 5, name: 'Eniripsa', completed: true, date: '2026-01-09' },
|
||||
{ id: 6, name: 'Sacrieur', completed: true, date: '2026-01-08' },
|
||||
{ id: 7, name: 'Pandawa', completed: true, date: '2026-01-10' },
|
||||
{ id: 8, name: 'Eliotrope', completed: true, date: '2026-01-07' },
|
||||
];
|
||||
|
||||
const progressions = [
|
||||
{ id: "dofus-turquoise", name: "Dofus Turquoise" },
|
||||
{ id: "donjon-bethel", name: "Donjon Bethel" },
|
||||
{ id: "quete-ebene", name: "Quête Ébène" },
|
||||
{ id: "succes-dimension", name: "Succès Dimension" },
|
||||
{ id: 'dofus-turquoise', name: 'Dofus Turquoise' },
|
||||
{ id: 'donjon-bethel', name: 'Donjon Bethel' },
|
||||
{ id: 'quete-ebene', name: 'Quête Ébène' },
|
||||
{ id: 'succes-dimension', name: 'Succès Dimension' },
|
||||
];
|
||||
|
||||
export default function TeamDetailPage() {
|
||||
const [selectedProgression, setSelectedProgression] =
|
||||
useState("dofus-turquoise");
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedProgression, setSelectedProgression] =
|
||||
useState('dofus-turquoise');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const completedCount = teamMembers.filter((m) => m.completed).length;
|
||||
const totalCount = teamMembers.length;
|
||||
const incompleteCount = totalCount - completedCount;
|
||||
const progressPercentage = Math.round((completedCount / totalCount) * 100);
|
||||
const completedCount = teamMembers.filter((m) => m.completed).length;
|
||||
const totalCount = teamMembers.length;
|
||||
const incompleteCount = totalCount - completedCount;
|
||||
const progressPercentage = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
const selectedProgressionName =
|
||||
progressions.find((p) => p.id === selectedProgression)?.name || "";
|
||||
const selectedProgressionName =
|
||||
progressions.find((p) => p.id === selectedProgression)?.name || '';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header with Breadcrumb and Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span className="hover:text-foreground cursor-pointer">Teams</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="text-foreground">Main Team</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit team</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete team</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="min-h-screen bg-background p-6">
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Header with Breadcrumb and Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<span className="hover:text-foreground cursor-pointer">Teams</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<span className="text-foreground">Main Team</span>
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">Edit team</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete team</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Team Info Card */}
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="p-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">MAIN TEAM</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Type: Main</p>
|
||||
<div className="mt-4 flex items-center gap-3 text-sm">
|
||||
<span>{totalCount} membres</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#4ADE80]/20 text-[#4ADE80] hover:bg-[#4ADE80]/30"
|
||||
>
|
||||
✅ Active
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span>{totalCount} comptes différents</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Team Info Card */}
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="p-6">
|
||||
<h1 className="text-2xl font-bold tracking-tight">MAIN TEAM</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Type: Main</p>
|
||||
<div className="mt-4 flex items-center gap-3 text-sm">
|
||||
<span>{totalCount} membres</span>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-[#4ADE80]/20 text-[#4ADE80] hover:bg-[#4ADE80]/30"
|
||||
>
|
||||
✅ Active
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">•</span>
|
||||
<span>{totalCount} comptes différents</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="statut-progressions" className="w-full">
|
||||
<TabsList className="w-full justify-start rounded-lg bg-card">
|
||||
<TabsTrigger value="membres" className="rounded-md">
|
||||
Membres
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="statut-progressions" className="rounded-md">
|
||||
Statut Progressions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="statut-progressions" className="w-full">
|
||||
<TabsList className="w-full justify-start rounded-lg bg-card">
|
||||
<TabsTrigger value="membres" className="rounded-md">
|
||||
Membres
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="statut-progressions" className="rounded-md">
|
||||
Statut Progressions
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Membres Tab - Placeholder */}
|
||||
<TabsContent value="membres" className="mt-4">
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground">
|
||||
Liste des membres à venir...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
{/* Membres Tab - Placeholder */}
|
||||
<TabsContent value="membres" className="mt-4">
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="p-6">
|
||||
<p className="text-muted-foreground">
|
||||
Liste des membres à venir...
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Statut Progressions Tab */}
|
||||
<TabsContent value="statut-progressions" className="mt-4 space-y-4">
|
||||
{/* Progression Selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium">Progression:</label>
|
||||
<Select
|
||||
value={selectedProgression}
|
||||
onValueChange={setSelectedProgression}
|
||||
>
|
||||
<SelectTrigger className="w-[220px] rounded-md">
|
||||
<SelectValue placeholder="Sélectionner une progression" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{progressions.map((prog) => (
|
||||
<SelectItem key={prog.id} value={prog.id}>
|
||||
{prog.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Statut Progressions Tab */}
|
||||
<TabsContent value="statut-progressions" className="mt-4 space-y-4">
|
||||
{/* Progression Selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm font-medium">Progression:</label>
|
||||
<Select
|
||||
value={selectedProgression}
|
||||
onValueChange={setSelectedProgression}
|
||||
>
|
||||
<SelectTrigger className="w-[220px] rounded-md">
|
||||
<SelectValue placeholder="Sélectionner une progression" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{progressions.map((prog) => (
|
||||
<SelectItem key={prog.id} value={prog.id}>
|
||||
{prog.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Progress Summary */}
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
{progressPercentage}% ({completedCount}/{totalCount})
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progressPercentage}
|
||||
className="h-4 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
disabled={incompleteCount === 0}
|
||||
className="rounded-md"
|
||||
>
|
||||
Marquer tous comme fait
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Progress Summary */}
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium">
|
||||
{progressPercentage}% ({completedCount}/{totalCount})
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={progressPercentage}
|
||||
className="h-4 rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
disabled={incompleteCount === 0}
|
||||
className="rounded-md"
|
||||
>
|
||||
Marquer tous comme fait
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Status Table */}
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-10">Perso</TableHead>
|
||||
<TableHead className="h-10">Statut</TableHead>
|
||||
<TableHead className="h-10">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{teamMembers.map((member) => (
|
||||
<TableRow key={member.id} className="h-10">
|
||||
<TableCell className="py-2 font-medium">
|
||||
{member.name}
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
{member.completed ? (
|
||||
<CheckCircle className="h-5 w-5 text-[#4ADE80]" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-[#F87171]" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-2 text-muted-foreground">
|
||||
{member.date || "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
{/* Status Table */}
|
||||
<Card className="rounded-lg">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="h-10">Perso</TableHead>
|
||||
<TableHead className="h-10">Statut</TableHead>
|
||||
<TableHead className="h-10">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{teamMembers.map((member) => (
|
||||
<TableRow key={member.id} className="h-10">
|
||||
<TableCell className="py-2 font-medium">
|
||||
{member.name}
|
||||
</TableCell>
|
||||
<TableCell className="py-2">
|
||||
{member.completed ? (
|
||||
<CheckCircle className="h-5 w-5 text-[#4ADE80]" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-[#F87171]" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-2 text-muted-foreground">
|
||||
{member.date || '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmationModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
progressionName={selectedProgressionName}
|
||||
incompleteCount={incompleteCount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
{/* Confirmation Modal */}
|
||||
<ConfirmationModal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
progressionName={selectedProgressionName}
|
||||
incompleteCount={incompleteCount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
46
src/components/ui/badge.tsx
Normal file
46
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
62
src/components/ui/button.tsx
Normal file
62
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', 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-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
30
src/components/ui/checkbox.tsx
Normal file
30
src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
33
src/components/ui/collapsible.tsx
Normal file
33
src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
143
src/components/ui/dialog.tsx
Normal file
143
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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 left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none 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 left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
31
src/components/ui/progress.tsx
Normal file
31
src/components/ui/progress.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
45
src/components/ui/radio-group.tsx
Normal file
45
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
188
src/components/ui/select.tsx
Normal file
188
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
114
src/components/ui/table.tsx
Normal file
114
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import type * 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(
|
||||
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
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 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'caption'>) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn('text-muted-foreground mt-4 text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
64
src/components/ui/tabs.tsx
Normal file
64
src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
59
src/components/ui/tooltip.tsx
Normal file
59
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 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 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
28
src/lib/server/db.ts
Normal file
28
src/lib/server/db.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { Pool } from 'pg';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
function createPrismaClient() {
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
const adapter = new PrismaPg(pool);
|
||||
|
||||
return new PrismaClient({
|
||||
adapter,
|
||||
log:
|
||||
process.env.NODE_ENV === 'development'
|
||||
? ['query', 'error', 'warn']
|
||||
: ['error'],
|
||||
});
|
||||
}
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
12
src/logo.svg
Normal file
12
src/logo.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
241
src/routeTree.gen.ts
Normal file
241
src/routeTree.gen.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root';
|
||||
import { Route as ApiHealthRouteImport } from './routes/api/health';
|
||||
import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names';
|
||||
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request';
|
||||
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs';
|
||||
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only';
|
||||
import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr';
|
||||
import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.index';
|
||||
import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode';
|
||||
import { Route as IndexRouteImport } from './routes/index';
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const ApiHealthRoute = ApiHealthRouteImport.update({
|
||||
id: '/api/health',
|
||||
path: '/api/health',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({
|
||||
id: '/demo/start/server-funcs',
|
||||
path: '/demo/start/server-funcs',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DemoStartApiRequestRoute = DemoStartApiRequestRouteImport.update({
|
||||
id: '/demo/start/api-request',
|
||||
path: '/demo/start/api-request',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DemoApiNamesRoute = DemoApiNamesRouteImport.update({
|
||||
id: '/demo/api/names',
|
||||
path: '/demo/api/names',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DemoStartSsrIndexRoute = DemoStartSsrIndexRouteImport.update({
|
||||
id: '/demo/start/ssr/',
|
||||
path: '/demo/start/ssr/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DemoStartSsrSpaModeRoute = DemoStartSsrSpaModeRouteImport.update({
|
||||
id: '/demo/start/ssr/spa-mode',
|
||||
path: '/demo/start/ssr/spa-mode',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DemoStartSsrFullSsrRoute = DemoStartSsrFullSsrRouteImport.update({
|
||||
id: '/demo/start/ssr/full-ssr',
|
||||
path: '/demo/start/ssr/full-ssr',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({
|
||||
id: '/demo/start/ssr/data-only',
|
||||
path: '/demo/start/ssr/data-only',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute;
|
||||
'/api/health': typeof ApiHealthRoute;
|
||||
'/demo/api/names': typeof DemoApiNamesRoute;
|
||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute;
|
||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute;
|
||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute;
|
||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute;
|
||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute;
|
||||
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute;
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute;
|
||||
'/api/health': typeof ApiHealthRoute;
|
||||
'/demo/api/names': typeof DemoApiNamesRoute;
|
||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute;
|
||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute;
|
||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute;
|
||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute;
|
||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute;
|
||||
'/demo/start/ssr': typeof DemoStartSsrIndexRoute;
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport;
|
||||
'/': typeof IndexRoute;
|
||||
'/api/health': typeof ApiHealthRoute;
|
||||
'/demo/api/names': typeof DemoApiNamesRoute;
|
||||
'/demo/start/api-request': typeof DemoStartApiRequestRoute;
|
||||
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute;
|
||||
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute;
|
||||
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute;
|
||||
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute;
|
||||
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute;
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath;
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/api/health'
|
||||
| '/demo/api/names'
|
||||
| '/demo/start/api-request'
|
||||
| '/demo/start/server-funcs'
|
||||
| '/demo/start/ssr/data-only'
|
||||
| '/demo/start/ssr/full-ssr'
|
||||
| '/demo/start/ssr/spa-mode'
|
||||
| '/demo/start/ssr/';
|
||||
fileRoutesByTo: FileRoutesByTo;
|
||||
to:
|
||||
| '/'
|
||||
| '/api/health'
|
||||
| '/demo/api/names'
|
||||
| '/demo/start/api-request'
|
||||
| '/demo/start/server-funcs'
|
||||
| '/demo/start/ssr/data-only'
|
||||
| '/demo/start/ssr/full-ssr'
|
||||
| '/demo/start/ssr/spa-mode'
|
||||
| '/demo/start/ssr';
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/api/health'
|
||||
| '/demo/api/names'
|
||||
| '/demo/start/api-request'
|
||||
| '/demo/start/server-funcs'
|
||||
| '/demo/start/ssr/data-only'
|
||||
| '/demo/start/ssr/full-ssr'
|
||||
| '/demo/start/ssr/spa-mode'
|
||||
| '/demo/start/ssr/';
|
||||
fileRoutesById: FileRoutesById;
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute;
|
||||
ApiHealthRoute: typeof ApiHealthRoute;
|
||||
DemoApiNamesRoute: typeof DemoApiNamesRoute;
|
||||
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute;
|
||||
DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute;
|
||||
DemoStartSsrDataOnlyRoute: typeof DemoStartSsrDataOnlyRoute;
|
||||
DemoStartSsrFullSsrRoute: typeof DemoStartSsrFullSsrRoute;
|
||||
DemoStartSsrSpaModeRoute: typeof DemoStartSsrSpaModeRoute;
|
||||
DemoStartSsrIndexRoute: typeof DemoStartSsrIndexRoute;
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/';
|
||||
path: '/';
|
||||
fullPath: '/';
|
||||
preLoaderRoute: typeof IndexRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
'/api/health': {
|
||||
id: '/api/health';
|
||||
path: '/api/health';
|
||||
fullPath: '/api/health';
|
||||
preLoaderRoute: typeof ApiHealthRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
'/demo/start/server-funcs': {
|
||||
id: '/demo/start/server-funcs';
|
||||
path: '/demo/start/server-funcs';
|
||||
fullPath: '/demo/start/server-funcs';
|
||||
preLoaderRoute: typeof DemoStartServerFuncsRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
'/demo/start/api-request': {
|
||||
id: '/demo/start/api-request';
|
||||
path: '/demo/start/api-request';
|
||||
fullPath: '/demo/start/api-request';
|
||||
preLoaderRoute: typeof DemoStartApiRequestRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
'/demo/api/names': {
|
||||
id: '/demo/api/names';
|
||||
path: '/demo/api/names';
|
||||
fullPath: '/demo/api/names';
|
||||
preLoaderRoute: typeof DemoApiNamesRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
'/demo/start/ssr/': {
|
||||
id: '/demo/start/ssr/';
|
||||
path: '/demo/start/ssr';
|
||||
fullPath: '/demo/start/ssr/';
|
||||
preLoaderRoute: typeof DemoStartSsrIndexRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
'/demo/start/ssr/spa-mode': {
|
||||
id: '/demo/start/ssr/spa-mode';
|
||||
path: '/demo/start/ssr/spa-mode';
|
||||
fullPath: '/demo/start/ssr/spa-mode';
|
||||
preLoaderRoute: typeof DemoStartSsrSpaModeRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
'/demo/start/ssr/full-ssr': {
|
||||
id: '/demo/start/ssr/full-ssr';
|
||||
path: '/demo/start/ssr/full-ssr';
|
||||
fullPath: '/demo/start/ssr/full-ssr';
|
||||
preLoaderRoute: typeof DemoStartSsrFullSsrRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
'/demo/start/ssr/data-only': {
|
||||
id: '/demo/start/ssr/data-only';
|
||||
path: '/demo/start/ssr/data-only';
|
||||
fullPath: '/demo/start/ssr/data-only';
|
||||
preLoaderRoute: typeof DemoStartSsrDataOnlyRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ApiHealthRoute: ApiHealthRoute,
|
||||
DemoApiNamesRoute: DemoApiNamesRoute,
|
||||
DemoStartApiRequestRoute: DemoStartApiRequestRoute,
|
||||
DemoStartServerFuncsRoute: DemoStartServerFuncsRoute,
|
||||
DemoStartSsrDataOnlyRoute: DemoStartSsrDataOnlyRoute,
|
||||
DemoStartSsrFullSsrRoute: DemoStartSsrFullSsrRoute,
|
||||
DemoStartSsrSpaModeRoute: DemoStartSsrSpaModeRoute,
|
||||
DemoStartSsrIndexRoute: DemoStartSsrIndexRoute,
|
||||
};
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>();
|
||||
|
||||
import type { createStart } from '@tanstack/react-start';
|
||||
import type { getRouter } from './router.tsx';
|
||||
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true;
|
||||
router: Awaited<ReturnType<typeof getRouter>>;
|
||||
}
|
||||
}
|
||||
17
src/router.tsx
Normal file
17
src/router.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createRouter } from '@tanstack/react-router';
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from './routeTree.gen';
|
||||
|
||||
// Create a new router instance
|
||||
export const getRouter = () => {
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {},
|
||||
|
||||
scrollRestoration: true,
|
||||
defaultPreloadStaleTime: 0,
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,5 +1,55 @@
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools';
|
||||
import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router';
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return <AppShell>{children}</AppShell>;
|
||||
import appCss from '../app/globals.css?url';
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: 'utf-8',
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{
|
||||
title: 'Dofus Manager',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: appCss,
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
shellComponent: RootDocument,
|
||||
});
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'Tanstack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
30
src/routes/api/health.tsx
Normal file
30
src/routes/api/health.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { createServerFn } from '@tanstack/react-start';
|
||||
import { prisma } from '@/lib/server/db';
|
||||
|
||||
const checkHealth = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
let dbStatus = 'disconnected';
|
||||
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
dbStatus = 'connected';
|
||||
} catch {
|
||||
dbStatus = 'error';
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: dbStatus,
|
||||
};
|
||||
});
|
||||
|
||||
export const Route = createFileRoute('/api/health')({
|
||||
loader: () => checkHealth(),
|
||||
component: HealthPage,
|
||||
});
|
||||
|
||||
function HealthPage() {
|
||||
const data = Route.useLoaderData();
|
||||
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||
}
|
||||
87
src/routes/index.tsx
Normal file
87
src/routes/index.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { Server, Swords, User, Users } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
|
||||
export const Route = createFileRoute('/')({ component: HomePage });
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-16">
|
||||
<header className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold text-foreground mb-4">
|
||||
Dofus Manager
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Gérez vos personnages, comptes et équipes Dofus
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-16">
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<User className="w-10 h-10 text-primary mb-2" />
|
||||
<CardTitle>Personnages</CardTitle>
|
||||
<CardDescription>Gérez tous vos personnages</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" className="w-full">
|
||||
Voir les personnages
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<Users className="w-10 h-10 text-primary mb-2" />
|
||||
<CardTitle>Comptes</CardTitle>
|
||||
<CardDescription>Organisez vos comptes Dofus</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" className="w-full">
|
||||
Voir les comptes
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<Swords className="w-10 h-10 text-primary mb-2" />
|
||||
<CardTitle>Équipes</CardTitle>
|
||||
<CardDescription>Composez vos équipes</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" className="w-full">
|
||||
Voir les équipes
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<Server className="w-10 h-10 text-primary mb-2" />
|
||||
<CardTitle>Serveurs</CardTitle>
|
||||
<CardDescription>Vos serveurs de jeu</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button variant="outline" className="w-full">
|
||||
Voir les serveurs
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<footer className="text-center text-muted-foreground">
|
||||
<p>Dofus Manager - Gérez votre aventure</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/styles.css
Normal file
14
src/styles.css
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
7
tests/unit/example.test.ts
Normal file
7
tests/unit/example.test.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('Example', () => {
|
||||
it('should pass', () => {
|
||||
expect(1 + 1).toBe(2);
|
||||
});
|
||||
});
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
28
vite.config.ts
Normal file
28
vite.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { devtools } from '@tanstack/devtools-vite'
|
||||
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
|
||||
import viteReact from '@vitejs/plugin-react'
|
||||
import viteTsConfigPaths from 'vite-tsconfig-paths'
|
||||
import { fileURLToPath, URL } from 'url'
|
||||
import { nitro } from 'nitro/vite'
|
||||
|
||||
const config = defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
devtools(),
|
||||
nitro(),
|
||||
// this is the plugin that enables path aliases
|
||||
viteTsConfigPaths({
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
|
||||
tanstackStart(),
|
||||
viteReact(),
|
||||
],
|
||||
})
|
||||
|
||||
export default config
|
||||
Reference in New Issue
Block a user