Compare commits

..

15 Commits

Author SHA1 Message Date
BeauTroll
d552d31f4a set healthcheck
Some checks failed
CI / test (push) Has been cancelled
CI / build (push) Has been cancelled
CI / deploy-staging (push) Has been cancelled
CI / deploy-production (push) Has been cancelled
CI / deploy-manual (push) Has been cancelled
2026-01-19 19:27:49 +01:00
BeauTroll
a22fed0f9d feat: set homepage 2026-01-19 19:03:16 +01:00
BeauTroll
f6621200cd feat: add readme 2026-01-19 19:03:05 +01:00
BeauTroll
54abb81a07 add pnpm caching 2026-01-19 19:00:08 +01:00
BeauTroll
1045aea277 feat: add Gitea Actions CI workflow
Some checks failed
CI / test (push) Failing after 18m16s
CI / build (push) Has been cancelled
CI / deploy-staging (push) Has been cancelled
CI / deploy-production (push) Has been cancelled
CI / deploy-manual (push) Has been cancelled
2026-01-19 18:53:05 +01:00
BeauTroll
d470eb2cd5 a gitea ci 2026-01-19 18:36:27 +01:00
BeauTroll
cbd0cdced4 biome lint applied 2026-01-19 18:09:23 +01:00
BeauTroll
e8269bb2fe add javascript linting config 2026-01-19 18:04:52 +01:00
BeauTroll
ba8a46fcd3 add biome 2026-01-19 18:01:31 +01:00
BeauTroll
0bea7bc3f4 added biome 2026-01-19 13:35:20 +01:00
BeauTroll
10859a4e64 feat: add healthcheck 2026-01-19 12:23:01 +01:00
BeauTroll
2b78d4e332 feat: add prisma 2026-01-19 12:22:08 +01:00
BeauTroll
448721f0a0 feat: add docker-compose 2026-01-19 09:52:44 +01:00
BeauTroll
eb7c7a7221 feat: add Dockerfile 2026-01-19 09:42:01 +01:00
BeauTroll
4c8a6e9fd3 init tanstack 2026-01-19 09:32:47 +01:00
65 changed files with 10340 additions and 1346 deletions

16
.cta.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]

View 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
View 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:

View File

@@ -22,10 +22,10 @@ src/server/
```typescript ```typescript
// src/server/functions/characters.ts // src/server/functions/characters.ts
import { createServerFn } from '@tanstack/react-start/server'; import { createServerFn } from "@tanstack/react-start/server";
import { z } from 'zod'; import { z } from "zod";
import { db } from '@/lib/server/db'; import { db } from "@/lib/server/db";
import { requireAuth } from '@/server/middleware/auth'; import { requireAuth } from "@/server/middleware/auth";
// Schemas // Schemas
const createCharacterSchema = z.object({ const createCharacterSchema = z.object({
@@ -48,7 +48,7 @@ const characterFiltersSchema = z.object({
}); });
// Functions // Functions
export const getCharacters = createServerFn({ method: 'GET' }) export const getCharacters = createServerFn({ method: "GET" })
.validator((data: unknown) => characterFiltersSchema.parse(data)) .validator((data: unknown) => characterFiltersSchema.parse(data))
.handler(async ({ data }) => { .handler(async ({ data }) => {
const session = await requireAuth(); const session = await requireAuth();
@@ -57,7 +57,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
const where = { const where = {
account: { userId: session.userId }, account: { userId: session.userId },
...(search && { ...(search && {
name: { contains: search, mode: 'insensitive' as const }, name: { contains: search, mode: "insensitive" as const },
}), }),
...(classIds?.length && { classId: { in: classIds } }), ...(classIds?.length && { classId: { in: classIds } }),
...(serverIds?.length && { serverId: { in: serverIds } }), ...(serverIds?.length && { serverId: { in: serverIds } }),
@@ -70,7 +70,7 @@ export const getCharacters = createServerFn({ method: 'GET' })
include: { account: { select: { id: true, name: true } } }, include: { account: { select: { id: true, name: true } } },
skip: (page - 1) * limit, skip: (page - 1) * limit,
take: limit, take: limit,
orderBy: { name: 'asc' }, orderBy: { name: "asc" },
}), }),
db.character.count({ where }), 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)) .validator((data: unknown) => createCharacterSchema.parse(data))
.handler(async ({ data }) => { .handler(async ({ data }) => {
const session = await requireAuth(); const session = await requireAuth();
@@ -97,14 +97,16 @@ export const createCharacter = createServerFn({ method: 'POST' })
}); });
if (!account) { if (!account) {
throw new Error('Account not found'); throw new Error("Account not found");
} }
return db.character.create({ data }); return db.character.create({ data });
}); });
export const bulkDeleteCharacters = createServerFn({ method: 'POST' }) export const bulkDeleteCharacters = createServerFn({ method: "POST" })
.validator((data: unknown) => z.object({ ids: z.array(z.string().cuid()) }).parse(data)) .validator((data: unknown) =>
z.object({ ids: z.array(z.string().cuid()) }).parse(data),
)
.handler(async ({ data }) => { .handler(async ({ data }) => {
const session = await requireAuth(); const session = await requireAuth();
@@ -123,8 +125,8 @@ export const bulkDeleteCharacters = createServerFn({ method: 'POST' })
```typescript ```typescript
// src/server/middleware/auth.ts // src/server/middleware/auth.ts
import { getWebRequest } from '@tanstack/react-start/server'; import { getWebRequest } from "@tanstack/react-start/server";
import { db } from '@/lib/server/db'; import { db } from "@/lib/server/db";
interface Session { interface Session {
userId: string; userId: string;
@@ -133,13 +135,14 @@ interface Session {
export async function requireAuth(): Promise<Session> { export async function requireAuth(): Promise<Session> {
const request = getWebRequest(); const request = getWebRequest();
const sessionId = request.headers.get('cookie') const sessionId = request.headers
?.split(';') .get("cookie")
.find(c => c.trim().startsWith('session=')) ?.split(";")
?.split('=')[1]; .find((c) => c.trim().startsWith("session="))
?.split("=")[1];
if (!sessionId) { if (!sessionId) {
throw new Error('Unauthorized'); throw new Error("Unauthorized");
} }
const session = await db.session.findUnique({ const session = await db.session.findUnique({
@@ -148,7 +151,7 @@ export async function requireAuth(): Promise<Session> {
}); });
if (!session || session.expiresAt < new Date()) { if (!session || session.expiresAt < new Date()) {
throw new Error('Session expired'); throw new Error("Session expired");
} }
return { return {
@@ -170,7 +173,7 @@ export async function getOptionalAuth(): Promise<Session | null> {
```typescript ```typescript
// src/lib/server/cache.ts // src/lib/server/cache.ts
import NodeCache from 'node-cache'; import NodeCache from "node-cache";
// Different TTLs for different data types // Different TTLs for different data types
const caches = { const caches = {
@@ -196,7 +199,9 @@ export const userCache = {
// Helper to invalidate all cache for a user // Helper to invalidate all cache for a user
invalidateUser: (userId: string): void => { 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); caches.user.del(keys);
}, },
}; };

View File

@@ -16,77 +16,84 @@ Draft
2. Docker Compose configuration with app service and PostgreSQL 16 2. Docker Compose configuration with app service and PostgreSQL 16
3. Prisma configured and connected to PostgreSQL 3. Prisma configured and connected to PostgreSQL
4. shadcn/ui installed with base components (Button, Input, Card, Table) 4. shadcn/ui installed with base components (Button, Input, Card, Table)
5. ESLint + Prettier configured with recommended rules 5. Biome configured for linting and formatting
6. GitLab CI pipeline: build, lint, test stages 6. Gitea Actions workflow: build, lint, test stages
7. Dockerfile multi-stage pour production build 7. Dockerfile multi-stage pour production build
8. README avec instructions de setup local 8. README avec instructions de setup local
9. Application démarre et affiche une page d'accueil "Dofus Manager" 9. Application démarre et affiche une page d'accueil "Dofus Manager"
10. Health check endpoint `/api/health` pour Docker healthcheck
## Tasks / Subtasks ## Tasks / Subtasks
- [ ] Task 1: Initialize TanStack Start project (AC: 1) - [x] Task 1: Initialize TanStack Start project (AC: 1)
- [ ] Create new TanStack Start project with `pnpm create @tanstack/start` - [x] Create new TanStack Start project with `pnpm create @tanstack/start`
- [ ] Configure `tsconfig.json` with strict mode enabled - [x] Configure `tsconfig.json` with strict mode enabled
- [ ] Configure path aliases (`@/` pointing to `src/`) - [x] Configure path aliases (`@/` pointing to `src/`)
- [ ] Verify TypeScript strict compilation works - [x] Verify TypeScript strict compilation works
- [ ] Task 2: Setup Docker environment (AC: 2, 7) - [x] Task 2: Setup Docker environment (AC: 2, 7)
- [ ] Create `docker/` directory - [x] Create `docker/` directory
- [ ] Create `docker/Dockerfile` with multi-stage build (builder + runner) - [x] Create `docker/Dockerfile` with multi-stage build (builder + runner)
- [ ] Create `docker/docker-compose.yml` with app and postgres services - [x] Create `docker/docker-compose.yml` with app and postgres services
- [ ] Create `docker/docker-compose.dev.yml` for local development (postgres only) - [x] Create `docker/docker-compose.dev.yml` for local development (postgres only)
- [ ] Configure PostgreSQL 16-alpine with healthcheck - [x] Configure PostgreSQL 16-alpine with healthcheck
- [ ] Test database connectivity - [x] Test database connectivity
- [ ] Task 3: Configure Prisma ORM (AC: 3) - [x] Task 3: Configure Prisma ORM (AC: 3)
- [ ] Install Prisma dependencies (`prisma`, `@prisma/client`) - [x] Install Prisma dependencies (`prisma`, `@prisma/client`)
- [ ] Initialize Prisma with `pnpm prisma init` - [x] Initialize Prisma with `pnpm prisma init`
- [ ] Configure `prisma/schema.prisma` with PostgreSQL provider - [x] Configure `prisma/schema.prisma` with PostgreSQL provider
- [ ] Create `src/lib/server/db.ts` for Prisma client singleton - [x] Create `src/lib/server/db.ts` for Prisma client singleton
- [ ] Create `.env.example` with DATABASE_URL template - [x] Create `.env.example` with DATABASE_URL template
- [ ] Verify Prisma connects to database - [x] Verify Prisma connects to database
- [ ] Task 4: Install and configure shadcn/ui (AC: 4) - [x] Task 4: Install and configure shadcn/ui (AC: 4)
- [ ] Install Tailwind CSS 4.x - [x] Install Tailwind CSS 4.x
- [ ] Initialize shadcn/ui with `pnpm dlx shadcn@latest init` - [x] Initialize shadcn/ui with `pnpm dlx shadcn@latest init`
- [ ] Configure `components.json` for path aliases - [x] Configure `components.json` for path aliases
- [ ] Install base components: Button, Input, Card, Table - [x] Install base components: Button, Input, Card, Table
- [ ] Create `src/lib/utils.ts` with `cn` utility function - [x] Create `src/lib/utils.ts` with `cn` utility function
- [ ] Install Lucide React for icons - [x] Install Lucide React for icons
- [ ] Task 5: Configure linting and formatting (AC: 5) - [x] Task 5: Configure linting and formatting (AC: 5)
- [ ] Install Biome (as specified in tech stack, not ESLint+Prettier) - [x] Install Biome (as specified in tech stack, not ESLint+Prettier)
- [ ] Create `biome.json` with recommended rules - [x] Create `biome.json` with recommended rules
- [ ] Add lint and format scripts to `package.json` - [x] Add lint and format scripts to `package.json`
- [ ] Verify linting works on project files - [x] Verify linting works on project files
- [ ] Task 6: Setup GitLab CI/CD pipeline (AC: 6) - [x] Task 6: Setup Gitea Actions workflow (AC: 6)
- [ ] Create `.gitlab-ci.yml` with stages: test, build, deploy - [x] Create `.gitea/workflows/ci.yml`
- [ ] Configure test stage: lint, typecheck, test - [x] Configure test job: lint, typecheck, test
- [ ] Configure build stage: Docker image build and push - [x] Configure build job: Docker image build and push
- [ ] Configure deploy stages (staging/production) with manual triggers - [x] Configure deploy jobs (staging/production) with manual triggers
- [ ] Add caching for node_modules - [x] Add caching for pnpm store
- [ ] Task 7: Create README documentation (AC: 8) - [x] Task 7: Create README documentation (AC: 8)
- [ ] Document project overview - [x] Document project overview
- [ ] Document prerequisites (Node 20, pnpm, Docker) - [x] Document prerequisites (Node 20, pnpm, Docker)
- [ ] Document local development setup steps - [x] Document local development setup steps
- [ ] Document available npm scripts - [x] Document available npm scripts
- [ ] Document environment variables - [x] Document environment variables
- [ ] Task 8: Create home page (AC: 9) - [x] Task 8: Create home page (AC: 9)
- [ ] Create `src/routes/index.tsx` as home page - [x] Create `src/routes/index.tsx` as home page
- [ ] Display "Dofus Manager" title - [x] Display "Dofus Manager" title
- [ ] Add basic layout structure - [x] Add basic layout structure
- [ ] Create `src/styles/globals.css` with Tailwind imports - [x] Create `src/styles/globals.css` with Tailwind imports
- [ ] Verify application starts and renders correctly - [x] Verify application starts and renders correctly
- [ ] Task 9: Final verification - [x] Task 9: Create health check endpoint (AC: 10)
- [ ] Run `pnpm dev` and verify app starts - [x] Create `src/routes/api/health.ts` server function
- [ ] Run `pnpm lint` and verify no errors - [x] Return JSON `{ status: "ok", timestamp: Date }`
- [ ] Run `pnpm typecheck` and verify no errors - [x] Optionally check database connectivity
- [ ] Test Docker build locally - [x] Verify endpoint responds at `GET /api/health`
- [ ] Verify PostgreSQL connection via Prisma
- [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 ## Dev Notes
@@ -118,7 +125,7 @@ Draft
- Docker - Docker
- Docker Compose - Docker Compose
- GitLab CI - Gitea Actions
**Dev Tools:** **Dev Tools:**
@@ -131,9 +138,13 @@ Draft
``` ```
dofus-manager/ dofus-manager/
├── .gitea/
│ └── workflows/
│ └── ci.yml
├── docker/ ├── docker/
│ ├── Dockerfile │ ├── Dockerfile
── docker-compose.yml ── docker-compose.yml
│ └── docker-compose.dev.yml
├── prisma/ ├── prisma/
│ ├── schema.prisma │ ├── schema.prisma
│ └── migrations/ │ └── migrations/
@@ -156,7 +167,9 @@ dofus-manager/
│ │ └── logger.ts │ │ └── logger.ts
│ ├── routes/ │ ├── routes/
│ │ ├── __root.tsx │ │ ├── __root.tsx
│ │ ── index.tsx │ │ ── index.tsx
│ │ └── api/
│ │ └── health.ts
│ ├── styles/ │ ├── styles/
│ │ └── globals.css │ │ └── globals.css
│ └── app.tsx │ └── app.tsx
@@ -187,22 +200,66 @@ dofus-manager/
- PostgreSQL 16-alpine with healthcheck - PostgreSQL 16-alpine with healthcheck
- Traefik labels for reverse proxy (production) - 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 **Test job:**
- Commands: pnpm lint, pnpm typecheck, pnpm test
- Cache node_modules
**Build stage:** - runs-on: ubuntu-latest
- Steps: checkout, setup pnpm, setup node, install, lint, typecheck, test
- Cache pnpm store
- image: docker:24 **Build job:**
- Build and push Docker image to registry
- needs: test
- Build and push Docker image
- Only on main/develop branches - 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] ### Environment Variables [Source: architecture/13-development-workflow.md#environment-variables]
```bash ```bash
@@ -234,9 +291,45 @@ SESSION_SECRET="your-secret-key-min-32-chars"
- Types/Interfaces: PascalCase - Types/Interfaces: PascalCase
- Constants: SCREAMING_SNAKE_CASE - 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 ## Testing
@@ -264,9 +357,10 @@ AC #5 specifies "ESLint + Prettier" but the architecture documents (3-technology
## Change Log ## Change Log
| Date | Version | Description | Author | | Date | Version | Description | Author |
| ---------- | ------- | ---------------------- | -------- | | ---------- | ------- | --------------------------------------------- | -------- |
| 2026-01-19 | 1.0 | Initial story creation | SM Agent | | 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
View 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

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
onlyBuiltDependencies:
- '@prisma/engines'
- esbuild
- prisma

14
prisma.config.ts Normal file
View 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"],
},
});

View 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;

View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View 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
View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

38
src/App.css Normal file
View 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);
}
}

View File

@@ -5,75 +5,75 @@
/* Updated to Dofus Manager dark gaming theme */ /* Updated to Dofus Manager dark gaming theme */
:root { :root {
--background: #0f172a; --background: oklch(1 0 0);
--foreground: #f8fafc; --foreground: oklch(0.147 0.004 49.25);
--card: #1e293b; --card: oklch(1 0 0);
--card-foreground: #f8fafc; --card-foreground: oklch(0.147 0.004 49.25);
--popover: #1e293b; --popover: oklch(1 0 0);
--popover-foreground: #f8fafc; --popover-foreground: oklch(0.147 0.004 49.25);
--primary: #60a5fa; --primary: oklch(0.216 0.006 56.043);
--primary-foreground: #0f172a; --primary-foreground: oklch(0.985 0.001 106.423);
--secondary: #334155; --secondary: oklch(0.97 0.001 106.424);
--secondary-foreground: #f8fafc; --secondary-foreground: oklch(0.216 0.006 56.043);
--muted: #334155; --muted: oklch(0.97 0.001 106.424);
--muted-foreground: #94a3b8; --muted-foreground: oklch(0.553 0.013 58.071);
--accent: #334155; --accent: oklch(0.97 0.001 106.424);
--accent-foreground: #f8fafc; --accent-foreground: oklch(0.216 0.006 56.043);
--destructive: #ef4444; --destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: #f8fafc; --destructive-foreground: #f8fafc;
--border: #334155; --border: oklch(0.923 0.003 48.717);
--input: #334155; --input: oklch(0.923 0.003 48.717);
--ring: #60a5fa; --ring: oklch(0.709 0.01 56.259);
--chart-1: #60a5fa; --chart-1: oklch(0.646 0.222 41.116);
--chart-2: #4ade80; --chart-2: oklch(0.6 0.118 184.704);
--chart-3: #f87171; --chart-3: oklch(0.398 0.07 227.392);
--chart-4: #fbbf24; --chart-4: oklch(0.828 0.189 84.429);
--chart-5: #a78bfa; --chart-5: oklch(0.769 0.188 70.08);
--radius: 0.5rem; --radius: 0.625rem;
--sidebar: #1e293b; --sidebar: oklch(0.985 0.001 106.423);
--sidebar-foreground: #f8fafc; --sidebar-foreground: oklch(0.147 0.004 49.25);
--sidebar-primary: #60a5fa; --sidebar-primary: oklch(0.216 0.006 56.043);
--sidebar-primary-foreground: #0f172a; --sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: #334155; --sidebar-accent: oklch(0.97 0.001 106.424);
--sidebar-accent-foreground: #f8fafc; --sidebar-accent-foreground: oklch(0.216 0.006 56.043);
--sidebar-border: #334155; --sidebar-border: oklch(0.923 0.003 48.717);
--sidebar-ring: #60a5fa; --sidebar-ring: oklch(0.709 0.01 56.259);
} }
/* Keep dark class same as root for dark-first approach */ /* Keep dark class same as root for dark-first approach */
.dark { .dark {
--background: #0f172a; --background: oklch(0.147 0.004 49.25);
--foreground: #f8fafc; --foreground: oklch(0.985 0.001 106.423);
--card: #1e293b; --card: oklch(0.216 0.006 56.043);
--card-foreground: #f8fafc; --card-foreground: oklch(0.985 0.001 106.423);
--popover: #1e293b; --popover: oklch(0.216 0.006 56.043);
--popover-foreground: #f8fafc; --popover-foreground: oklch(0.985 0.001 106.423);
--primary: #60a5fa; --primary: oklch(0.923 0.003 48.717);
--primary-foreground: #0f172a; --primary-foreground: oklch(0.216 0.006 56.043);
--secondary: #334155; --secondary: oklch(0.268 0.007 34.298);
--secondary-foreground: #f8fafc; --secondary-foreground: oklch(0.985 0.001 106.423);
--muted: #334155; --muted: oklch(0.268 0.007 34.298);
--muted-foreground: #94a3b8; --muted-foreground: oklch(0.709 0.01 56.259);
--accent: #334155; --accent: oklch(0.268 0.007 34.298);
--accent-foreground: #f8fafc; --accent-foreground: oklch(0.985 0.001 106.423);
--destructive: #ef4444; --destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: #f8fafc; --destructive-foreground: #f8fafc;
--border: #334155; --border: oklch(1 0 0 / 10%);
--input: #334155; --input: oklch(1 0 0 / 15%);
--ring: #60a5fa; --ring: oklch(0.553 0.013 58.071);
--chart-1: #60a5fa; --chart-1: oklch(0.488 0.243 264.376);
--chart-2: #4ade80; --chart-2: oklch(0.696 0.17 162.48);
--chart-3: #f87171; --chart-3: oklch(0.769 0.188 70.08);
--chart-4: #fbbf24; --chart-4: oklch(0.627 0.265 303.9);
--chart-5: #a78bfa; --chart-5: oklch(0.645 0.246 16.439);
--sidebar: #1e293b; --sidebar: oklch(0.216 0.006 56.043);
--sidebar-foreground: #f8fafc; --sidebar-foreground: oklch(0.985 0.001 106.423);
--sidebar-primary: #60a5fa; --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: #0f172a; --sidebar-primary-foreground: oklch(0.985 0.001 106.423);
--sidebar-accent: #334155; --sidebar-accent: oklch(0.268 0.007 34.298);
--sidebar-accent-foreground: #f8fafc; --sidebar-accent-foreground: oklch(0.985 0.001 106.423);
--sidebar-border: #334155; --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: #60a5fa; --sidebar-ring: oklch(0.553 0.013 58.071);
} }
@theme inline { @theme inline {
@@ -115,6 +115,9 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --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 { @layer base {

18
src/components/Header.css Normal file
View 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
View 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>
);
}

View File

@@ -1,437 +1,437 @@
import * as React from "react";
import { import {
Search, ArrowUpDown,
Plus, ChevronDown,
ChevronDown, ChevronUp,
ChevronUp, Plus,
ArrowUpDown, Search,
} from "lucide-react"; } from 'lucide-react';
import { Input } from "@/components/ui/input"; import * as React from 'react';
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button';
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from '@/components/ui/checkbox';
import { import {
Table, Collapsible,
TableBody, CollapsibleContent,
TableCell, CollapsibleTrigger,
TableHead, } from '@/components/ui/collapsible';
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
Collapsible, DropdownMenu,
CollapsibleContent, DropdownMenuContent,
CollapsibleTrigger, DropdownMenuItem,
} from "@/components/ui/collapsible"; DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { import {
DropdownMenu, Table,
DropdownMenuContent, TableBody,
DropdownMenuItem, TableCell,
DropdownMenuTrigger, TableHead,
} from "@/components/ui/dropdown-menu"; TableHeader,
import { cn } from "@/lib/utils"; TableRow,
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
// Sample data // Sample data
const characters = [ const characters = [
{ {
id: 1, id: 1,
nom: "Krosmaster", nom: 'Krosmaster',
classe: "Cra", classe: 'Cra',
niveau: 200, niveau: 200,
serveur: "Imagiro", serveur: 'Imagiro',
compte: "Compte1", compte: 'Compte1',
}, },
{ {
id: 2, id: 2,
nom: "TankMaster", nom: 'TankMaster',
classe: "Iop", classe: 'Iop',
niveau: 200, niveau: 200,
serveur: "Imagiro", serveur: 'Imagiro',
compte: "Compte1", compte: 'Compte1',
}, },
{ {
id: 3, id: 3,
nom: "MoneyMaker", nom: 'MoneyMaker',
classe: "Enu", classe: 'Enu',
niveau: 200, niveau: 200,
serveur: "Imagiro", serveur: 'Imagiro',
compte: "Compte2", compte: 'Compte2',
}, },
{ {
id: 4, id: 4,
nom: "HealBot", nom: 'HealBot',
classe: "Eni", classe: 'Eni',
niveau: 195, niveau: 195,
serveur: "Tylezia", serveur: 'Tylezia',
compte: "Compte2", compte: 'Compte2',
}, },
{ {
id: 5, id: 5,
nom: "ShadowKill", nom: 'ShadowKill',
classe: "Sram", classe: 'Sram',
niveau: 200, niveau: 200,
serveur: "Draconiros", serveur: 'Draconiros',
compte: "Compte3", compte: 'Compte3',
}, },
{ {
id: 6, id: 6,
nom: "TimeWarp", nom: 'TimeWarp',
classe: "Elio", classe: 'Elio',
niveau: 180, niveau: 180,
serveur: "Imagiro", serveur: 'Imagiro',
compte: "Compte1", compte: 'Compte1',
}, },
{ {
id: 7, id: 7,
nom: "ArrowStorm", nom: 'ArrowStorm',
classe: "Cra", classe: 'Cra',
niveau: 200, niveau: 200,
serveur: "Tylezia", serveur: 'Tylezia',
compte: "Compte4", compte: 'Compte4',
}, },
{ {
id: 8, id: 8,
nom: "BerserkerX", nom: 'BerserkerX',
classe: "Iop", classe: 'Iop',
niveau: 175, niveau: 175,
serveur: "Draconiros", serveur: 'Draconiros',
compte: "Compte3", compte: 'Compte3',
}, },
]; ];
const classes = [ const classes = [
{ name: "Cra", count: 12 }, { name: 'Cra', count: 12 },
{ name: "Iop", count: 8 }, { name: 'Iop', count: 8 },
{ name: "Enu", count: 6 }, { name: 'Enu', count: 6 },
{ name: "Eni", count: 5 }, { name: 'Eni', count: 5 },
{ name: "Elio", count: 4 }, { name: 'Elio', count: 4 },
{ name: "Sram", count: 6 }, { name: 'Sram', count: 6 },
]; ];
const serveurs = ["Imagiro", "Tylezia", "Draconiros"]; const serveurs = ['Imagiro', 'Tylezia', 'Draconiros'];
export function CharacterList() { export function CharacterList() {
const [selectedIds, setSelectedIds] = React.useState<number[]>([]); const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
const [classeOpen, setClasseOpen] = React.useState(true); const [classeOpen, setClasseOpen] = React.useState(true);
const [serveurOpen, setServeurOpen] = React.useState(true); const [serveurOpen, setServeurOpen] = React.useState(true);
const [progressionOpen, setProgressionOpen] = React.useState(true); const [progressionOpen, setProgressionOpen] = React.useState(true);
const toggleSelect = (id: number) => { const toggleSelect = (id: number) => {
setSelectedIds((prev) => setSelectedIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id], prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
); );
}; };
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedIds.length === characters.length) { if (selectedIds.length === characters.length) {
setSelectedIds([]); setSelectedIds([]);
} else { } else {
setSelectedIds(characters.map((c) => c.id)); setSelectedIds(characters.map((c) => c.id));
} }
}; };
return ( return (
<div className="flex h-screen"> <div className="flex h-screen">
{/* Filter Sidebar */} {/* Filter Sidebar */}
<aside className="w-[250px] border-r border-[#334155] bg-[#1E293B] p-4 sticky top-0 h-screen overflow-y-auto"> <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> <h2 className="text-[#F8FAFC] font-semibold text-lg mb-6">Filtres</h2>
{/* Classe Section */} {/* Classe Section */}
<Collapsible open={classeOpen} onOpenChange={setClasseOpen}> <Collapsible open={classeOpen} onOpenChange={setClasseOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full mb-3"> <CollapsibleTrigger className="flex items-center justify-between w-full mb-3">
<span className="text-xs uppercase tracking-wider text-[#94A3B8]"> <span className="text-xs uppercase tracking-wider text-[#94A3B8]">
Classe Classe
</span> </span>
{classeOpen ? ( {classeOpen ? (
<ChevronUp className="h-4 w-4 text-[#94A3B8]" /> <ChevronUp className="h-4 w-4 text-[#94A3B8]" />
) : ( ) : (
<ChevronDown className="h-4 w-4 text-[#94A3B8]" /> <ChevronDown className="h-4 w-4 text-[#94A3B8]" />
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-2 mb-6"> <CollapsibleContent className="space-y-2 mb-6">
{classes.map((classe) => ( {classes.map((classe) => (
<label <label
key={classe.name} key={classe.name}
className="flex items-center gap-3 cursor-pointer group" className="flex items-center gap-3 cursor-pointer group"
> >
<Checkbox className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]" /> <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"> <span className="text-[#F8FAFC] text-sm group-hover:text-[#60A5FA] transition-colors">
{classe.name} {classe.name}
</span> </span>
<span className="text-[#64748B] text-sm ml-auto"> <span className="text-[#64748B] text-sm ml-auto">
({classe.count}) ({classe.count})
</span> </span>
</label> </label>
))} ))}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* Serveur Section */} {/* Serveur Section */}
<Collapsible open={serveurOpen} onOpenChange={setServeurOpen}> <Collapsible open={serveurOpen} onOpenChange={setServeurOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full mb-3"> <CollapsibleTrigger className="flex items-center justify-between w-full mb-3">
<span className="text-xs uppercase tracking-wider text-[#94A3B8]"> <span className="text-xs uppercase tracking-wider text-[#94A3B8]">
Serveur Serveur
</span> </span>
{serveurOpen ? ( {serveurOpen ? (
<ChevronUp className="h-4 w-4 text-[#94A3B8]" /> <ChevronUp className="h-4 w-4 text-[#94A3B8]" />
) : ( ) : (
<ChevronDown className="h-4 w-4 text-[#94A3B8]" /> <ChevronDown className="h-4 w-4 text-[#94A3B8]" />
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-2 mb-6"> <CollapsibleContent className="space-y-2 mb-6">
{serveurs.map((serveur) => ( {serveurs.map((serveur) => (
<label <label
key={serveur} key={serveur}
className="flex items-center gap-3 cursor-pointer group" className="flex items-center gap-3 cursor-pointer group"
> >
<Checkbox className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]" /> <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"> <span className="text-[#F8FAFC] text-sm group-hover:text-[#60A5FA] transition-colors">
{serveur} {serveur}
</span> </span>
</label> </label>
))} ))}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* Progression Section */} {/* Progression Section */}
<Collapsible open={progressionOpen} onOpenChange={setProgressionOpen}> <Collapsible open={progressionOpen} onOpenChange={setProgressionOpen}>
<CollapsibleTrigger className="flex items-center justify-between w-full mb-3"> <CollapsibleTrigger className="flex items-center justify-between w-full mb-3">
<span className="text-xs uppercase tracking-wider text-[#94A3B8]"> <span className="text-xs uppercase tracking-wider text-[#94A3B8]">
Progression Progression
</span> </span>
{progressionOpen ? ( {progressionOpen ? (
<ChevronUp className="h-4 w-4 text-[#94A3B8]" /> <ChevronUp className="h-4 w-4 text-[#94A3B8]" />
) : ( ) : (
<ChevronDown className="h-4 w-4 text-[#94A3B8]" /> <ChevronDown className="h-4 w-4 text-[#94A3B8]" />
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mb-6"> <CollapsibleContent className="space-y-4 mb-6">
<div> <div>
<label className="text-xs text-[#94A3B8] mb-2 block">Type</label> <label className="text-xs text-[#94A3B8] mb-2 block">Type</label>
<Select> <Select>
<SelectTrigger className="w-full bg-[#0F172A] border-[#475569] text-[#F8FAFC] h-9 rounded-[6px]"> <SelectTrigger className="w-full bg-[#0F172A] border-[#475569] text-[#F8FAFC] h-9 rounded-[6px]">
<SelectValue placeholder="Sélectionner..." /> <SelectValue placeholder="Sélectionner..." />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-[#1E293B] border-[#475569]"> <SelectContent className="bg-[#1E293B] border-[#475569]">
<SelectItem <SelectItem
value="dofus" value="dofus"
className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]" className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"
> >
Dofus Dofus
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="donjons" value="donjons"
className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]" className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"
> >
Donjons Donjons
</SelectItem> </SelectItem>
<SelectItem <SelectItem
value="recherches" value="recherches"
className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]" className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"
> >
Recherchés Recherchés
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<RadioGroup defaultValue="a-fait" className="gap-2"> <RadioGroup defaultValue="a-fait" className="gap-2">
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer">
<RadioGroupItem <RadioGroupItem
value="a-fait" value="a-fait"
className="border-[#475569] text-[#60A5FA]" className="border-[#475569] text-[#60A5FA]"
/> />
<span className="text-[#F8FAFC] text-sm">A fait</span> <span className="text-[#F8FAFC] text-sm">A fait</span>
</label> </label>
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer">
<RadioGroupItem <RadioGroupItem
value="na-pas-fait" value="na-pas-fait"
className="border-[#475569] text-[#60A5FA]" className="border-[#475569] text-[#60A5FA]"
/> />
<span className="text-[#F8FAFC] text-sm">{"N'a pas fait"}</span> <span className="text-[#F8FAFC] text-sm">{"N'a pas fait"}</span>
</label> </label>
</RadioGroup> </RadioGroup>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
{/* Reset Button */} {/* Reset Button */}
<Button <Button
variant="outline" variant="outline"
className="w-full mt-4 border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent" className="w-full mt-4 border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
> >
Réinitialiser Réinitialiser
</Button> </Button>
</aside> </aside>
{/* Main Content */} {/* Main Content */}
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between p-4 border-b border-[#334155] bg-[#0F172A]"> <div className="flex items-center justify-between p-4 border-b border-[#334155] bg-[#0F172A]">
<div className="relative w-72"> <div className="relative w-72">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#64748B]" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#64748B]" />
<Input <Input
placeholder="Rechercher..." placeholder="Rechercher..."
className="pl-10 bg-[#1E293B] border-[#475569] text-[#F8FAFC] placeholder:text-[#64748B] h-9 rounded-[6px]" className="pl-10 bg-[#1E293B] border-[#475569] text-[#F8FAFC] placeholder:text-[#64748B] h-9 rounded-[6px]"
/> />
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<> <>
<span className="text-sm text-[#94A3B8]"> <span className="text-sm text-[#94A3B8]">
{selectedIds.length} sélectionné {selectedIds.length} sélectionné
{selectedIds.length > 1 ? "s" : ""} {selectedIds.length > 1 ? 's' : ''}
</span> </span>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="border-[#475569] text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent" className="border-[#475569] text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
> >
Bulk Actions Bulk Actions
<ChevronDown className="h-4 w-4 ml-2" /> <ChevronDown className="h-4 w-4 ml-2" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="bg-[#1E293B] border-[#475569]"> <DropdownMenuContent className="bg-[#1E293B] border-[#475569]">
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"> <DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
Exporter Exporter
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"> <DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
Supprimer Supprimer
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</> </>
)} )}
<Button className="bg-[#60A5FA] text-[#0F172A] hover:bg-[#3B82F6] rounded-[6px]"> <Button className="bg-[#60A5FA] text-[#0F172A] hover:bg-[#3B82F6] rounded-[6px]">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
Ajouter Ajouter
</Button> </Button>
</div> </div>
</div> </div>
{/* Table */} {/* Table */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
<Table> <Table>
<TableHeader className="sticky top-0 bg-[#1E293B] z-10"> <TableHeader className="sticky top-0 bg-[#1E293B] z-10">
<TableRow className="border-[#334155] hover:bg-[#1E293B]"> <TableRow className="border-[#334155] hover:bg-[#1E293B]">
<TableHead className="w-[40px] text-[#94A3B8]"> <TableHead className="w-[40px] text-[#94A3B8]">
<Checkbox <Checkbox
checked={ checked={
selectedIds.length === characters.length && selectedIds.length === characters.length &&
characters.length > 0 characters.length > 0
} }
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]" className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]"
/> />
</TableHead> </TableHead>
<TableHead className="text-[#94A3B8]"> <TableHead className="text-[#94A3B8]">
<button className="flex items-center gap-1 hover:text-[#F8FAFC] transition-colors"> <button className="flex items-center gap-1 hover:text-[#F8FAFC] transition-colors">
Nom Nom
<ArrowUpDown className="h-4 w-4" /> <ArrowUpDown className="h-4 w-4" />
</button> </button>
</TableHead> </TableHead>
<TableHead className="text-[#94A3B8]">Classe</TableHead> <TableHead className="text-[#94A3B8]">Classe</TableHead>
<TableHead className="text-[#94A3B8]"> <TableHead className="text-[#94A3B8]">
<button className="flex items-center gap-1 hover:text-[#F8FAFC] transition-colors"> <button className="flex items-center gap-1 hover:text-[#F8FAFC] transition-colors">
Niveau Niveau
<ArrowUpDown className="h-4 w-4" /> <ArrowUpDown className="h-4 w-4" />
</button> </button>
</TableHead> </TableHead>
<TableHead className="text-[#94A3B8]">Serveur</TableHead> <TableHead className="text-[#94A3B8]">Serveur</TableHead>
<TableHead className="text-[#94A3B8]">Compte</TableHead> <TableHead className="text-[#94A3B8]">Compte</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{characters.map((character) => { {characters.map((character) => {
const isSelected = selectedIds.includes(character.id); const isSelected = selectedIds.includes(character.id);
return ( return (
<TableRow <TableRow
key={character.id} key={character.id}
className={cn( className={cn(
"border-[#334155] h-12 transition-colors", 'border-[#334155] h-12 transition-colors',
isSelected isSelected
? "bg-[#1E293B] border-l-2 border-l-[#60A5FA]" ? 'bg-[#1E293B] border-l-2 border-l-[#60A5FA]'
: "hover:bg-[#1E293B]/50", : 'hover:bg-[#1E293B]/50',
)} )}
> >
<TableCell className="w-[40px]"> <TableCell className="w-[40px]">
<Checkbox <Checkbox
checked={isSelected} checked={isSelected}
onCheckedChange={() => toggleSelect(character.id)} onCheckedChange={() => toggleSelect(character.id)}
className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]" className="border-[#475569] data-[state=checked]:bg-[#60A5FA] data-[state=checked]:border-[#60A5FA]"
/> />
</TableCell> </TableCell>
<TableCell className="text-[#F8FAFC] font-medium"> <TableCell className="text-[#F8FAFC] font-medium">
{character.nom} {character.nom}
</TableCell> </TableCell>
<TableCell className="text-[#F8FAFC]"> <TableCell className="text-[#F8FAFC]">
{character.classe} {character.classe}
</TableCell> </TableCell>
<TableCell className="text-[#F8FAFC]"> <TableCell className="text-[#F8FAFC]">
{character.niveau} {character.niveau}
</TableCell> </TableCell>
<TableCell className="text-[#F8FAFC]"> <TableCell className="text-[#F8FAFC]">
{character.serveur} {character.serveur}
</TableCell> </TableCell>
<TableCell className="text-[#F8FAFC]"> <TableCell className="text-[#F8FAFC]">
{character.compte} {character.compte}
</TableCell> </TableCell>
</TableRow> </TableRow>
); );
})} })}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{/* Pagination Footer */} {/* Pagination Footer */}
<div className="flex items-center justify-between p-4 border-t border-[#334155] bg-[#0F172A]"> <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> <span className="text-sm text-[#94A3B8]">Showing 1-8 of 64</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent" className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
> >
{"<"} {'<'}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="border-[#60A5FA] bg-[#60A5FA]/10 text-[#60A5FA] hover:bg-[#60A5FA]/20 rounded-[6px]" className="border-[#60A5FA] bg-[#60A5FA]/10 text-[#60A5FA] hover:bg-[#60A5FA]/20 rounded-[6px]"
> >
1 1
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent" className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
> >
2 2
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent" className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
> >
3 3
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent" className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
> >
{">"} {'>'}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,54 +1,54 @@
import { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from '@/components/ui/dialog';
import { Button } from "@/components/ui/button";
interface ConfirmationModalProps { interface ConfirmationModalProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
progressionName: string; progressionName: string;
incompleteCount: number; incompleteCount: number;
} }
export function ConfirmationModal({ export function ConfirmationModal({
open, open,
onOpenChange, onOpenChange,
progressionName, progressionName,
incompleteCount, incompleteCount,
}: ConfirmationModalProps) { }: ConfirmationModalProps) {
const handleConfirm = () => { const handleConfirm = () => {
// Do NOT implement actual update logic // Do NOT implement actual update logic
onOpenChange(false); onOpenChange(false);
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[400px] rounded-lg"> <DialogContent className="max-w-[400px] rounded-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Confirmer la mise à jour</DialogTitle> <DialogTitle>Confirmer la mise à jour</DialogTitle>
<DialogDescription> <DialogDescription>
Marquer {progressionName} comme fait pour {incompleteCount}{" "} Marquer {progressionName} comme fait pour {incompleteCount}{' '}
personnages ? personnages ?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="gap-2 sm:gap-0"> <DialogFooter className="gap-2 sm:gap-0">
<Button <Button
variant="secondary" variant="secondary"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="rounded-md" className="rounded-md"
> >
Annuler Annuler
</Button> </Button>
<Button onClick={handleConfirm} className="rounded-md"> <Button onClick={handleConfirm} className="rounded-md">
Confirmer Confirmer
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
} }

View File

@@ -1,72 +1,72 @@
import { useLocation } from "@tanstack/react-router"; import { useLocation } from '@tanstack/react-router';
import { Button } from "@/components/ui/button"; import { ChevronRight, Moon, Sun } from 'lucide-react';
import { Moon, Sun, ChevronRight } from "lucide-react"; import { Button } from '@/components/ui/button';
const routeLabels: Record<string, string> = { const routeLabels: Record<string, string> = {
"/": "Dashboard", '/': 'Dashboard',
"/characters": "Personnages", '/characters': 'Personnages',
"/accounts": "Comptes", '/accounts': 'Comptes',
"/teams": "Teams", '/teams': 'Teams',
"/settings": "Paramètres", '/settings': 'Paramètres',
}; };
interface AppHeaderProps { interface AppHeaderProps {
theme: "dark" | "light"; theme: 'dark' | 'light';
onToggleTheme: () => void; onToggleTheme: () => void;
} }
export function AppHeader({ theme, onToggleTheme }: AppHeaderProps) { export function AppHeader({ theme, onToggleTheme }: AppHeaderProps) {
const { pathname } = useLocation(); const { pathname } = useLocation();
// Generate breadcrumb from pathname // Generate breadcrumb from pathname
const segments = pathname.split("/").filter(Boolean); const segments = pathname.split('/').filter(Boolean);
const breadcrumbs = const breadcrumbs =
segments.length === 0 segments.length === 0
? [{ label: "Dashboard", href: "/" }] ? [{ label: 'Dashboard', href: '/' }]
: segments.map((segment, index) => { : segments.map((segment, index) => {
const href = "/" + segments.slice(0, index + 1).join("/"); const href = '/' + segments.slice(0, index + 1).join('/');
const label = const label =
routeLabels[href] || routeLabels[href] ||
segment.charAt(0).toUpperCase() + segment.slice(1); segment.charAt(0).toUpperCase() + segment.slice(1);
return { label, href }; return { label, href };
}); });
return ( return (
<header className="flex h-16 items-center justify-between border-b border-border bg-card px-6"> <header className="flex h-16 items-center justify-between border-b border-border bg-card px-6">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="flex items-center gap-1 text-sm"> <nav className="flex items-center gap-1 text-sm">
{breadcrumbs.map((crumb, index) => ( {breadcrumbs.map((crumb, index) => (
<span key={crumb.href} className="flex items-center gap-1"> <span key={crumb.href} className="flex items-center gap-1">
{index > 0 && ( {index > 0 && (
<ChevronRight className="h-4 w-4 text-muted-foreground" /> <ChevronRight className="h-4 w-4 text-muted-foreground" />
)} )}
<span <span
className={ className={
index === breadcrumbs.length - 1 index === breadcrumbs.length - 1
? "font-medium text-foreground" ? 'font-medium text-foreground'
: "text-muted-foreground" : 'text-muted-foreground'
} }
> >
{crumb.label} {crumb.label}
</span> </span>
</span> </span>
))} ))}
</nav> </nav>
{/* Theme toggle */} {/* Theme toggle */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggleTheme} onClick={onToggleTheme}
className="h-9 w-9" className="h-9 w-9"
> >
{theme === "dark" ? ( {theme === 'dark' ? (
<Sun className="h-5 w-5" /> <Sun className="h-5 w-5" />
) : ( ) : (
<Moon className="h-5 w-5" /> <Moon className="h-5 w-5" />
)} )}
<span className="sr-only">Toggle theme</span> <span className="sr-only">Toggle theme</span>
</Button> </Button>
</header> </header>
); );
} }

View File

@@ -1,53 +1,53 @@
import type React from "react"; import type React from 'react';
import { useState, useEffect } from "react"; import { useEffect, useState } from 'react';
import { cn } from "@/lib/utils"; import { AppHeader } from '@/components/layout/app-header';
import { AppSidebar } from "@/components/layout/app-sidebar"; import { AppSidebar } from '@/components/layout/app-sidebar';
import { AppHeader } from "@/components/layout/app-header"; import { cn } from '@/lib/utils';
interface AppShellProps { interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
} }
export function AppShell({ children }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [theme, setTheme] = useState<"dark" | "light">("dark"); const [theme, setTheme] = useState<'dark' | 'light'>('dark');
// Apply theme class to html element // Apply theme class to html element
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
if (theme === "light") { if (theme === 'light') {
root.classList.add("light"); root.classList.add('light');
} else { } else {
root.classList.remove("light"); root.classList.remove('light');
} }
}, [theme]); }, [theme]);
const toggleTheme = () => { const toggleTheme = () => {
setTheme((prev) => (prev === "dark" ? "light" : "dark")); setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
}; };
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<AppSidebar <AppSidebar
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)} onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
/> />
{/* Main content area */} {/* Main content area */}
<div <div
className={cn( className={cn(
"flex min-h-screen flex-col transition-all duration-200", 'flex min-h-screen flex-col transition-all duration-200',
sidebarCollapsed ? "pl-16" : "pl-60", sidebarCollapsed ? 'pl-16' : 'pl-60',
)} )}
> >
<AppHeader theme={theme} onToggleTheme={toggleTheme} /> <AppHeader theme={theme} onToggleTheme={toggleTheme} />
{/* Scrollable main content */} {/* Scrollable main content */}
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">
<div className="mx-auto max-w-[1280px] p-6">{children}</div> <div className="mx-auto max-w-[1280px] p-6">{children}</div>
</main> </main>
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,107 +1,107 @@
import { Link, useLocation } from "@tanstack/react-router"; import { Link, useLocation } from '@tanstack/react-router';
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { import {
Tooltip, ChevronLeft,
TooltipContent, ChevronRight,
TooltipProvider, Folder,
TooltipTrigger, Home,
} from "@/components/ui/tooltip"; Settings,
Swords,
Users,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { import {
Home, Tooltip,
Users, TooltipContent,
Folder, TooltipProvider,
Swords, TooltipTrigger,
Settings, } from '@/components/ui/tooltip';
ChevronLeft, import { cn } from '@/lib/utils';
ChevronRight,
} from "lucide-react";
const navItems = [ const navItems = [
{ icon: Home, label: "Dashboard", href: "/" }, { icon: Home, label: 'Dashboard', href: '/' },
{ icon: Users, label: "Personnages", href: "/characters" }, { icon: Users, label: 'Personnages', href: '/characters' },
{ icon: Folder, label: "Comptes", href: "/accounts" }, { icon: Folder, label: 'Comptes', href: '/accounts' },
{ icon: Swords, label: "Teams", href: "/teams" }, { icon: Swords, label: 'Teams', href: '/teams' },
{ icon: Settings, label: "Paramètres", href: "/settings" }, { icon: Settings, label: 'Paramètres', href: '/settings' },
]; ];
interface AppSidebarProps { interface AppSidebarProps {
collapsed: boolean; collapsed: boolean;
onToggle: () => void; onToggle: () => void;
} }
export function AppSidebar({ collapsed, onToggle }: AppSidebarProps) { export function AppSidebar({ collapsed, onToggle }: AppSidebarProps) {
const { pathname } = useLocation(); const { pathname } = useLocation();
return ( return (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<aside <aside
className={cn( 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", '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", collapsed ? 'w-16' : 'w-60',
)} )}
> >
{/* Header with logo and toggle */} {/* Header with logo and toggle */}
<div className="flex h-16 items-center justify-between border-b border-sidebar-border px-3"> <div className="flex h-16 items-center justify-between border-b border-sidebar-border px-3">
{!collapsed && ( {!collapsed && (
<span className="text-lg font-bold tracking-tight text-sidebar-foreground"> <span className="text-lg font-bold tracking-tight text-sidebar-foreground">
DOFUS MANAGER DOFUS MANAGER
</span> </span>
)} )}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={onToggle} onClick={onToggle}
className={cn( className={cn(
"h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent", 'h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent',
collapsed && "mx-auto", collapsed && 'mx-auto',
)} )}
> >
{collapsed ? ( {collapsed ? (
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
) : ( ) : (
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
)} )}
<span className="sr-only">Toggle sidebar</span> <span className="sr-only">Toggle sidebar</span>
</Button> </Button>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 space-y-1 p-2"> <nav className="flex-1 space-y-1 p-2">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = pathname === item.href; const isActive = pathname === item.href;
const Icon = item.icon; const Icon = item.icon;
const linkContent = ( const linkContent = (
<Link <Link
to={item.href} to={item.href}
className={cn( className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", 'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive isActive
? "bg-sidebar-primary text-sidebar-primary-foreground" ? 'bg-sidebar-primary text-sidebar-primary-foreground'
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", : 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
)} )}
> >
<Icon className="h-5 w-5 shrink-0" /> <Icon className="h-5 w-5 shrink-0" />
{!collapsed && <span>{item.label}</span>} {!collapsed && <span>{item.label}</span>}
</Link> </Link>
); );
if (collapsed) { if (collapsed) {
return ( return (
<Tooltip key={item.href}> <Tooltip key={item.href}>
<TooltipTrigger asChild>{linkContent}</TooltipTrigger> <TooltipTrigger asChild>{linkContent}</TooltipTrigger>
<TooltipContent side="right" className="font-medium"> <TooltipContent side="right" className="font-medium">
{item.label} {item.label}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
} }
return <div key={item.href}>{linkContent}</div>; return <div key={item.href}>{linkContent}</div>;
})} })}
</nav> </nav>
</aside> </aside>
</TooltipProvider> </TooltipProvider>
); );
} }

View File

@@ -1,273 +1,273 @@
import type React from "react";
import { import {
FolderOpen, ArrowRight,
Users, BarChart3,
Swords, ChevronDown,
Coins, Coins,
BarChart3, FolderOpen,
Plus, Plus,
ChevronDown, RefreshCw,
ArrowRight, Swords,
RefreshCw, Users,
} from "lucide-react"; } from 'lucide-react';
import type React from 'react';
import { Button } from '@/components/ui/button';
import { import {
Card, Card,
CardContent, CardContent,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from '@/components/ui/card';
import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from '@/components/ui/dropdown-menu';
// Custom progress bar with color support // Custom progress bar with color support
function ColoredProgress({ function ColoredProgress({
value, value,
color, color,
}: { }: {
value: number; value: number;
color: "success" | "warning" | "info"; color: 'success' | 'warning' | 'info';
}) { }) {
const colorClasses = { const colorClasses = {
success: "bg-[#4ADE80]", success: 'bg-[#4ADE80]',
warning: "bg-[#FBBF24]", warning: 'bg-[#FBBF24]',
info: "bg-[#60A5FA]", info: 'bg-[#60A5FA]',
}; };
return ( return (
<div className="relative h-2 w-full overflow-hidden rounded-full bg-[#334155]"> <div className="relative h-2 w-full overflow-hidden rounded-full bg-[#334155]">
<div <div
className={`h-full transition-all ${colorClasses[color]}`} className={`h-full transition-all ${colorClasses[color]}`}
style={{ width: `${value}%` }} style={{ width: `${value}%` }}
/> />
</div> </div>
); );
} }
// Stat card component with hover effect // Stat card component with hover effect
function StatCard({ function StatCard({
icon: Icon, icon: Icon,
title, title,
mainStat, mainStat,
secondary, secondary,
linkText, linkText,
children, children,
className = "", className = '',
}: { }: {
icon: React.ElementType; icon: React.ElementType;
title: string; title: string;
mainStat?: string; mainStat?: string;
secondary?: React.ReactNode; secondary?: React.ReactNode;
linkText: string; linkText: string;
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
}) { }) {
return ( return (
<Card <Card
className={`border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01] ${className}`} className={`border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01] ${className}`}
> >
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10"> <div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10">
<Icon className="size-5 text-[#60A5FA]" /> <Icon className="size-5 text-[#60A5FA]" />
</div> </div>
<CardTitle className="text-base font-semibold text-[#F8FAFC]"> <CardTitle className="text-base font-semibold text-[#F8FAFC]">
{title} {title}
</CardTitle> </CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-1"> <CardContent className="space-y-1">
{mainStat && ( {mainStat && (
<p className="text-2xl font-bold text-[#F8FAFC]">{mainStat}</p> <p className="text-2xl font-bold text-[#F8FAFC]">{mainStat}</p>
)} )}
{secondary && <div className="text-sm text-[#94A3B8]">{secondary}</div>} {secondary && <div className="text-sm text-[#94A3B8]">{secondary}</div>}
{children} {children}
</CardContent> </CardContent>
<CardFooter className="pt-2"> <CardFooter className="pt-2">
<button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline"> <button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline">
{linkText} {linkText}
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</button> </button>
</CardFooter> </CardFooter>
</Card> </Card>
); );
} }
export function Dashboard() { export function Dashboard() {
// Mock data // Mock data
const currencies = [ const currencies = [
{ name: "Doplons", amount: "12,450" }, { name: 'Doplons', amount: '12,450' },
{ name: "Orichor", amount: "3,200" }, { name: 'Orichor', amount: '3,200' },
{ name: "Kamas glace", amount: "8,100" }, { name: 'Kamas glace', amount: '8,100' },
{ name: "Nuggets", amount: "2,340" }, { name: 'Nuggets', amount: '2,340' },
]; ];
const progressions = [ const progressions = [
{ label: "Dofus", value: 72, color: "success" as const }, { label: 'Dofus', value: 72, color: 'success' as const },
{ label: "Donjons", value: 45, color: "warning" as const }, { label: 'Donjons', value: 45, color: 'warning' as const },
{ label: "Recherchés", value: 61, color: "info" as const }, { label: 'Recherchés', value: 61, color: 'info' as const },
]; ];
return ( return (
<div className="min-h-screen bg-[#0F172A] p-6"> <div className="min-h-screen bg-[#0F172A] p-6">
{/* Header */} {/* Header */}
<header className="mb-8 flex items-center justify-between"> <header className="mb-8 flex items-center justify-between">
<h1 className="text-[32px] font-bold text-[#F8FAFC]">DASHBOARD</h1> <h1 className="text-[32px] font-bold text-[#F8FAFC]">DASHBOARD</h1>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90"> <Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90">
<Plus className="size-4" /> <Plus className="size-4" />
Nouveau Nouveau
<ChevronDown className="size-4" /> <ChevronDown className="size-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="border-[#334155] bg-[#1E293B]"> <DropdownMenuContent className="border-[#334155] bg-[#1E293B]">
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"> <DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
Personnage Personnage
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"> <DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
Compte Compte
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]"> <DropdownMenuItem className="text-[#F8FAFC] focus:bg-[#334155] focus:text-[#F8FAFC]">
Team Team
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</header> </header>
{/* Widget Grid */} {/* Widget Grid */}
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
{/* Comptes Card */} {/* Comptes Card */}
<StatCard <StatCard
icon={FolderOpen} icon={FolderOpen}
title="Comptes" title="Comptes"
mainStat="12 comptes" mainStat="12 comptes"
secondary="45,230 ogrines" secondary="45,230 ogrines"
linkText="Voir tout" linkText="Voir tout"
/> />
{/* Personnages Card */} {/* Personnages Card */}
<StatCard <StatCard
icon={Users} icon={Users}
title="Personnages" title="Personnages"
mainStat="64 personnages" mainStat="64 personnages"
secondary="Niv. moy: 198" secondary="Niv. moy: 198"
linkText="Voir tout" linkText="Voir tout"
/> />
{/* Teams Card */} {/* Teams Card */}
<StatCard <StatCard
icon={Swords} icon={Swords}
title="Teams" title="Teams"
mainStat="3 actives" mainStat="3 actives"
secondary={ secondary={
<div className="space-y-1"> <div className="space-y-1">
<span>87% complete</span> <span>87% complete</span>
<ColoredProgress value={87} color="info" /> <ColoredProgress value={87} color="info" />
</div> </div>
} }
linkText="Voir tout" linkText="Voir tout"
/> />
{/* Monnaies Card - spans 2 columns on desktop */} {/* 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"> <Card className="border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01] xl:col-span-2">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10"> <div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10">
<Coins className="size-5 text-[#60A5FA]" /> <Coins className="size-5 text-[#60A5FA]" />
</div> </div>
<CardTitle className="text-base font-semibold text-[#F8FAFC]"> <CardTitle className="text-base font-semibold text-[#F8FAFC]">
Monnaies Monnaies
</CardTitle> </CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{currencies.map((currency) => ( {currencies.map((currency) => (
<div key={currency.name} className="space-y-1"> <div key={currency.name} className="space-y-1">
<p className="text-sm text-[#94A3B8]">{currency.name}</p> <p className="text-sm text-[#94A3B8]">{currency.name}</p>
<p className="text-xl font-semibold text-[#F8FAFC]"> <p className="text-xl font-semibold text-[#F8FAFC]">
{currency.amount} {currency.amount}
</p> </p>
</div> </div>
))} ))}
</div> </div>
</CardContent> </CardContent>
<CardFooter className="pt-2"> <CardFooter className="pt-2">
<button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline"> <button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline">
Détail par compte Détail par compte
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</button> </button>
</CardFooter> </CardFooter>
</Card> </Card>
{/* Progressions Card */} {/* Progressions Card */}
<Card className="border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01]"> <Card className="border-[#334155] bg-[#1E293B] transition-transform duration-150 hover:scale-[1.01]">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10"> <div className="flex size-10 items-center justify-center rounded-lg bg-[#60A5FA]/10">
<BarChart3 className="size-5 text-[#60A5FA]" /> <BarChart3 className="size-5 text-[#60A5FA]" />
</div> </div>
<CardTitle className="text-base font-semibold text-[#F8FAFC]"> <CardTitle className="text-base font-semibold text-[#F8FAFC]">
Progressions Progressions
</CardTitle> </CardTitle>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{progressions.map((prog) => ( {progressions.map((prog) => (
<div key={prog.label} className="space-y-2"> <div key={prog.label} className="space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-[#F8FAFC]">{prog.label}</span> <span className="text-[#F8FAFC]">{prog.label}</span>
<span className="text-[#94A3B8]">{prog.value}%</span> <span className="text-[#94A3B8]">{prog.value}%</span>
</div> </div>
<ColoredProgress value={prog.value} color={prog.color} /> <ColoredProgress value={prog.value} color={prog.color} />
</div> </div>
))} ))}
</CardContent> </CardContent>
<CardFooter className="pt-2"> <CardFooter className="pt-2">
<button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline"> <button className="flex items-center gap-1 text-sm text-[#60A5FA] hover:underline">
Bulk Update Bulk Update
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</button> </button>
</CardFooter> </CardFooter>
</Card> </Card>
</div> </div>
{/* Quick Actions Section */} {/* Quick Actions Section */}
<section className="mt-8"> <section className="mt-8">
<h2 className="mb-4 text-lg font-semibold text-[#F8FAFC]"> <h2 className="mb-4 text-lg font-semibold text-[#F8FAFC]">
Actions Rapides Actions Rapides
</h2> </h2>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90"> <Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90">
<Plus className="size-4" /> <Plus className="size-4" />
Personnage Personnage
</Button> </Button>
<Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90"> <Button className="gap-2 rounded-[6px] bg-[#60A5FA] text-[#0F172A] hover:bg-[#60A5FA]/90">
<Plus className="size-4" /> <Plus className="size-4" />
Team Team
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
className="gap-2 rounded-[6px] border-[#334155] bg-[#1E293B] text-[#F8FAFC] hover:bg-[#334155]" className="gap-2 rounded-[6px] border-[#334155] bg-[#1E293B] text-[#F8FAFC] hover:bg-[#334155]"
> >
<BarChart3 className="size-4" /> <BarChart3 className="size-4" />
Bulk Progressions Bulk Progressions
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
className="gap-2 rounded-[6px] border-[#334155] bg-[#1E293B] text-[#F8FAFC] hover:bg-[#334155]" className="gap-2 rounded-[6px] border-[#334155] bg-[#1E293B] text-[#F8FAFC] hover:bg-[#334155]"
> >
<RefreshCw className="size-4" /> <RefreshCw className="size-4" />
Sync DofusDB Sync DofusDB
</Button> </Button>
</div> </div>
</section> </section>
</div> </div>
); );
} }

View File

@@ -1,106 +1,106 @@
import { useState } from "react"; import { Check, ChevronDown, ChevronRight } from 'lucide-react';
import { ChevronDown, ChevronRight, Check } from "lucide-react"; import { useState } from 'react';
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from '@/components/ui/checkbox';
import { import {
Collapsible, Collapsible,
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from '@/components/ui/collapsible';
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils';
interface ProgressionItem { interface ProgressionItem {
id: string; id: string;
name: string; name: string;
completed: boolean; completed: boolean;
completedDate?: string; completedDate?: string;
} }
interface ProgressionSectionProps { interface ProgressionSectionProps {
title: string; title: string;
items: ProgressionItem[]; items: ProgressionItem[];
filter: "all" | "done" | "not-done"; filter: 'all' | 'done' | 'not-done';
} }
export function ProgressionSection({ export function ProgressionSection({
title, title,
items, items,
filter, filter,
}: ProgressionSectionProps) { }: ProgressionSectionProps) {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const [localItems, setLocalItems] = useState(items); const [localItems, setLocalItems] = useState(items);
const filteredItems = localItems.filter((item) => { const filteredItems = localItems.filter((item) => {
if (filter === "done") return item.completed; if (filter === 'done') return item.completed;
if (filter === "not-done") return !item.completed; if (filter === 'not-done') return !item.completed;
return true; return true;
}); });
const completedCount = localItems.filter((item) => item.completed).length; const completedCount = localItems.filter((item) => item.completed).length;
const totalCount = localItems.length; const totalCount = localItems.length;
const handleToggle = (id: string) => { const handleToggle = (id: string) => {
setLocalItems((prev) => setLocalItems((prev) =>
prev.map((item) => prev.map((item) =>
item.id === id item.id === id
? { ? {
...item, ...item,
completed: !item.completed, completed: !item.completed,
completedDate: !item.completed completedDate: !item.completed
? new Date().toISOString().split("T")[0] ? new Date().toISOString().split('T')[0]
: undefined, : undefined,
} }
: item, : item,
), ),
); );
}; };
if (filteredItems.length === 0) return null; if (filteredItems.length === 0) return null;
return ( return (
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="space-y-2"> <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"> <CollapsibleTrigger className="flex w-full items-center gap-2 py-2 text-left hover:bg-secondary/50 rounded-md px-2 transition-colors">
{isOpen ? ( {isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" /> <ChevronDown className="h-4 w-4 text-muted-foreground" />
) : ( ) : (
<ChevronRight 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="font-medium text-foreground">{title}</span>
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
({completedCount}/{totalCount}) ({completedCount}/{totalCount})
</span> </span>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent className="space-y-1 pl-6"> <CollapsibleContent className="space-y-1 pl-6">
{filteredItems.map((item) => ( {filteredItems.map((item) => (
<div <div
key={item.id} key={item.id}
className="flex items-center gap-3 py-2 px-2 rounded-md hover:bg-secondary/30 transition-colors" className="flex items-center gap-3 py-2 px-2 rounded-md hover:bg-secondary/30 transition-colors"
> >
<Checkbox <Checkbox
id={item.id} id={item.id}
checked={item.completed} checked={item.completed}
onCheckedChange={() => handleToggle(item.id)} onCheckedChange={() => handleToggle(item.id)}
className="border-muted-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary" className="border-muted-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/> />
<label <label
htmlFor={item.id} htmlFor={item.id}
className={cn( className={cn(
"flex-1 text-sm cursor-pointer", 'flex-1 text-sm cursor-pointer',
item.completed ? "text-foreground" : "text-muted-foreground", item.completed ? 'text-foreground' : 'text-muted-foreground',
)} )}
> >
{item.name} {item.name}
</label> </label>
{item.completed ? ( {item.completed ? (
<div className="flex items-center gap-1.5 text-sm"> <div className="flex items-center gap-1.5 text-sm">
<Check className="h-3.5 w-3.5" style={{ color: "#4ADE80" }} /> <Check className="h-3.5 w-3.5" style={{ color: '#4ADE80' }} />
<span style={{ color: "#4ADE80" }}>{item.completedDate}</span> <span style={{ color: '#4ADE80' }}>{item.completedDate}</span>
</div> </div>
) : ( ) : (
<span className="text-muted-foreground text-sm"></span> <span className="text-muted-foreground text-sm"></span>
)} )}
</div> </div>
))} ))}
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
); );
} }

View File

@@ -1,225 +1,225 @@
import { useState } from "react";
import { import {
ChevronRight, CheckCircle,
Pencil, ChevronRight,
Trash2, Pencil,
CheckCircle, Trash2,
XCircle, XCircle,
} from "lucide-react"; } from 'lucide-react';
import { Card, CardContent } from "@/components/ui/card"; import { useState } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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 { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from '@/components/ui/select';
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { import {
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from '@/components/ui/table';
import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ConfirmationModal } from "@/components/confirmation-modal";
// Mock data // Mock data
const teamMembers = [ const teamMembers = [
{ id: 1, name: "Krosmaster", completed: true, date: "2026-01-10" }, { id: 1, name: 'Krosmaster', completed: true, date: '2026-01-10' },
{ id: 2, name: "TankMaster", 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: 3, name: 'HealBot', completed: false, date: null },
{ id: 4, name: "SramKiller", completed: false, date: null }, { id: 4, name: 'SramKiller', completed: false, date: null },
{ id: 5, name: "Eniripsa", completed: true, date: "2026-01-09" }, { id: 5, name: 'Eniripsa', completed: true, date: '2026-01-09' },
{ id: 6, name: "Sacrieur", completed: true, date: "2026-01-08" }, { id: 6, name: 'Sacrieur', completed: true, date: '2026-01-08' },
{ id: 7, name: "Pandawa", completed: true, date: "2026-01-10" }, { id: 7, name: 'Pandawa', completed: true, date: '2026-01-10' },
{ id: 8, name: "Eliotrope", completed: true, date: "2026-01-07" }, { id: 8, name: 'Eliotrope', completed: true, date: '2026-01-07' },
]; ];
const progressions = [ const progressions = [
{ id: "dofus-turquoise", name: "Dofus Turquoise" }, { id: 'dofus-turquoise', name: 'Dofus Turquoise' },
{ id: "donjon-bethel", name: "Donjon Bethel" }, { id: 'donjon-bethel', name: 'Donjon Bethel' },
{ id: "quete-ebene", name: "Quête Ébène" }, { id: 'quete-ebene', name: 'Quête Ébène' },
{ id: "succes-dimension", name: "Succès Dimension" }, { id: 'succes-dimension', name: 'Succès Dimension' },
]; ];
export default function TeamDetailPage() { export default function TeamDetailPage() {
const [selectedProgression, setSelectedProgression] = const [selectedProgression, setSelectedProgression] =
useState("dofus-turquoise"); useState('dofus-turquoise');
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const completedCount = teamMembers.filter((m) => m.completed).length; const completedCount = teamMembers.filter((m) => m.completed).length;
const totalCount = teamMembers.length; const totalCount = teamMembers.length;
const incompleteCount = totalCount - completedCount; const incompleteCount = totalCount - completedCount;
const progressPercentage = Math.round((completedCount / totalCount) * 100); const progressPercentage = Math.round((completedCount / totalCount) * 100);
const selectedProgressionName = const selectedProgressionName =
progressions.find((p) => p.id === selectedProgression)?.name || ""; progressions.find((p) => p.id === selectedProgression)?.name || '';
return ( return (
<div className="min-h-screen bg-background p-6"> <div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-4xl space-y-6"> <div className="mx-auto max-w-4xl space-y-6">
{/* Header with Breadcrumb and Actions */} {/* Header with Breadcrumb and Actions */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<nav className="flex items-center gap-1 text-sm text-muted-foreground"> <nav className="flex items-center gap-1 text-sm text-muted-foreground">
<span className="hover:text-foreground cursor-pointer">Teams</span> <span className="hover:text-foreground cursor-pointer">Teams</span>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
<span className="text-foreground">Main Team</span> <span className="text-foreground">Main Team</span>
</nav> </nav>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8"> <Button variant="ghost" size="icon" className="h-8 w-8">
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
<span className="sr-only">Edit team</span> <span className="sr-only">Edit team</span>
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-destructive hover:text-destructive" className="h-8 w-8 text-destructive hover:text-destructive"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
<span className="sr-only">Delete team</span> <span className="sr-only">Delete team</span>
</Button> </Button>
</div> </div>
</div> </div>
{/* Team Info Card */} {/* Team Info Card */}
<Card className="rounded-lg"> <Card className="rounded-lg">
<CardContent className="p-6"> <CardContent className="p-6">
<h1 className="text-2xl font-bold tracking-tight">MAIN TEAM</h1> <h1 className="text-2xl font-bold tracking-tight">MAIN TEAM</h1>
<p className="mt-1 text-sm text-muted-foreground">Type: Main</p> <p className="mt-1 text-sm text-muted-foreground">Type: Main</p>
<div className="mt-4 flex items-center gap-3 text-sm"> <div className="mt-4 flex items-center gap-3 text-sm">
<span>{totalCount} membres</span> <span>{totalCount} membres</span>
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<Badge <Badge
variant="secondary" variant="secondary"
className="bg-[#4ADE80]/20 text-[#4ADE80] hover:bg-[#4ADE80]/30" className="bg-[#4ADE80]/20 text-[#4ADE80] hover:bg-[#4ADE80]/30"
> >
Active Active
</Badge> </Badge>
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span>{totalCount} comptes différents</span> <span>{totalCount} comptes différents</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue="statut-progressions" className="w-full"> <Tabs defaultValue="statut-progressions" className="w-full">
<TabsList className="w-full justify-start rounded-lg bg-card"> <TabsList className="w-full justify-start rounded-lg bg-card">
<TabsTrigger value="membres" className="rounded-md"> <TabsTrigger value="membres" className="rounded-md">
Membres Membres
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="statut-progressions" className="rounded-md"> <TabsTrigger value="statut-progressions" className="rounded-md">
Statut Progressions Statut Progressions
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
{/* Membres Tab - Placeholder */} {/* Membres Tab - Placeholder */}
<TabsContent value="membres" className="mt-4"> <TabsContent value="membres" className="mt-4">
<Card className="rounded-lg"> <Card className="rounded-lg">
<CardContent className="p-6"> <CardContent className="p-6">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Liste des membres à venir... Liste des membres à venir...
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
{/* Statut Progressions Tab */} {/* Statut Progressions Tab */}
<TabsContent value="statut-progressions" className="mt-4 space-y-4"> <TabsContent value="statut-progressions" className="mt-4 space-y-4">
{/* Progression Selector */} {/* Progression Selector */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label className="text-sm font-medium">Progression:</label> <label className="text-sm font-medium">Progression:</label>
<Select <Select
value={selectedProgression} value={selectedProgression}
onValueChange={setSelectedProgression} onValueChange={setSelectedProgression}
> >
<SelectTrigger className="w-[220px] rounded-md"> <SelectTrigger className="w-[220px] rounded-md">
<SelectValue placeholder="Sélectionner une progression" /> <SelectValue placeholder="Sélectionner une progression" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{progressions.map((prog) => ( {progressions.map((prog) => (
<SelectItem key={prog.id} value={prog.id}> <SelectItem key={prog.id} value={prog.id}>
{prog.name} {prog.name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Progress Summary */} {/* Progress Summary */}
<Card className="rounded-lg"> <Card className="rounded-lg">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="font-medium"> <span className="font-medium">
{progressPercentage}% ({completedCount}/{totalCount}) {progressPercentage}% ({completedCount}/{totalCount})
</span> </span>
</div> </div>
<Progress <Progress
value={progressPercentage} value={progressPercentage}
className="h-4 rounded-md" className="h-4 rounded-md"
/> />
</div> </div>
<Button <Button
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
disabled={incompleteCount === 0} disabled={incompleteCount === 0}
className="rounded-md" className="rounded-md"
> >
Marquer tous comme fait Marquer tous comme fait
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Status Table */} {/* Status Table */}
<Card className="rounded-lg"> <Card className="rounded-lg">
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent"> <TableRow className="hover:bg-transparent">
<TableHead className="h-10">Perso</TableHead> <TableHead className="h-10">Perso</TableHead>
<TableHead className="h-10">Statut</TableHead> <TableHead className="h-10">Statut</TableHead>
<TableHead className="h-10">Date</TableHead> <TableHead className="h-10">Date</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{teamMembers.map((member) => ( {teamMembers.map((member) => (
<TableRow key={member.id} className="h-10"> <TableRow key={member.id} className="h-10">
<TableCell className="py-2 font-medium"> <TableCell className="py-2 font-medium">
{member.name} {member.name}
</TableCell> </TableCell>
<TableCell className="py-2"> <TableCell className="py-2">
{member.completed ? ( {member.completed ? (
<CheckCircle className="h-5 w-5 text-[#4ADE80]" /> <CheckCircle className="h-5 w-5 text-[#4ADE80]" />
) : ( ) : (
<XCircle className="h-5 w-5 text-[#F87171]" /> <XCircle className="h-5 w-5 text-[#F87171]" />
)} )}
</TableCell> </TableCell>
<TableCell className="py-2 text-muted-foreground"> <TableCell className="py-2 text-muted-foreground">
{member.date || "—"} {member.date || '—'}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
{/* Confirmation Modal */} {/* Confirmation Modal */}
<ConfirmationModal <ConfirmationModal
open={isModalOpen} open={isModalOpen}
onOpenChange={setIsModalOpen} onOpenChange={setIsModalOpen}
progressionName={selectedProgressionName} progressionName={selectedProgressionName}
incompleteCount={incompleteCount} incompleteCount={incompleteCount}
/> />
</div> </div>
); );
} }

View 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 }

View 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 };

View 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,
};

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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 };

View 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 }

View 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 }

View 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
View 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,
};

View 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 }

View 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
View 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
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

241
src/routeTree.gen.ts Normal file
View 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
View 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;
};

View File

@@ -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 }) { import appCss from '../app/globals.css?url';
return <AppShell>{children}</AppShell>;
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
View 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
View 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
View 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;
}

View 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
View 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
View 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