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

View File

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

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

18
src/components/Header.css Normal file
View File

@@ -0,0 +1,18 @@
.header {
padding: 0.5rem;
display: flex;
gap: 0.5rem;
background-color: #fff;
color: #000;
justify-content: space-between;
}
.nav {
display: flex;
flex-direction: row;
}
.nav-item {
padding: 0 0.5rem;
font-weight: bold;
}

27
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,27 @@
import { Link } from '@tanstack/react-router';
import './Header.css';
export default function Header() {
return (
<header className="header">
<nav className="nav">
<div className="nav-item">
<Link to="/">Home</Link>
</div>
<div className="px-2 font-bold">
<Link to="/demo/start/server-funcs">Start - Server Functions</Link>
</div>
<div className="px-2 font-bold">
<Link to="/demo/start/api-request">Start - API Request</Link>
</div>
<div className="px-2 font-bold">
<Link to="/demo/start/ssr">Start - SSR Demos</Link>
</div>
</nav>
</header>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 }) { import Header from '../components/Header';
return <AppShell>{children}</AppShell>;
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