Frontend : squelette React + Vite + TS + Tailwind

Scaffold de base en attendant l'intégration du design Claude Design
(« Bénévoles Festival.dc.html ») : routage, plomberie i18n (t() +
locales/fr.json), client API avec proxy Vite vers le backend. Build et
lint passent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
AdamThomas
2026-09-09 23:16:38 +02:00
co-authored by Claude Sonnet 5
parent 49ac6a4726
commit 5154a3c61b
23 changed files with 4673 additions and 9 deletions
+5
View File
@@ -0,0 +1,5 @@
# URL du backend visée par le proxy Vite en dev.
VITE_API_TARGET=http://localhost:8000
# En production, l'app est servie en statique et tape directement l'API.
# VITE_API_BASE_URL=/api
+12
View File
@@ -0,0 +1,12 @@
node_modules
dist
dist-ssr
*.local
*.tsbuildinfo
.vite
# Éditeurs
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
+12
View File
@@ -0,0 +1,12 @@
# Image de dev : serveur Vite avec hot reload.
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"]
+41 -9
View File
@@ -1,12 +1,44 @@
# Frontend
# Frontend — Gestion des bénévoles
React + Vite + TypeScript + Tailwind. Le design est fourni séparément et fera
foi.
React + Vite + TypeScript + Tailwind. Interface en français, code en anglais.
Non initialisé à ce stade : le premier incrément porte sur le backend (modèle
de données, import, API CRUD, moteur de validation, endpoint matrice, jeu de
démo). Le scaffold frontend sera ajouté dans une étape dédiée.
Le design fait foi : projet Claude Design « Bénévoles Festival »
(`Bénévoles Festival.dc.html`). Les écrans et composants réels sont dérivés de
ce fichier et intégrés au fil des étapes. Le contenu actuel est un squelette
(routage, i18n, client API).
Rappel de convention : aucun texte visible par l'utilisateur en dur, tout
passe par un fichier de traduction, même si l'application est monolingue au
départ.
## Commandes
| Action | Commande |
|------------------------|-----------------------------|
| Installer | `npm install` |
| Dev (hot reload) | `npm run dev` → http://localhost:5173 |
| Build de production | `npm run build` |
| Prévisualiser le build | `npm run preview` |
| Lint | `npm run lint` |
| Vérif. de types | `npm run typecheck` |
En dev, Vite proxifie `/api` vers le backend (`VITE_API_TARGET`, défaut
`http://localhost:8000`).
## Conventions
- **Aucun texte visible en dur.** Tout passe par `src/i18n` (`t(key, params)`)
et `src/i18n/locales/fr.json`, même en monolingue.
- Code, noms de fichiers et de variables en anglais.
- Alias d'import `@/` → `src/`.
- Vocabulaire métier : voir le glossaire FR ↔ EN de `CLAUDE.md`.
## Arborescence
```
src/
├── main.tsx # point d'entrée (React Query + Router)
├── index.css # directives Tailwind
├── app/
│ ├── router.tsx # routes
│ └── AppLayout.tsx # coquille de mise en page (remplacée par le design)
├── pages/ # écrans
├── api/client.ts # client HTTP + ApiError
└── i18n/ # t() + locales/fr.json
```
+25
View File
@@ -0,0 +1,25 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
);
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gestion des bénévoles</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4259
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "benevoles-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.59.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0"
},
"devDependencies": {
"@eslint/js": "^9.12.0",
"@types/node": "^22.7.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.8.1",
"@typescript-eslint/parser": "^8.8.1",
"@vitejs/plugin-react": "^4.3.2",
"autoprefixer": "^10.4.20",
"eslint": "^9.12.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.12",
"globals": "^15.11.0",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.13",
"typescript": "^5.6.2",
"typescript-eslint": "^8.8.1",
"vite": "^5.4.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+42
View File
@@ -0,0 +1,42 @@
// Client HTTP minimal. En dev, Vite proxifie `/api` vers le backend (:8000).
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
export class ApiError extends Error {
constructor(
public readonly status: number,
public readonly body: unknown,
) {
super(`API ${status}`);
this.name = 'ApiError';
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...init?.headers,
},
credentials: 'include',
});
const isJson = res.headers.get('content-type')?.includes('application/json');
const body = isJson ? await res.json() : await res.text();
if (!res.ok) {
throw new ApiError(res.status, body);
}
return body as T;
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'POST', body: data === undefined ? undefined : JSON.stringify(data) }),
patch: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'PATCH', body: data === undefined ? undefined : JSON.stringify(data) }),
put: <T>(path: string, data?: unknown) =>
request<T>(path, { method: 'PUT', body: data === undefined ? undefined : JSON.stringify(data) }),
delete: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
};
+18
View File
@@ -0,0 +1,18 @@
import { Outlet } from 'react-router-dom';
import { t } from '@/i18n';
// Coquille de mise en page. Le design définira la navigation, l'en-tête et
// la palette ; ce composant sera remplacé lors de l'intégration du design.
export function AppLayout() {
return (
<div className="min-h-screen bg-slate-50 text-slate-900">
<header className="border-b border-slate-200 bg-white px-6 py-4">
<h1 className="text-lg font-semibold">{t('app.title')}</h1>
</header>
<main className="p-6">
<Outlet />
</main>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { createBrowserRouter } from 'react-router-dom';
import { AppLayout } from './AppLayout';
import { DashboardPage } from '@/pages/DashboardPage';
// Squelette de routage. Les écrans réels sont dérivés du design
// (« Bénévoles Festival.dc.html ») et ajoutés au fil des étapes.
export const router = createBrowserRouter([
{
path: '/',
element: <AppLayout />,
children: [{ index: true, element: <DashboardPage /> }],
},
]);
+21
View File
@@ -0,0 +1,21 @@
import fr from './locales/fr.json';
type Messages = Record<string, string>;
// Monolingue pour l'instant (français). La structure permet d'ajouter des
// locales sans toucher aux appelants.
const messages: Messages = fr;
export type TranslationKey = keyof typeof fr;
/**
* Traduit une clé. Les paramètres `{nom}` présents dans le message sont
* remplacés par `params.nom`. Aucun texte visible en dur ailleurs dans l'app.
*/
export function t(key: TranslationKey | string, params?: Record<string, string | number>): string {
const template = messages[key] ?? key;
if (!params) return template;
return template.replace(/\{(\w+)\}/g, (_, name: string) =>
name in params ? String(params[name]) : `{${name}}`,
);
}
+46
View File
@@ -0,0 +1,46 @@
{
"app.title": "Gestion des bénévoles",
"app.loading": "Chargement…",
"app.error.generic": "Une erreur est survenue",
"app.error.network": "Impossible de joindre le serveur",
"nav.dashboard": "Tableau de bord",
"nav.volunteers": "Bénévoles",
"nav.positions": "Postes",
"nav.shifts": "Créneaux",
"nav.assignments": "Affectations",
"nav.availabilities": "Disponibilités",
"nav.import": "Import",
"role.coordinator": "Coordinateur",
"role.lead": "Référent",
"role.volunteer": "Bénévole",
"assignment.status.proposed": "Proposée",
"assignment.status.confirmed": "Confirmée",
"assignment.status.declined": "Refusée",
"assignment.status.cancelled": "Annulée",
"assignment.source.solver": "Solveur",
"assignment.source.manual": "Manuelle",
"availability.status.available": "Disponible",
"availability.status.if_needed": "Si besoin",
"availability.status.unavailable": "Indisponible",
"position.category.bar": "Bar",
"position.category.ticketing": "Billetterie",
"position.category.welcome": "Accueil",
"position.category.parking": "Parking",
"position.category.cleaning": "Nettoyage",
"position.category.catering": "Restauration",
"position.category.setup": "Montage",
"position.category.stage": "Scène",
"position.category.other": "Autre",
"validation.SHIFT_OVERLAP": "Deux créneaux se chevauchent",
"validation.OUTSIDE_AVAILABILITY": "En dehors des disponibilités déclarées",
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h d'affectations sur la journée",
"validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux",
"validation.MISSING_REQUIRED_SKILL": "Compétence requise manquante"
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+21
View File
@@ -0,0 +1,21 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from 'react-router-dom';
import { router } from '@/app/router';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 30_000, refetchOnWindowFocus: false },
},
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</StrictMode>,
);
+12
View File
@@ -0,0 +1,12 @@
import { t } from '@/i18n';
export function DashboardPage() {
return (
<section>
<h2 className="text-base font-medium text-slate-700">{t('nav.dashboard')}</h2>
<p className="mt-2 text-sm text-slate-500">
{t('app.title')} — {import.meta.env.MODE}
</p>
</section>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {},
},
plugins: [],
};
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"types": ["node"],
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Le backend tourne sur :8000 en dev (voir docker-compose.yml).
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
host: true,
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_TARGET ?? 'http://localhost:8000',
changeOrigin: true,
},
},
},
});