Compare commits

...

10 Commits

Author SHA1 Message Date
BeauTroll
d470eb2cd5 a gitea ci 2026-01-19 18:36:27 +01:00
BeauTroll
cbd0cdced4 biome lint applied 2026-01-19 18:09:23 +01:00
BeauTroll
e8269bb2fe add javascript linting config 2026-01-19 18:04:52 +01:00
BeauTroll
ba8a46fcd3 add biome 2026-01-19 18:01:31 +01:00
BeauTroll
0bea7bc3f4 added biome 2026-01-19 13:35:20 +01:00
BeauTroll
10859a4e64 feat: add healthcheck 2026-01-19 12:23:01 +01:00
BeauTroll
2b78d4e332 feat: add prisma 2026-01-19 12:22:08 +01:00
BeauTroll
448721f0a0 feat: add docker-compose 2026-01-19 09:52:44 +01:00
BeauTroll
eb7c7a7221 feat: add Dockerfile 2026-01-19 09:42:01 +01:00
BeauTroll
4c8a6e9fd3 init tanstack 2026-01-19 09:32:47 +01:00
64 changed files with 8692 additions and 1329 deletions

16
.cta.json Normal file
View File

@@ -0,0 +1,16 @@
{
"projectName": "dofus-manager2",
"mode": "file-router",
"typescript": true,
"tailwind": false,
"packageManager": "pnpm",
"git": true,
"install": true,
"addOnOptions": {},
"version": 1,
"framework": "react-cra",
"chosenAddOns": [
"start",
"nitro"
]
}

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/dofus_manager?schema=public"
# App
APP_URL="http://localhost:3000"
NODE_ENV="development"
# Session
SESSION_SECRET="change-me-to-a-random-string-min-32-chars"

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

@@ -0,0 +1,135 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: "Environment to deploy"
required: true
default: "staging"
type: choice
options:
- staging
- production
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: dofus_manager
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: pnpm prisma generate
- name: Run migrations
run: pnpm prisma migrate deploy
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/dofus_manager
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm typecheck
- name: Test
run: pnpm test
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/dofus_manager
build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop'
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_URL }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
file: docker/Dockerfile
push: true
tags: |
${{ vars.REGISTRY_URL }}/dofus-manager:${{ github.sha }}
${{ vars.REGISTRY_URL }}/dofus-manager:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- name: Deploy to staging
run: |
echo "Deploying to staging..."
# SSH ou webhook vers votre serveur staging
curl -X POST "${{ secrets.STAGING_WEBHOOK_URL }}" || true
deploy-production:
needs: build
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- name: Deploy to production
run: |
echo "Deploying to production..."
curl -X POST "${{ secrets.PRODUCTION_WEBHOOK_URL }}" || true
deploy-manual:
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch'
environment: ${{ github.event.inputs.environment }}
steps:
- name: Deploy to ${{ github.event.inputs.environment }}
run: |
echo "Manual deploy to ${{ github.event.inputs.environment }}"
# Votre logique de déploiement ici

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
count.txt
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
todos.json
/generated/prisma

11
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"files.watcherExclude": {
"**/routeTree.gen.ts": true
},
"search.exclude": {
"**/routeTree.gen.ts": true
},
"files.readonlyInclude": {
"**/routeTree.gen.ts": true
}
}

290
README.md Normal file
View File

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

37
biome.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["src/**/*.ts", "src/**/*.tsx"],
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "always",
"trailingCommas": "all"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "stone",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

45
docker/Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# docker/Dockerfile
# ============ Build stage ============
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Copy source and build
COPY . .
RUN pnpm prisma generate
RUN pnpm build
# ============ Production stage ============
FROM node:20-alpine AS runner
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 app
# Copy built application
COPY --from=builder --chown=app:nodejs /app/.output ./.output
COPY --from=builder --chown=app:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=app:nodejs /app/package.json ./package.json
COPY --from=builder --chown=app:nodejs /app/prisma ./prisma
USER app
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -0,0 +1,20 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=dofus_manager
ports:
- "5432:5432"
volumes:
- postgres_dev_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d dofus_manager"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_dev_data:

47
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,47 @@
services:
app:
build:
context: ..
dockerfile: docker/Dockerfile
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://postgres:${DB_PASSWORD}@db:5432/dofus_manager
- SESSION_SECRET=${SESSION_SECRET}
- NODE_ENV=production
ports:
- "3000:3000"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
- internal
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=dofus_manager
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d dofus_manager"]
interval: 5s
timeout: 5s
retries: 5
networks:
- internal
volumes:
postgres_data:
networks:
internal:

View File

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

View File

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

53
package.json Normal file
View File

@@ -0,0 +1,53 @@
{
"name": "dofus-manager2",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@prisma/client": "^7.2.0",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "^0.7.0",
"@tanstack/react-router": "^1.132.0",
"@tanstack/react-router-devtools": "^1.132.0",
"@tanstack/react-router-ssr-query": "^1.131.7",
"@tanstack/react-start": "^1.132.0",
"@tanstack/router-plugin": "^1.132.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.561.0",
"nitro": "npm:nitro-nightly@latest",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"vite-tsconfig-paths": "^6.0.2"
},
"devDependencies": {
"@biomejs/biome": "2.3.11",
"@tanstack/devtools-vite": "^0.3.11",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"dotenv": "^17.2.3",
"jsdom": "^27.0.0",
"prisma": "^7.2.0",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vitest": "^3.0.5",
"web-vitals": "^5.1.0"
}
}

5035
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

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

14
prisma.config.ts Normal file
View File

@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

View File

@@ -0,0 +1,187 @@
-- CreateEnum
CREATE TYPE "TeamType" AS ENUM ('MAIN', 'SECONDARY', 'CUSTOM');
-- CreateEnum
CREATE TYPE "ProgressionType" AS ENUM ('QUEST', 'DUNGEON', 'ACHIEVEMENT', 'DOFUS');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"password_hash" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "characters" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"level" INTEGER NOT NULL DEFAULT 1,
"class_id" INTEGER NOT NULL,
"class_name" TEXT NOT NULL,
"server_id" INTEGER NOT NULL,
"server_name" TEXT NOT NULL,
"account_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "characters_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "teams" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "TeamType" NOT NULL DEFAULT 'CUSTOM',
"user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "team_members" (
"id" TEXT NOT NULL,
"team_id" TEXT NOT NULL,
"character_id" TEXT NOT NULL,
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "team_members_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "progressions" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "ProgressionType" NOT NULL,
"category" TEXT NOT NULL,
"dofusdb_id" INTEGER,
CONSTRAINT "progressions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "character_progressions" (
"id" TEXT NOT NULL,
"character_id" TEXT NOT NULL,
"progression_id" TEXT NOT NULL,
"completed" BOOLEAN NOT NULL DEFAULT false,
"completed_at" TIMESTAMP(3),
CONSTRAINT "character_progressions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
-- CreateIndex
CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at");
-- CreateIndex
CREATE INDEX "accounts_user_id_idx" ON "accounts"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_user_id_name_key" ON "accounts"("user_id", "name");
-- CreateIndex
CREATE INDEX "characters_account_id_idx" ON "characters"("account_id");
-- CreateIndex
CREATE INDEX "characters_class_id_idx" ON "characters"("class_id");
-- CreateIndex
CREATE INDEX "characters_server_id_idx" ON "characters"("server_id");
-- CreateIndex
CREATE INDEX "characters_level_idx" ON "characters"("level");
-- CreateIndex
CREATE UNIQUE INDEX "characters_account_id_name_key" ON "characters"("account_id", "name");
-- CreateIndex
CREATE INDEX "teams_user_id_idx" ON "teams"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "teams_user_id_name_key" ON "teams"("user_id", "name");
-- CreateIndex
CREATE INDEX "team_members_team_id_idx" ON "team_members"("team_id");
-- CreateIndex
CREATE INDEX "team_members_character_id_idx" ON "team_members"("character_id");
-- CreateIndex
CREATE UNIQUE INDEX "team_members_team_id_character_id_key" ON "team_members"("team_id", "character_id");
-- CreateIndex
CREATE UNIQUE INDEX "progressions_dofusdb_id_key" ON "progressions"("dofusdb_id");
-- CreateIndex
CREATE INDEX "progressions_type_idx" ON "progressions"("type");
-- CreateIndex
CREATE INDEX "progressions_category_idx" ON "progressions"("category");
-- CreateIndex
CREATE INDEX "character_progressions_character_id_idx" ON "character_progressions"("character_id");
-- CreateIndex
CREATE INDEX "character_progressions_progression_id_idx" ON "character_progressions"("progression_id");
-- CreateIndex
CREATE INDEX "character_progressions_completed_idx" ON "character_progressions"("completed");
-- CreateIndex
CREATE UNIQUE INDEX "character_progressions_character_id_progression_id_key" ON "character_progressions"("character_id", "progression_id");
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "characters" ADD CONSTRAINT "characters_account_id_fkey" FOREIGN KEY ("account_id") REFERENCES "accounts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "teams" ADD CONSTRAINT "teams_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_fkey" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "team_members" ADD CONSTRAINT "team_members_character_id_fkey" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "character_progressions" ADD CONSTRAINT "character_progressions_character_id_fkey" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "character_progressions" ADD CONSTRAINT "character_progressions_progression_id_fkey" FOREIGN KEY ("progression_id") REFERENCES "progressions"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

140
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,140 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String @map("password_hash")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
accounts Account[]
sessions Session[]
teams Team[]
@@map("users")
}
model Session {
id String @id @default(cuid())
userId String @map("user_id")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([expiresAt])
@@map("sessions")
}
model Account {
id String @id @default(cuid())
name String
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
characters Character[]
@@unique([userId, name])
@@index([userId])
@@map("accounts")
}
model Character {
id String @id @default(cuid())
name String
level Int @default(1)
classId Int @map("class_id")
className String @map("class_name")
serverId Int @map("server_id")
serverName String @map("server_name")
accountId String @map("account_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
progressions CharacterProgression[]
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
teamMembers TeamMember[]
@@unique([accountId, name])
@@index([accountId])
@@index([classId])
@@index([serverId])
@@index([level])
@@map("characters")
}
model Team {
id String @id @default(cuid())
name String
type TeamType @default(CUSTOM)
userId String @map("user_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
members TeamMember[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, name])
@@index([userId])
@@map("teams")
}
model TeamMember {
id String @id @default(cuid())
teamId String @map("team_id")
characterId String @map("character_id")
joinedAt DateTime @default(now()) @map("joined_at")
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@unique([teamId, characterId])
@@index([teamId])
@@index([characterId])
@@map("team_members")
}
model Progression {
id String @id @default(cuid())
name String
type ProgressionType
category String
dofusDbId Int? @unique @map("dofusdb_id")
characterProgressions CharacterProgression[]
@@index([type])
@@index([category])
@@map("progressions")
}
model CharacterProgression {
id String @id @default(cuid())
characterId String @map("character_id")
progressionId String @map("progression_id")
completed Boolean @default(false)
completedAt DateTime? @map("completed_at")
character Character @relation(fields: [characterId], references: [id], onDelete: Cascade)
progression Progression @relation(fields: [progressionId], references: [id], onDelete: Cascade)
@@unique([characterId, progressionId])
@@index([characterId])
@@index([progressionId])
@@index([completed])
@@map("character_progressions")
}
enum TeamType {
MAIN
SECONDARY
CUSTOM
}
enum ProgressionType {
QUEST
DUNGEON
ACHIEVEMENT
DOFUS
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View File

@@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

38
src/App.css Normal file
View File

@@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

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

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

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

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

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

View File

@@ -1,14 +1,33 @@
import * as React from "react";
import {
Search,
Plus,
ArrowUpDown,
ChevronDown,
ChevronUp,
ArrowUpDown,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
Plus,
Search,
} from 'lucide-react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
@@ -16,106 +35,87 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
} from '@/components/ui/table';
import { cn } from '@/lib/utils';
// Sample data
const characters = [
{
id: 1,
nom: "Krosmaster",
classe: "Cra",
nom: 'Krosmaster',
classe: 'Cra',
niveau: 200,
serveur: "Imagiro",
compte: "Compte1",
serveur: 'Imagiro',
compte: 'Compte1',
},
{
id: 2,
nom: "TankMaster",
classe: "Iop",
nom: 'TankMaster',
classe: 'Iop',
niveau: 200,
serveur: "Imagiro",
compte: "Compte1",
serveur: 'Imagiro',
compte: 'Compte1',
},
{
id: 3,
nom: "MoneyMaker",
classe: "Enu",
nom: 'MoneyMaker',
classe: 'Enu',
niveau: 200,
serveur: "Imagiro",
compte: "Compte2",
serveur: 'Imagiro',
compte: 'Compte2',
},
{
id: 4,
nom: "HealBot",
classe: "Eni",
nom: 'HealBot',
classe: 'Eni',
niveau: 195,
serveur: "Tylezia",
compte: "Compte2",
serveur: 'Tylezia',
compte: 'Compte2',
},
{
id: 5,
nom: "ShadowKill",
classe: "Sram",
nom: 'ShadowKill',
classe: 'Sram',
niveau: 200,
serveur: "Draconiros",
compte: "Compte3",
serveur: 'Draconiros',
compte: 'Compte3',
},
{
id: 6,
nom: "TimeWarp",
classe: "Elio",
nom: 'TimeWarp',
classe: 'Elio',
niveau: 180,
serveur: "Imagiro",
compte: "Compte1",
serveur: 'Imagiro',
compte: 'Compte1',
},
{
id: 7,
nom: "ArrowStorm",
classe: "Cra",
nom: 'ArrowStorm',
classe: 'Cra',
niveau: 200,
serveur: "Tylezia",
compte: "Compte4",
serveur: 'Tylezia',
compte: 'Compte4',
},
{
id: 8,
nom: "BerserkerX",
classe: "Iop",
nom: 'BerserkerX',
classe: 'Iop',
niveau: 175,
serveur: "Draconiros",
compte: "Compte3",
serveur: 'Draconiros',
compte: 'Compte3',
},
];
const classes = [
{ name: "Cra", count: 12 },
{ name: "Iop", count: 8 },
{ name: "Enu", count: 6 },
{ name: "Eni", count: 5 },
{ name: "Elio", count: 4 },
{ name: "Sram", count: 6 },
{ name: 'Cra', count: 12 },
{ name: 'Iop', count: 8 },
{ name: 'Enu', count: 6 },
{ name: 'Eni', count: 5 },
{ name: 'Elio', count: 4 },
{ name: 'Sram', count: 6 },
];
const serveurs = ["Imagiro", "Tylezia", "Draconiros"];
const serveurs = ['Imagiro', 'Tylezia', 'Draconiros'];
export function CharacterList() {
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
@@ -286,7 +286,7 @@ export function CharacterList() {
<>
<span className="text-sm text-[#94A3B8]">
{selectedIds.length} sélectionné
{selectedIds.length > 1 ? "s" : ""}
{selectedIds.length > 1 ? 's' : ''}
</span>
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -355,10 +355,10 @@ export function CharacterList() {
<TableRow
key={character.id}
className={cn(
"border-[#334155] h-12 transition-colors",
'border-[#334155] h-12 transition-colors',
isSelected
? "bg-[#1E293B] border-l-2 border-l-[#60A5FA]"
: "hover:bg-[#1E293B]/50",
? 'bg-[#1E293B] border-l-2 border-l-[#60A5FA]'
: 'hover:bg-[#1E293B]/50',
)}
>
<TableCell className="w-[40px]">
@@ -399,7 +399,7 @@ export function CharacterList() {
size="sm"
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
>
{"<"}
{'<'}
</Button>
<Button
variant="outline"
@@ -427,7 +427,7 @@ export function CharacterList() {
size="sm"
className="border-[#475569] text-[#94A3B8] hover:text-[#F8FAFC] hover:bg-[#334155] rounded-[6px] bg-transparent"
>
{">"}
{'>'}
</Button>
</div>
</div>

View File

@@ -1,3 +1,4 @@
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -5,8 +6,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
} from '@/components/ui/dialog';
interface ConfirmationModalProps {
open: boolean;
@@ -32,7 +32,7 @@ export function ConfirmationModal({
<DialogHeader>
<DialogTitle>Confirmer la mise à jour</DialogTitle>
<DialogDescription>
Marquer {progressionName} comme fait pour {incompleteCount}{" "}
Marquer {progressionName} comme fait pour {incompleteCount}{' '}
personnages ?
</DialogDescription>
</DialogHeader>

View File

@@ -1,17 +1,17 @@
import { useLocation } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { Moon, Sun, ChevronRight } from "lucide-react";
import { useLocation } from '@tanstack/react-router';
import { ChevronRight, Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
const routeLabels: Record<string, string> = {
"/": "Dashboard",
"/characters": "Personnages",
"/accounts": "Comptes",
"/teams": "Teams",
"/settings": "Paramètres",
'/': 'Dashboard',
'/characters': 'Personnages',
'/accounts': 'Comptes',
'/teams': 'Teams',
'/settings': 'Paramètres',
};
interface AppHeaderProps {
theme: "dark" | "light";
theme: 'dark' | 'light';
onToggleTheme: () => void;
}
@@ -19,12 +19,12 @@ export function AppHeader({ theme, onToggleTheme }: AppHeaderProps) {
const { pathname } = useLocation();
// Generate breadcrumb from pathname
const segments = pathname.split("/").filter(Boolean);
const segments = pathname.split('/').filter(Boolean);
const breadcrumbs =
segments.length === 0
? [{ label: "Dashboard", href: "/" }]
? [{ label: 'Dashboard', href: '/' }]
: segments.map((segment, index) => {
const href = "/" + segments.slice(0, index + 1).join("/");
const href = '/' + segments.slice(0, index + 1).join('/');
const label =
routeLabels[href] ||
segment.charAt(0).toUpperCase() + segment.slice(1);
@@ -43,8 +43,8 @@ export function AppHeader({ theme, onToggleTheme }: AppHeaderProps) {
<span
className={
index === breadcrumbs.length - 1
? "font-medium text-foreground"
: "text-muted-foreground"
? 'font-medium text-foreground'
: 'text-muted-foreground'
}
>
{crumb.label}
@@ -60,7 +60,7 @@ export function AppHeader({ theme, onToggleTheme }: AppHeaderProps) {
onClick={onToggleTheme}
className="h-9 w-9"
>
{theme === "dark" ? (
{theme === 'dark' ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />

View File

@@ -1,9 +1,9 @@
import type React from "react";
import type React from 'react';
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { AppHeader } from "@/components/layout/app-header";
import { useEffect, useState } from 'react';
import { AppHeader } from '@/components/layout/app-header';
import { AppSidebar } from '@/components/layout/app-sidebar';
import { cn } from '@/lib/utils';
interface AppShellProps {
children: React.ReactNode;
@@ -11,20 +11,20 @@ interface AppShellProps {
export function AppShell({ children }: AppShellProps) {
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
useEffect(() => {
const root = document.documentElement;
if (theme === "light") {
root.classList.add("light");
if (theme === 'light') {
root.classList.add('light');
} else {
root.classList.remove("light");
root.classList.remove('light');
}
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === "dark" ? "light" : "dark"));
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
return (
@@ -37,8 +37,8 @@ export function AppShell({ children }: AppShellProps) {
{/* Main content area */}
<div
className={cn(
"flex min-h-screen flex-col transition-all duration-200",
sidebarCollapsed ? "pl-16" : "pl-60",
'flex min-h-screen flex-col transition-all duration-200',
sidebarCollapsed ? 'pl-16' : 'pl-60',
)}
>
<AppHeader theme={theme} onToggleTheme={toggleTheme} />

View File

@@ -1,28 +1,28 @@
import { Link, useLocation } from "@tanstack/react-router";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Link, useLocation } from '@tanstack/react-router';
import {
ChevronLeft,
ChevronRight,
Folder,
Home,
Settings,
Swords,
Users,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Home,
Users,
Folder,
Swords,
Settings,
ChevronLeft,
ChevronRight,
} from "lucide-react";
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
const navItems = [
{ icon: Home, label: "Dashboard", href: "/" },
{ icon: Users, label: "Personnages", href: "/characters" },
{ icon: Folder, label: "Comptes", href: "/accounts" },
{ icon: Swords, label: "Teams", href: "/teams" },
{ icon: Settings, label: "Paramètres", href: "/settings" },
{ icon: Home, label: 'Dashboard', href: '/' },
{ icon: Users, label: 'Personnages', href: '/characters' },
{ icon: Folder, label: 'Comptes', href: '/accounts' },
{ icon: Swords, label: 'Teams', href: '/teams' },
{ icon: Settings, label: 'Paramètres', href: '/settings' },
];
interface AppSidebarProps {
@@ -37,8 +37,8 @@ export function AppSidebar({ collapsed, onToggle }: AppSidebarProps) {
<TooltipProvider delayDuration={0}>
<aside
className={cn(
"fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200",
collapsed ? "w-16" : "w-60",
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200',
collapsed ? 'w-16' : 'w-60',
)}
>
{/* Header with logo and toggle */}
@@ -53,8 +53,8 @@ export function AppSidebar({ collapsed, onToggle }: AppSidebarProps) {
size="icon"
onClick={onToggle}
className={cn(
"h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent",
collapsed && "mx-auto",
'h-8 w-8 text-sidebar-foreground hover:bg-sidebar-accent',
collapsed && 'mx-auto',
)}
>
{collapsed ? (
@@ -76,10 +76,10 @@ export function AppSidebar({ collapsed, onToggle }: AppSidebarProps) {
<Link
to={item.href}
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
? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
? 'bg-sidebar-primary text-sidebar-primary-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
)}
>
<Icon className="h-5 w-5 shrink-0" />

View File

@@ -1,29 +1,29 @@
import type React from "react";
import {
FolderOpen,
Users,
Swords,
Coins,
BarChart3,
Plus,
ChevronDown,
ArrowRight,
BarChart3,
ChevronDown,
Coins,
FolderOpen,
Plus,
RefreshCw,
} from "lucide-react";
Swords,
Users,
} from 'lucide-react';
import type React from 'react';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
} from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
// Custom progress bar with color support
function ColoredProgress({
@@ -31,12 +31,12 @@ function ColoredProgress({
color,
}: {
value: number;
color: "success" | "warning" | "info";
color: 'success' | 'warning' | 'info';
}) {
const colorClasses = {
success: "bg-[#4ADE80]",
warning: "bg-[#FBBF24]",
info: "bg-[#60A5FA]",
success: 'bg-[#4ADE80]',
warning: 'bg-[#FBBF24]',
info: 'bg-[#60A5FA]',
};
return (
@@ -57,7 +57,7 @@ function StatCard({
secondary,
linkText,
children,
className = "",
className = '',
}: {
icon: React.ElementType;
title: string;
@@ -101,16 +101,16 @@ function StatCard({
export function Dashboard() {
// Mock data
const currencies = [
{ name: "Doplons", amount: "12,450" },
{ name: "Orichor", amount: "3,200" },
{ name: "Kamas glace", amount: "8,100" },
{ name: "Nuggets", amount: "2,340" },
{ name: 'Doplons', amount: '12,450' },
{ name: 'Orichor', amount: '3,200' },
{ name: 'Kamas glace', amount: '8,100' },
{ name: 'Nuggets', amount: '2,340' },
];
const progressions = [
{ label: "Dofus", value: 72, color: "success" as const },
{ label: "Donjons", value: 45, color: "warning" as const },
{ label: "Recherchés", value: 61, color: "info" as const },
{ label: 'Dofus', value: 72, color: 'success' as const },
{ label: 'Donjons', value: 45, color: 'warning' as const },
{ label: 'Recherchés', value: 61, color: 'info' as const },
];
return (

View File

@@ -1,12 +1,12 @@
import { useState } from "react";
import { ChevronDown, ChevronRight, Check } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
import { useState } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
interface ProgressionItem {
id: string;
@@ -18,7 +18,7 @@ interface ProgressionItem {
interface ProgressionSectionProps {
title: string;
items: ProgressionItem[];
filter: "all" | "done" | "not-done";
filter: 'all' | 'done' | 'not-done';
}
export function ProgressionSection({
@@ -30,8 +30,8 @@ export function ProgressionSection({
const [localItems, setLocalItems] = useState(items);
const filteredItems = localItems.filter((item) => {
if (filter === "done") return item.completed;
if (filter === "not-done") return !item.completed;
if (filter === 'done') return item.completed;
if (filter === 'not-done') return !item.completed;
return true;
});
@@ -46,7 +46,7 @@ export function ProgressionSection({
...item,
completed: !item.completed,
completedDate: !item.completed
? new Date().toISOString().split("T")[0]
? new Date().toISOString().split('T')[0]
: undefined,
}
: item,
@@ -84,16 +84,16 @@ export function ProgressionSection({
<label
htmlFor={item.id}
className={cn(
"flex-1 text-sm cursor-pointer",
item.completed ? "text-foreground" : "text-muted-foreground",
'flex-1 text-sm cursor-pointer',
item.completed ? 'text-foreground' : 'text-muted-foreground',
)}
>
{item.name}
</label>
{item.completed ? (
<div className="flex items-center gap-1.5 text-sm">
<Check className="h-3.5 w-3.5" style={{ color: "#4ADE80" }} />
<span style={{ color: "#4ADE80" }}>{item.completedDate}</span>
<Check className="h-3.5 w-3.5" style={{ color: '#4ADE80' }} />
<span style={{ color: '#4ADE80' }}>{item.completedDate}</span>
</div>
) : (
<span className="text-muted-foreground text-sm"></span>

View File

@@ -1,22 +1,23 @@
import { useState } from "react";
import {
CheckCircle,
ChevronRight,
Pencil,
Trash2,
CheckCircle,
XCircle,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
} from 'lucide-react';
import { useState } from 'react';
import { ConfirmationModal } from '@/components/confirmation-modal';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
} from '@/components/ui/select';
import {
Table,
TableBody,
@@ -24,32 +25,31 @@ import {
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { ConfirmationModal } from "@/components/confirmation-modal";
} from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
// Mock data
const teamMembers = [
{ id: 1, name: "Krosmaster", completed: true, date: "2026-01-10" },
{ id: 2, name: "TankMaster", completed: true, date: "2026-01-10" },
{ id: 3, name: "HealBot", completed: false, date: null },
{ id: 4, name: "SramKiller", completed: false, date: null },
{ id: 5, name: "Eniripsa", completed: true, date: "2026-01-09" },
{ id: 6, name: "Sacrieur", completed: true, date: "2026-01-08" },
{ id: 7, name: "Pandawa", completed: true, date: "2026-01-10" },
{ id: 8, name: "Eliotrope", completed: true, date: "2026-01-07" },
{ id: 1, name: 'Krosmaster', completed: true, date: '2026-01-10' },
{ id: 2, name: 'TankMaster', completed: true, date: '2026-01-10' },
{ id: 3, name: 'HealBot', completed: false, date: null },
{ id: 4, name: 'SramKiller', completed: false, date: null },
{ id: 5, name: 'Eniripsa', completed: true, date: '2026-01-09' },
{ id: 6, name: 'Sacrieur', completed: true, date: '2026-01-08' },
{ id: 7, name: 'Pandawa', completed: true, date: '2026-01-10' },
{ id: 8, name: 'Eliotrope', completed: true, date: '2026-01-07' },
];
const progressions = [
{ id: "dofus-turquoise", name: "Dofus Turquoise" },
{ id: "donjon-bethel", name: "Donjon Bethel" },
{ id: "quete-ebene", name: "Quête Ébène" },
{ id: "succes-dimension", name: "Succès Dimension" },
{ id: 'dofus-turquoise', name: 'Dofus Turquoise' },
{ id: 'donjon-bethel', name: 'Donjon Bethel' },
{ id: 'quete-ebene', name: 'Quête Ébène' },
{ id: 'succes-dimension', name: 'Succès Dimension' },
];
export default function TeamDetailPage() {
const [selectedProgression, setSelectedProgression] =
useState("dofus-turquoise");
useState('dofus-turquoise');
const [isModalOpen, setIsModalOpen] = useState(false);
const completedCount = teamMembers.filter((m) => m.completed).length;
@@ -58,7 +58,7 @@ export default function TeamDetailPage() {
const progressPercentage = Math.round((completedCount / totalCount) * 100);
const selectedProgressionName =
progressions.find((p) => p.id === selectedProgression)?.name || "";
progressions.find((p) => p.id === selectedProgression)?.name || '';
return (
<div className="min-h-screen bg-background p-6">
@@ -201,7 +201,7 @@ export default function TeamDetailPage() {
)}
</TableCell>
<TableCell className="py-2 text-muted-foreground">
{member.date || "—"}
{member.date || '—'}
</TableCell>
</TableRow>
))}

View File

@@ -0,0 +1,62 @@
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Button({
className,
variant = 'default',
size = 'default',
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : 'button';
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,92 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,21 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
);
}
export { Input };

114
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,114 @@
import type * as React from 'react';
import { cn } from '@/lib/utils';
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

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

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

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

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

12
src/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

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

@@ -0,0 +1,240 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiHealthRouteImport } from './routes/api/health'
import { Route as DemoStartServerFuncsRouteImport } from './routes/demo/start.server-funcs'
import { Route as DemoStartApiRequestRouteImport } from './routes/demo/start.api-request'
import { Route as DemoApiNamesRouteImport } from './routes/demo/api.names'
import { Route as DemoStartSsrIndexRouteImport } from './routes/demo/start.ssr.index'
import { Route as DemoStartSsrSpaModeRouteImport } from './routes/demo/start.ssr.spa-mode'
import { Route as DemoStartSsrFullSsrRouteImport } from './routes/demo/start.ssr.full-ssr'
import { Route as DemoStartSsrDataOnlyRouteImport } from './routes/demo/start.ssr.data-only'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHealthRoute = ApiHealthRouteImport.update({
id: '/api/health',
path: '/api/health',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartServerFuncsRoute = DemoStartServerFuncsRouteImport.update({
id: '/demo/start/server-funcs',
path: '/demo/start/server-funcs',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartApiRequestRoute = DemoStartApiRequestRouteImport.update({
id: '/demo/start/api-request',
path: '/demo/start/api-request',
getParentRoute: () => rootRouteImport,
} as any)
const DemoApiNamesRoute = DemoApiNamesRouteImport.update({
id: '/demo/api/names',
path: '/demo/api/names',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartSsrIndexRoute = DemoStartSsrIndexRouteImport.update({
id: '/demo/start/ssr/',
path: '/demo/start/ssr/',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartSsrSpaModeRoute = DemoStartSsrSpaModeRouteImport.update({
id: '/demo/start/ssr/spa-mode',
path: '/demo/start/ssr/spa-mode',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartSsrFullSsrRoute = DemoStartSsrFullSsrRouteImport.update({
id: '/demo/start/ssr/full-ssr',
path: '/demo/start/ssr/full-ssr',
getParentRoute: () => rootRouteImport,
} as any)
const DemoStartSsrDataOnlyRoute = DemoStartSsrDataOnlyRouteImport.update({
id: '/demo/start/ssr/data-only',
path: '/demo/start/ssr/data-only',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/api/health': typeof ApiHealthRoute
'/demo/api/names': typeof DemoApiNamesRoute
'/demo/start/api-request': typeof DemoStartApiRequestRoute
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/api/health': typeof ApiHealthRoute
'/demo/api/names': typeof DemoApiNamesRoute
'/demo/start/api-request': typeof DemoStartApiRequestRoute
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
'/demo/start/ssr': typeof DemoStartSsrIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/api/health': typeof ApiHealthRoute
'/demo/api/names': typeof DemoApiNamesRoute
'/demo/start/api-request': typeof DemoStartApiRequestRoute
'/demo/start/server-funcs': typeof DemoStartServerFuncsRoute
'/demo/start/ssr/data-only': typeof DemoStartSsrDataOnlyRoute
'/demo/start/ssr/full-ssr': typeof DemoStartSsrFullSsrRoute
'/demo/start/ssr/spa-mode': typeof DemoStartSsrSpaModeRoute
'/demo/start/ssr/': typeof DemoStartSsrIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/api/health'
| '/demo/api/names'
| '/demo/start/api-request'
| '/demo/start/server-funcs'
| '/demo/start/ssr/data-only'
| '/demo/start/ssr/full-ssr'
| '/demo/start/ssr/spa-mode'
| '/demo/start/ssr/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/api/health'
| '/demo/api/names'
| '/demo/start/api-request'
| '/demo/start/server-funcs'
| '/demo/start/ssr/data-only'
| '/demo/start/ssr/full-ssr'
| '/demo/start/ssr/spa-mode'
| '/demo/start/ssr'
id:
| '__root__'
| '/'
| '/api/health'
| '/demo/api/names'
| '/demo/start/api-request'
| '/demo/start/server-funcs'
| '/demo/start/ssr/data-only'
| '/demo/start/ssr/full-ssr'
| '/demo/start/ssr/spa-mode'
| '/demo/start/ssr/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ApiHealthRoute: typeof ApiHealthRoute
DemoApiNamesRoute: typeof DemoApiNamesRoute
DemoStartApiRequestRoute: typeof DemoStartApiRequestRoute
DemoStartServerFuncsRoute: typeof DemoStartServerFuncsRoute
DemoStartSsrDataOnlyRoute: typeof DemoStartSsrDataOnlyRoute
DemoStartSsrFullSsrRoute: typeof DemoStartSsrFullSsrRoute
DemoStartSsrSpaModeRoute: typeof DemoStartSsrSpaModeRoute
DemoStartSsrIndexRoute: typeof DemoStartSsrIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/health': {
id: '/api/health'
path: '/api/health'
fullPath: '/api/health'
preLoaderRoute: typeof ApiHealthRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/server-funcs': {
id: '/demo/start/server-funcs'
path: '/demo/start/server-funcs'
fullPath: '/demo/start/server-funcs'
preLoaderRoute: typeof DemoStartServerFuncsRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/api-request': {
id: '/demo/start/api-request'
path: '/demo/start/api-request'
fullPath: '/demo/start/api-request'
preLoaderRoute: typeof DemoStartApiRequestRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/api/names': {
id: '/demo/api/names'
path: '/demo/api/names'
fullPath: '/demo/api/names'
preLoaderRoute: typeof DemoApiNamesRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/ssr/': {
id: '/demo/start/ssr/'
path: '/demo/start/ssr'
fullPath: '/demo/start/ssr/'
preLoaderRoute: typeof DemoStartSsrIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/ssr/spa-mode': {
id: '/demo/start/ssr/spa-mode'
path: '/demo/start/ssr/spa-mode'
fullPath: '/demo/start/ssr/spa-mode'
preLoaderRoute: typeof DemoStartSsrSpaModeRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/ssr/full-ssr': {
id: '/demo/start/ssr/full-ssr'
path: '/demo/start/ssr/full-ssr'
fullPath: '/demo/start/ssr/full-ssr'
preLoaderRoute: typeof DemoStartSsrFullSsrRouteImport
parentRoute: typeof rootRouteImport
}
'/demo/start/ssr/data-only': {
id: '/demo/start/ssr/data-only'
path: '/demo/start/ssr/data-only'
fullPath: '/demo/start/ssr/data-only'
preLoaderRoute: typeof DemoStartSsrDataOnlyRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ApiHealthRoute: ApiHealthRoute,
DemoApiNamesRoute: DemoApiNamesRoute,
DemoStartApiRequestRoute: DemoStartApiRequestRoute,
DemoStartServerFuncsRoute: DemoStartServerFuncsRoute,
DemoStartSsrDataOnlyRoute: DemoStartSsrDataOnlyRoute,
DemoStartSsrFullSsrRoute: DemoStartSsrFullSsrRoute,
DemoStartSsrSpaModeRoute: DemoStartSsrSpaModeRoute,
DemoStartSsrIndexRoute: DemoStartSsrIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

17
src/router.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { createRouter } from '@tanstack/react-router';
// Import the generated route tree
import { routeTree } from './routeTree.gen';
// Create a new router instance
export const getRouter = () => {
const router = createRouter({
routeTree,
context: {},
scrollRestoration: true,
defaultPreloadStaleTime: 0,
});
return router;
};

View File

@@ -1,5 +1,58 @@
import { AppShell } from "@/components/layout/app-shell";
import { TanStackDevtools } from '@tanstack/react-devtools';
import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router';
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
export default function RootLayout({ children }) {
return <AppShell>{children}</AppShell>;
import Header from '../components/Header';
import appCss from '../styles.css?url';
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<Header />
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
);
}

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

@@ -0,0 +1,21 @@
import { createFileRoute } from '@tanstack/react-router'
import { createAPIFileRoute } from '@tanstack/start/api';
import { prisma } from '@/lib/server/db';
export const Route = createAPIFileRoute('/api/health')({
GET: async () => {
let dbStatus = 'disconnected';
try {
await prisma.$queryRaw`SELECT 1`;
dbStatus = 'connected';
} catch {
dbStatus = 'error';
}
return Response.json({
status: 'ok',
timestamp: new Date().toISOString(),
database: dbStatus,
})
},
});

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

14
src/styles.css Normal file
View File

@@ -0,0 +1,14 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
monospace;
}

View File

@@ -0,0 +1,7 @@
import { describe, expect, it } from 'vitest';
describe('Example', () => {
it('should pass', () => {
expect(1 + 1).toBe(2);
});
});

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

28
vite.config.ts Normal file
View File

@@ -0,0 +1,28 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import viteTsConfigPaths from 'vite-tsconfig-paths'
import { fileURLToPath, URL } from 'url'
import { nitro } from 'nitro/vite'
const config = defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
plugins: [
devtools(),
nitro(),
// this is the plugin that enables path aliases
viteTsConfigPaths({
projects: ['./tsconfig.json'],
}),
tanstackStart(),
viteReact(),
],
})
export default config