Compare commits

...

10 Commits

Author SHA1 Message Date
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
64 changed files with 8692 additions and 1329 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"

135
.gitea/workflow/ci.yml Normal file
View File

@@ -0,0 +1,135 @@
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: 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 }}/dofus-manager:${{ github.sha }}
${{ vars.REGISTRY_URL }}/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..."
# SSH ou webhook vers votre serveur 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 }}"
# 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
}
}

290
README.md Normal file
View File

@@ -0,0 +1,290 @@
Welcome to your new TanStack app!
# Getting Started
To run this application:
```bash
pnpm install
pnpm dev
```
# Building For Production
To build this application for production:
```bash
pnpm build
```
## Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
```bash
pnpm test
```
## Styling
This project uses CSS for styling.
## Routing
This project uses [TanStack Router](https://tanstack.com/router). The initial setup is a file based router. Which means that the routes are managed as files in `src/routes`.
### Adding A Route
To add a new route to your application just add another a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you use the `<Outlet />` component.
Here is an example layout that includes a header:
```tsx
import { Outlet, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { Link } from "@tanstack/react-router";
export const Route = createRootRoute({
component: () => (
<>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
<Outlet />
<TanStackRouterDevtools />
</>
),
})
```
The `<TanStackRouterDevtools />` component is not required so you can remove it if you don't want it in your layout.
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
const peopleRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/people",
loader: async () => {
const response = await fetch("https://swapi.dev/api/people");
return response.json() as Promise<{
results: {
name: string;
}[];
}>;
},
component: () => {
const data = peopleRoute.useLoaderData();
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
);
},
});
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
### React-Query
React-Query is an excellent addition or alternative to route loading and integrating it into you application is a breeze.
First add your dependencies:
```bash
pnpm add @tanstack/react-query @tanstack/react-query-devtools
```
Next we'll need to create a query client and provider. We recommend putting those in `main.tsx`.
```tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// ...
const queryClient = new QueryClient();
// ...
if (!rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
```
You can also add TanStack Query Devtools to the root route (optional).
```tsx
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const rootRoute = createRootRoute({
component: () => (
<>
<Outlet />
<ReactQueryDevtools buttonPosition="top-right" />
<TanStackRouterDevtools />
</>
),
});
```
Now you can use `useQuery` to fetch your data.
```tsx
import { useQuery } from "@tanstack/react-query";
import "./App.css";
function App() {
const { data } = useQuery({
queryKey: ["people"],
queryFn: () =>
fetch("https://swapi.dev/api/people")
.then((res) => res.json())
.then((data) => data.results as { name: string }[]),
initialData: [],
});
return (
<div>
<ul>
{data.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
</div>
);
}
export default App;
```
You can find out everything you need to know on how to use React-Query in the [React-Query documentation](https://tanstack.com/query/latest/docs/framework/react/overview).
## State Management
Another common requirement for React applications is state management. There are many options for state management in React. TanStack Store provides a great starting point for your project.
First you need to add TanStack Store as a dependency:
```bash
pnpm add @tanstack/store
```
Now let's create a simple counter in the `src/App.tsx` file as a demonstration.
```tsx
import { useStore } from "@tanstack/react-store";
import { Store } from "@tanstack/store";
import "./App.css";
const countStore = new Store(0);
function App() {
const count = useStore(countStore);
return (
<div>
<button onClick={() => countStore.setState((n) => n + 1)}>
Increment - {count}
</button>
</div>
);
}
export default App;
```
One of the many nice features of TanStack Store is the ability to derive state from other state. That derived state will update when the base state updates.
Let's check this out by doubling the count using derived state.
```tsx
import { useStore } from "@tanstack/react-store";
import { Store, Derived } from "@tanstack/store";
import "./App.css";
const countStore = new Store(0);
const doubledStore = new Derived({
fn: () => countStore.state * 2,
deps: [countStore],
});
doubledStore.mount();
function App() {
const count = useStore(countStore);
const doubledCount = useStore(doubledStore);
return (
<div>
<button onClick={() => countStore.setState((n) => n + 1)}>
Increment - {count}
</button>
<div>Doubled - {doubledCount}</div>
</div>
);
}
export default App;
```
We use the `Derived` class to create a new store that is derived from another store. The `Derived` class has a `mount` method that will start the derived store updating.
Once we've created the derived store we can use it in the `App` component just like we would any other store using the `useStore` hook.
You can find out everything you need to know on how to use TanStack Store in the [TanStack Store documentation](https://tanstack.com/store/latest).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).

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

View File

@@ -16,56 +16,57 @@ Draft
2. Docker Compose configuration with app service and PostgreSQL 16
3. Prisma configured and connected to PostgreSQL
4. shadcn/ui installed with base components (Button, Input, Card, Table)
5. ESLint + Prettier configured with recommended rules
6. GitLab CI pipeline: build, lint, test stages
5. Biome configured for linting and formatting
6. Gitea Actions workflow: build, lint, test stages
7. Dockerfile multi-stage pour production build
8. README avec instructions de setup local
9. Application démarre et affiche une page d'accueil "Dofus Manager"
10. Health check endpoint `/api/health` pour Docker healthcheck
## Tasks / Subtasks
- [ ] Task 1: Initialize TanStack Start project (AC: 1)
- [ ] Create new TanStack Start project with `pnpm create @tanstack/start`
- [ ] Configure `tsconfig.json` with strict mode enabled
- [ ] Configure path aliases (`@/` pointing to `src/`)
- [ ] Verify TypeScript strict compilation works
- [x] Task 1: Initialize TanStack Start project (AC: 1)
- [x] Create new TanStack Start project with `pnpm create @tanstack/start`
- [x] Configure `tsconfig.json` with strict mode enabled
- [x] Configure path aliases (`@/` pointing to `src/`)
- [x] Verify TypeScript strict compilation works
- [ ] Task 2: Setup Docker environment (AC: 2, 7)
- [ ] Create `docker/` directory
- [ ] Create `docker/Dockerfile` with multi-stage build (builder + runner)
- [ ] Create `docker/docker-compose.yml` with app and postgres services
- [ ] Create `docker/docker-compose.dev.yml` for local development (postgres only)
- [ ] Configure PostgreSQL 16-alpine with healthcheck
- [ ] Test database connectivity
- [x] Task 2: Setup Docker environment (AC: 2, 7)
- [x] Create `docker/` directory
- [x] Create `docker/Dockerfile` with multi-stage build (builder + runner)
- [x] Create `docker/docker-compose.yml` with app and postgres services
- [x] Create `docker/docker-compose.dev.yml` for local development (postgres only)
- [x] Configure PostgreSQL 16-alpine with healthcheck
- [x] Test database connectivity
- [ ] Task 3: Configure Prisma ORM (AC: 3)
- [ ] Install Prisma dependencies (`prisma`, `@prisma/client`)
- [ ] Initialize Prisma with `pnpm prisma init`
- [ ] Configure `prisma/schema.prisma` with PostgreSQL provider
- [ ] Create `src/lib/server/db.ts` for Prisma client singleton
- [ ] Create `.env.example` with DATABASE_URL template
- [ ] Verify Prisma connects to database
- [x] Task 3: Configure Prisma ORM (AC: 3)
- [x] Install Prisma dependencies (`prisma`, `@prisma/client`)
- [x] Initialize Prisma with `pnpm prisma init`
- [x] Configure `prisma/schema.prisma` with PostgreSQL provider
- [x] Create `src/lib/server/db.ts` for Prisma client singleton
- [x] Create `.env.example` with DATABASE_URL template
- [x] Verify Prisma connects to database
- [ ] Task 4: Install and configure shadcn/ui (AC: 4)
- [ ] Install Tailwind CSS 4.x
- [ ] Initialize shadcn/ui with `pnpm dlx shadcn@latest init`
- [ ] Configure `components.json` for path aliases
- [ ] Install base components: Button, Input, Card, Table
- [ ] Create `src/lib/utils.ts` with `cn` utility function
- [ ] Install Lucide React for icons
- [x] Task 4: Install and configure shadcn/ui (AC: 4)
- [x] Install Tailwind CSS 4.x
- [x] Initialize shadcn/ui with `pnpm dlx shadcn@latest init`
- [x] Configure `components.json` for path aliases
- [x] Install base components: Button, Input, Card, Table
- [x] Create `src/lib/utils.ts` with `cn` utility function
- [x] Install Lucide React for icons
- [ ] Task 5: Configure linting and formatting (AC: 5)
- [ ] Install Biome (as specified in tech stack, not ESLint+Prettier)
- [ ] Create `biome.json` with recommended rules
- [ ] Add lint and format scripts to `package.json`
- [ ] Verify linting works on project files
- [x] Task 5: Configure linting and formatting (AC: 5)
- [x] Install Biome (as specified in tech stack, not ESLint+Prettier)
- [x] Create `biome.json` with recommended rules
- [x] Add lint and format scripts to `package.json`
- [x] Verify linting works on project files
- [ ] Task 6: Setup GitLab CI/CD pipeline (AC: 6)
- [ ] Create `.gitlab-ci.yml` with stages: test, build, deploy
- [ ] Configure test stage: lint, typecheck, test
- [ ] Configure build stage: Docker image build and push
- [ ] Configure deploy stages (staging/production) with manual triggers
- [ ] Add caching for node_modules
- [ ] Task 6: Setup Gitea Actions workflow (AC: 6)
- [x] Create `.gitea/workflows/ci.yml`
- [x] Configure test job: lint, typecheck, test
- [x] Configure build job: Docker image build and push
- [x] Configure deploy jobs (staging/production) with manual triggers
- [ ] Add caching for pnpm store
- [ ] Task 7: Create README documentation (AC: 8)
- [ ] Document project overview
@@ -81,7 +82,13 @@ Draft
- [ ] Create `src/styles/globals.css` with Tailwind imports
- [ ] Verify application starts and renders correctly
- [ ] Task 9: Final verification
- [ ] Task 9: Create health check endpoint (AC: 10)
- [ ] Create `src/routes/api/health.ts` server function
- [ ] Return JSON `{ status: "ok", timestamp: Date }`
- [ ] Optionally check database connectivity
- [ ] Verify endpoint responds at `GET /api/health`
- [ ] Task 10: Final verification
- [ ] Run `pnpm dev` and verify app starts
- [ ] Run `pnpm lint` and verify no errors
- [ ] Run `pnpm typecheck` and verify no errors
@@ -118,7 +125,7 @@ Draft
- Docker
- Docker Compose
- GitLab CI
- Gitea Actions
**Dev Tools:**
@@ -131,9 +138,13 @@ Draft
```
dofus-manager/
├── .gitea/
│ └── workflows/
│ └── ci.yml
├── docker/
│ ├── Dockerfile
── docker-compose.yml
── docker-compose.yml
│ └── docker-compose.dev.yml
├── prisma/
│ ├── schema.prisma
│ └── migrations/
@@ -156,7 +167,9 @@ dofus-manager/
│ │ └── logger.ts
│ ├── routes/
│ │ ├── __root.tsx
│ │ ── index.tsx
│ │ ── index.tsx
│ │ └── api/
│ │ └── health.ts
│ ├── styles/
│ │ └── globals.css
│ └── app.tsx
@@ -187,22 +200,66 @@ dofus-manager/
- PostgreSQL 16-alpine with healthcheck
- Traefik labels for reverse proxy (production)
### GitLab CI/CD [Source: architecture/14-deployment-architecture.md#gitlab-cicd-pipeline]
### Gitea Actions [Adapted from architecture]
**Stages:** test, build, deploy
**Workflow file:** `.gitea/workflows/ci.yml`
**Test stage:**
**Jobs:** test, build, deploy
- image: node:20-alpine
- Commands: pnpm lint, pnpm typecheck, pnpm test
- Cache node_modules
**Test job:**
**Build stage:**
- runs-on: ubuntu-latest
- Steps: checkout, setup pnpm, setup node, install, lint, typecheck, test
- Cache pnpm store
- image: docker:24
- Build and push Docker image to registry
**Build job:**
- needs: test
- Build and push Docker image
- Only on main/develop branches
**Exemple de workflow:**
```yaml
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile
push: true
tags: registry.example.com/dofus-manager:${{ github.sha }}
```
### Environment Variables [Source: architecture/13-development-workflow.md#environment-variables]
```bash
@@ -234,9 +291,45 @@ SESSION_SECRET="your-secret-key-min-32-chars"
- Types/Interfaces: PascalCase
- Constants: SCREAMING_SNAKE_CASE
### Important Discrepancy Note
### Health Check Endpoint [Source: architecture/19-monitoring-observability.md]
AC #5 specifies "ESLint + Prettier" but the architecture documents (3-technology-stack.md) specify **Biome** for linting and formatting. Recommend following the architecture document and using Biome instead, as it's the project standard.
**Endpoint:** `GET /api/health`
**Response:**
```json
{
"status": "ok",
"timestamp": "2026-01-19T10:00:00.000Z",
"database": "connected"
}
```
**Implementation avec TanStack Start:**
```typescript
// src/routes/api/health.ts
import { createAPIFileRoute } from "@tanstack/start/api";
import { prisma } from "@/lib/server/db";
export const Route = createAPIFileRoute("/api/health")({
GET: async () => {
let dbStatus = "disconnected";
try {
await prisma.$queryRaw`SELECT 1`;
dbStatus = "connected";
} catch {
dbStatus = "error";
}
return Response.json({
status: "ok",
timestamp: new Date().toISOString(),
database: dbStatus,
});
},
});
```
## Testing
@@ -264,9 +357,10 @@ AC #5 specifies "ESLint + Prettier" but the architecture documents (3-technology
## Change Log
| Date | Version | Description | Author |
| ---------- | ------- | ---------------------- | -------- |
| 2026-01-19 | 1.0 | Initial story creation | SM Agent |
| Date | Version | Description | Author |
| ---------- | ------- | --------------------------------------------- | -------- |
| 2026-01-19 | 1.0 | Initial story creation | SM Agent |
| 2026-01-19 | 1.1 | Gitea Actions, Biome, Health endpoint ajoutés | SM Agent |
---

53
package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"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/client": "^7.2.0",
"@radix-ui/react-slot": "^1.2.4",
"@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",
"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/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"
}
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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,13 @@
import { createServerFn } from '@tanstack/react-start';
export const getPunkSongs = createServerFn({
method: 'GET',
}).handler(async () => [
{ id: 1, name: 'Teenage Dirtbag', artist: 'Wheatus' },
{ id: 2, name: 'Smells Like Teen Spirit', artist: 'Nirvana' },
{ id: 3, name: 'The Middle', artist: 'Jimmy Eat World' },
{ id: 4, name: 'My Own Worst Enemy', artist: 'Lit' },
{ id: 5, name: 'Fat Lip', artist: 'Sum 41' },
{ id: 6, name: 'All the Small Things', artist: 'blink-182' },
{ id: 7, name: 'Beverly Hills', artist: 'Weezer' },
]);

18
src/lib/server/db.ts Normal file
View File

@@ -0,0 +1,18 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
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

240
src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,240 @@
/* 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 IndexRouteImport } from './routes/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs'
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request'
import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names'
import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.index'
import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode'
import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr'
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
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 { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
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,58 @@
import { AppShell } from "@/components/layout/app-shell";
import { TanStackDevtools } from '@tanstack/react-devtools';
import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router';
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
export default function RootLayout({ children }) {
return <AppShell>{children}</AppShell>;
import Header from '../components/Header';
import appCss from '../styles.css?url';
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<Header />
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
);
}

21
src/routes/api/health.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createFileRoute } from '@tanstack/react-router'
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,
})
},
});

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router';
import { json } from '@tanstack/react-start';
export const Route = createFileRoute('/demo/api/names')({
server: {
handlers: {
GET: () => json(['Alice', 'Bob', 'Charlie']),
},
},
});

View File

@@ -0,0 +1,34 @@
import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import './start.css';
function getNames() {
return fetch('/demo/api/names').then(
(res) => res.json() as Promise<string[]>,
);
}
export const Route = createFileRoute('/demo/start/api-request')({
component: Home,
});
function Home() {
const [names, setNames] = useState<Array<string>>([]);
useEffect(() => {
getNames().then(setNames);
}, []);
return (
<div className="api-page">
<div className="content">
<h1>Start API Request Demo - Names List</h1>
<ul>
{names.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</div>
</div>
);
}

43
src/routes/demo/start.css Normal file
View File

@@ -0,0 +1,43 @@
.api-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1rem;
color: #fff;
}
.api-page .content {
width: 100%;
max-width: 2xl;
padding: 8rem;
border-radius: 1rem;
backdrop-filter: blur(1rem);
background-color: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 1rem 0 rgba(0, 0, 0, 0.1);
border: 0.5rem solid rgba(0, 0, 0, 0.1);
}
.api-page .content h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
.api-page .content ul {
margin-bottom: 1rem;
list-style: none;
padding: 0;
}
.api-page .content li {
background-color: rgba(255, 255, 255, 0.1);
padding: 0.5rem;
border-radius: 0.5rem;
border: 1px solid rgba(255, 255, 255, 0.2);
backdrop-filter: blur(0.5rem);
box-shadow: 0 0 0.5rem 0 rgba(255, 255, 255, 0.1);
}
.api-page .content li span {
font-size: 1.2rem;
}

View File

@@ -0,0 +1,92 @@
import fs from 'node:fs';
import { createFileRoute, useRouter } from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { useCallback, useState } from 'react';
import './start.css';
/*
const loggingMiddleware = createMiddleware().server(
async ({ next, request }) => {
console.log("Request:", request.url);
return next();
}
);
const loggedServerFunction = createServerFn({ method: "GET" }).middleware([
loggingMiddleware,
]);
*/
const TODOS_FILE = 'todos.json';
async function readTodos() {
return JSON.parse(
await fs.promises.readFile(TODOS_FILE, 'utf-8').catch(() =>
JSON.stringify(
[
{ id: 1, name: 'Get groceries' },
{ id: 2, name: 'Buy a new phone' },
],
null,
2,
),
),
);
}
const getTodos = createServerFn({
method: 'GET',
}).handler(async () => await readTodos());
const addTodo = createServerFn({ method: 'POST' })
.inputValidator((d: string) => d)
.handler(async ({ data }) => {
const todos = await readTodos();
todos.push({ id: todos.length + 1, name: data });
await fs.promises.writeFile(TODOS_FILE, JSON.stringify(todos, null, 2));
return todos;
});
export const Route = createFileRoute('/demo/start/server-funcs')({
component: Home,
loader: async () => await getTodos(),
});
function Home() {
const router = useRouter();
let todos = Route.useLoaderData();
const [todo, setTodo] = useState('');
const submitTodo = useCallback(async () => {
todos = await addTodo({ data: todo });
setTodo('');
router.invalidate();
}, [addTodo, todo]);
return (
<div>
<h1>Start Server Functions - Todo Example</h1>
<ul>
{todos?.map((t) => (
<li key={t.id}>{t.name}</li>
))}
</ul>
<div className="flex flex-col gap-2">
<input
type="text"
value={todo}
onChange={(e) => setTodo(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
submitTodo();
}
}}
placeholder="Enter a new todo..."
/>
<button disabled={todo.trim().length === 0} onClick={submitTodo}>
Add todo
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { createFileRoute } from '@tanstack/react-router';
import { getPunkSongs } from '@/data/demo.punk-songs';
export const Route = createFileRoute('/demo/start/ssr/data-only')({
ssr: 'data-only',
component: RouteComponent,
loader: async () => await getPunkSongs(),
});
function RouteComponent() {
const punkSongs = Route.useLoaderData();
return (
<div>
<h1>Data Only SSR - Punk Songs</h1>
<ul>
{punkSongs.map((song) => (
<li key={song.id}>
{song.name} - {song.artist}
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { createFileRoute } from '@tanstack/react-router';
import { getPunkSongs } from '@/data/demo.punk-songs';
export const Route = createFileRoute('/demo/start/ssr/full-ssr')({
component: RouteComponent,
loader: async () => await getPunkSongs(),
});
function RouteComponent() {
const punkSongs = Route.useLoaderData();
return (
<div>
<h1>Full SSR - Punk Songs</h1>
<ul>
{punkSongs.map((song) => (
<li key={song.id}>
{song.name} - {song.artist}
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1,24 @@
import { createFileRoute, Link } from '@tanstack/react-router';
export const Route = createFileRoute('/demo/start/ssr/')({
component: RouteComponent,
});
function RouteComponent() {
return (
<div>
<h1>SSR Demos</h1>
<ul>
<li>
<Link to="/demo/start/ssr/spa-mode">SPA Mode</Link>
</li>
<li>
<Link to="/demo/start/ssr/full-ssr">Full SSR</Link>
</li>
<li>
<Link to="/demo/start/ssr/data-only">Data Only</Link>
</li>
</ul>
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { createFileRoute } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { getPunkSongs } from '@/data/demo.punk-songs';
export const Route = createFileRoute('/demo/start/ssr/spa-mode')({
ssr: false,
component: RouteComponent,
});
function RouteComponent() {
const [punkSongs, setPunkSongs] = useState<
Awaited<ReturnType<typeof getPunkSongs>>
>([]);
useEffect(() => {
getPunkSongs().then(setPunkSongs);
}, []);
return (
<div>
<h1>SPA Mode - Punk Songs</h1>
<ul>
{punkSongs.map((song) => (
<li key={song.id}>
{song.name} - {song.artist}
</li>
))}
</ul>
</div>
);
}

37
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { createFileRoute } from '@tanstack/react-router';
import '../App.css';
export const Route = createFileRoute('/')({ component: App });
function App() {
return (
<div className="App">
<header className="App-header">
<img
src="/tanstack-circle-logo.png"
className="App-logo"
alt="TanStack Logo"
/>
<p>
Edit <code>src/routes/index.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
<a
className="App-link"
href="https://tanstack.com"
target="_blank"
rel="noopener noreferrer"
>
Learn TanStack
</a>
</header>
</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