feat(frontend): SPA Vue 3 + Vite + Tailwind v4 consommant l'API

Nouveau dossier /frontend avec un SPA complet qui couvre les écrans de la
maquette (cf. docs/Capture*.png) : public + admin token. Backend inchangé.

Stack
  - Vue 3.5 + Vite 8 + TypeScript 6 + Tailwind v4 (via @tailwindcss/vite)
  - Vue Router 4 en mode history, code-split par page (lazy import)
  - Aucune lib UI, aucun store (pas de Pinia) — état local + fetch direct
  - Bundle initial 50 kB gzip, total dist 240 kB

Pages
  - /                 redirige vers /donut
  - /dashboard        4 KPIs + mini donut + flux activité (via /api/stats + /api/activity)
  - /donut            viz SVG custom : 6 secteurs catégories, projets en points
                      colorés, anneaux plancher social / plafond écologique,
                      tooltip + click vers la fiche projet
  - /projects         grille de cartes paginée + filtres (catégorie, statut, recherche
                      avec debounce, query string comme source de vérité)
  - /projects/new     formulaire complet de création (token admin requis)
  - /projects/:slug   fiche : header méta + tabs Roadmap/Discussions/Ressources
                      + panneau droit Progression + Équipe, modal "Rejoindre"
  - /archives         projets status=archived
  - /categories       6 cartes coloured-top-border + count via /api/stats
  - /admin/login      saisie du token Bearer, validé via POST /api/members (vide)
  - 404               fallback

Structure
  - src/api/client.ts        : wrapper fetch typé + token Bearer auto + ApiError
  - src/composables/useAuth  : token localStorage + reactivité Vue
  - src/types/api.ts         : types miroirs des réponses PHP
  - src/utils/formatDate.ts  : ISO/MySQL/DATE -> JJ mois AAAA, relatif, etc.
  - src/components/          : Avatar, ProjectCard, StatusBadge, CategoryBadge,
                               ProgressBar, LoadingState, ErrorState, JoinProjectModal
  - src/layouts/AppLayout    : sidebar nav + main scrollable
  - src/components/AppSidebar: logo donut SVG, CTA, nav, statut admin en bas
  - src/pages/               : 10 pages ci-dessus

Intégration & déploiement
  - vite.config.ts proxy /api/* vers http://donut-gestprojet.test (Laragon)
  - caddy.conf prod : /api/* -> reverse_proxy PHP, le reste -> static dist/
    avec fallback /index.html (history mode), cache long sur /assets/*
  - DEPLOY.md mis à jour avec build local + rsync dist/ vers
    /srv/donut/web/infolab-front/dist
  - README.md mis à jour avec section "Frontend (SPA Vue 3)"

Build & types
  - npm run build : vue-tsc strict (TS 6 + erasableSyntaxOnly) -> OK
  - paths alias @/* configuré dans tsconfig.app.json

Vérifié localement
  - npm run dev sur :5173, proxy /api -> Apache Laragon -> PHP
  - Toutes les routes répondent 200 (curl http://localhost:5173/...)
  - Routes API consommées correctement (categories, stats, projects, ...)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 20:40:39 +02:00
parent 3a32e93ab1
commit 7fb22bcb29
49 changed files with 4754 additions and 13 deletions
+59 -6
View File
@@ -8,6 +8,7 @@ Procédure exacte de déploiement sur le serveur **Donut Marseille**.
- Conteneur PHP : `donut-infolab` (UID `33` = www-data côté Apache) - Conteneur PHP : `donut-infolab` (UID `33` = www-data côté Apache)
- BDD : conteneur existant `donut-mariadb`, réseau Docker `donut-net` - BDD : conteneur existant `donut-mariadb`, réseau Docker `donut-net`
- Reverse proxy : Caddy natif sur l'hôte - Reverse proxy : Caddy natif sur l'hôte
- Front : SPA Vue 3 buildé (statique) servi par Caddy depuis `/srv/donut/web/infolab-front/dist/`
> ⚠️ Pré-requis DNS : créer chez OVH un record `A infolab.ledonut-marseille.org → 82.66.40.209` > ⚠️ Pré-requis DNS : créer chez OVH un record `A infolab.ledonut-marseille.org → 82.66.40.209`
> **avant** le reload Caddy (sinon échec ACME challenge). > **avant** le reload Caddy (sinon échec ACME challenge).
@@ -31,16 +32,17 @@ getent group donut-admins
--- ---
## 1. Copier le projet vers le serveur ## 1. Copier le backend vers le serveur
Depuis ta machine de dev (à la racine du repo) : Depuis ta machine de dev (à la racine du repo) :
```bash ```bash
# Code app -> /srv/donut/web/infolab/ (bind-mount dans le conteneur, UID 33) # Code app PHP -> /srv/donut/web/infolab/ (bind-mount dans le conteneur, UID 33)
rsync -avz --delete \ rsync -avz --delete \
--exclude '.git' --exclude '.idea' --exclude '.env' --exclude 'tests' \ --exclude '.git' --exclude '.idea' --exclude '.env' --exclude 'tests' \
--exclude 'docker-compose.yml' --exclude 'Dockerfile' --exclude 'caddy.conf' \ --exclude 'docker-compose.yml' --exclude 'Dockerfile' --exclude 'caddy.conf' \
--exclude 'migrations' --exclude 'DEPLOY.md' --exclude 'INSTRUCTIONS.md' \ --exclude 'migrations' --exclude 'DEPLOY.md' --exclude 'INSTRUCTIONS.md' \
--exclude 'frontend' --exclude 'docs' \
./ arnaud.donut@82.66.40.209:/tmp/infolab-app/ ./ arnaud.donut@82.66.40.209:/tmp/infolab-app/
# Fichiers d'orchestration -> /srv/donut/compose/infolab/ # Fichiers d'orchestration -> /srv/donut/compose/infolab/
@@ -75,6 +77,40 @@ rm -rf /tmp/infolab-app /tmp/infolab-compose
--- ---
## 1bis. Builder & copier le frontend (SPA Vue)
Le SPA est compilé localement (Node 22+ requis) et déployé en statique. Pas de Node sur le serveur prod.
```bash
# Sur la machine de dev, à la racine du repo
cd frontend
npm ci # ou `npm install` la 1re fois
npm run build # produit frontend/dist/
ls -la dist/ # index.html + assets/ avec hash
# Copie de l'artifact vers le serveur (rsync direct, fichiers statiques)
rsync -avz --delete dist/ arnaud.donut@82.66.40.209:/tmp/infolab-front-dist/
cd ..
```
Sur le serveur :
```bash
ssh arnaud.donut@82.66.40.209
sudo mkdir -p /srv/donut/web/infolab-front/dist
sudo rsync -a --delete /tmp/infolab-front-dist/ /srv/donut/web/infolab-front/dist/
# Caddy tourne en `caddy` user — donner l'accès en lecture est suffisant
sudo chown -R caddy:caddy /srv/donut/web/infolab-front
sudo find /srv/donut/web/infolab-front -type d -exec chmod 755 {} \;
sudo find /srv/donut/web/infolab-front -type f -exec chmod 644 {} \;
rm -rf /tmp/infolab-front-dist
```
> ⚠️ Le build embarque l'URL de l'API en relatif (`/api/...`) — donc le SPA et l'API
> **doivent** être servis depuis le même domaine (ce que fait notre `caddy.conf`).
---
## 2. Créer les secrets `/srv/donut/secrets/infolab.env` ## 2. Créer les secrets `/srv/donut/secrets/infolab.env`
```bash ```bash
@@ -143,10 +179,11 @@ docker logs --tail 100 donut-infolab
--- ---
## 5. Vhost Caddy ## 5. Vhost Caddy (sert le SPA + reverse_proxy l'API)
Le fichier `caddy.conf` a été copié à `/srv/donut/compose/infolab/caddy.conf`. Le `caddy.conf` fait deux choses :
Vérifier qu'il est bien `import`-é par `/etc/caddy/Caddyfile` : - `/api/*` -> reverse_proxy vers le conteneur PHP `127.0.0.1:8003`
- tout le reste -> sert `/srv/donut/web/infolab-front/dist/` (SPA), fallback `index.html`
```bash ```bash
grep -n 'infolab' /etc/caddy/Caddyfile grep -n 'infolab' /etc/caddy/Caddyfile
@@ -162,6 +199,14 @@ sudo systemctl reload caddy
Caddy va négocier le certificat Let's Encrypt (à condition que le DNS A Caddy va négocier le certificat Let's Encrypt (à condition que le DNS A
record soit en place — voir avertissement en tête de fichier). record soit en place — voir avertissement en tête de fichier).
Vérifier qu'il sert bien le SPA et l'API :
```bash
curl -sI https://infolab.ledonut-marseille.org/ # SPA -> 200 text/html
curl -sI https://infolab.ledonut-marseille.org/api/health # API -> 200 application/json
curl -sI https://infolab.ledonut-marseille.org/whatever-fake # SPA fallback -> 200 index.html
```
--- ---
## 6. Vérification finale ## 6. Vérification finale
@@ -186,10 +231,15 @@ Si tout est vert : `OK : 11 / FAIL : 0`.
## 7. Cycle de mise à jour (pour plus tard) ## 7. Cycle de mise à jour (pour plus tard)
```bash ```bash
# Code app # Backend PHP
rsync -avz --delete --exclude '.git' --exclude '.env' \ rsync -avz --delete --exclude '.git' --exclude '.env' \
public/ src/ arnaud.donut@82.66.40.209:/srv/donut/web/infolab/ public/ src/ arnaud.donut@82.66.40.209:/srv/donut/web/infolab/
# Frontend Vue (build à chaque release)
(cd frontend && npm ci && npm run build)
rsync -avz --delete frontend/dist/ \
arnaud.donut@82.66.40.209:/srv/donut/web/infolab-front/dist/
# Si changement Dockerfile / docker-compose / migrations : # Si changement Dockerfile / docker-compose / migrations :
rsync -avz docker-compose.yml Dockerfile migrations/ \ rsync -avz docker-compose.yml Dockerfile migrations/ \
arnaud.donut@82.66.40.209:/srv/donut/compose/infolab/ arnaud.donut@82.66.40.209:/srv/donut/compose/infolab/
@@ -199,6 +249,7 @@ ssh arnaud.donut@82.66.40.209 << 'EOF'
cd /srv/donut/compose/infolab cd /srv/donut/compose/infolab
sudo docker compose --env-file /srv/donut/secrets/infolab.env up -d --build sudo docker compose --env-file /srv/donut/secrets/infolab.env up -d --build
sudo chown -R 33:33 /srv/donut/web/infolab sudo chown -R 33:33 /srv/donut/web/infolab
sudo chown -R caddy:caddy /srv/donut/web/infolab-front
EOF EOF
``` ```
@@ -210,5 +261,7 @@ EOF
- [ ] Certificat Let's Encrypt négocié OK (`caddy.log` côté hôte) - [ ] Certificat Let's Encrypt négocié OK (`caddy.log` côté hôte)
- [ ] `ADMIN_TOKEN` archivé dans le gestionnaire de mots de passe partagé - [ ] `ADMIN_TOKEN` archivé dans le gestionnaire de mots de passe partagé
- [ ] `tests/smoke.sh` vert depuis l'extérieur - [ ] `tests/smoke.sh` vert depuis l'extérieur
- [ ] SPA accessible : `https://infolab.ledonut-marseille.org/` renvoie la page d'accueil
- [ ] SPA routing OK : `https://infolab.ledonut-marseille.org/projects` aussi (pas de 404 — fallback Caddy)
- [ ] Conteneur `donut-infolab` visible dans Dozzle (logs Apache + PHP via stderr) - [ ] Conteneur `donut-infolab` visible dans Dozzle (logs Apache + PHP via stderr)
- [ ] Pas de fuite : `curl -I https://infolab.ledonut-marseille.org/.env` → 403/404 - [ ] Pas de fuite : `curl -I https://infolab.ledonut-marseille.org/.env` → 403/404
+37
View File
@@ -201,6 +201,43 @@ curl -s -X DELETE "$URL/api/projects/cartographie-des-ilots-de-chaleur" \
--- ---
## Frontend (SPA Vue 3)
Le SPA vit dans `/frontend/` — Vue 3 + Vite + TypeScript + Tailwind v4 + Vue Router.
Il consomme l'API JSON et propose les pages : Tableau de bord, Vision Donut (viz
SVG custom), Projets, Catégories, Fiche projet, Nouveau projet, Archives, Connexion admin.
### Dev local
```bash
# Backend PHP : Laragon sert déjà /api/* sur http://donut-gestprojet.test
cd frontend
npm install
npm run dev # http://localhost:5173 (Vite proxy /api vers Laragon)
```
### Build de prod
```bash
cd frontend
npm run build # vue-tsc + vite build -> frontend/dist/
```
Le SPA est servi par Caddy en statique depuis `frontend/dist/`, l'API par le
conteneur PHP. Détails dans `DEPLOY.md`.
### Architecture
- **Aucune lib UI** — composants maison + Tailwind v4 (utilities classes).
- **Pas de Pinia** : la source de vérité est l'API. État local via `ref`/`reactive`.
- **HTTP** : `fetch` natif + petit wrapper `src/api/client.ts` (typage + erreurs).
- **Auth admin** : token Bearer stocké dans `localStorage`, header injecté sur tous
les writes. Validé "best-effort" au login en pingant POST /api/members avec body vide.
- **Routing** : Vue Router 4 en mode `history`, code-split par page (lazy import).
- **Donut viz** : SVG natif, géométrie polaire calculée à la main dans `DonutPage.vue`.
---
## Ajouter une nouvelle entité ## Ajouter une nouvelle entité
1. Écrire la migration SQL dans `migrations/00X_xxx.sql` (CREATE TABLE). 1. Écrire la migration SQL dans `migrations/00X_xxx.sql` (CREATE TABLE).
+28 -7
View File
@@ -1,6 +1,9 @@
# infolab.ledonut-marseille.org — Le Donut Infolab (API JSON + SPA Vue)
#
# - /api/* -> reverse_proxy vers le conteneur donut-infolab (PHP/Apache, 127.0.0.1:8003)
# - tout le reste -> SPA Vue (build statique dans /srv/donut/web/infolab-front/dist)
# avec fallback /index.html pour le router HTML5 (Vue Router en mode history).
# infolab.ledonut-marseille.org — Le Donut Infolab (API + front)
infolab.ledonut-marseille.org { infolab.ledonut-marseille.org {
encode gzip zstd encode gzip zstd
@@ -12,10 +15,28 @@ infolab.ledonut-marseille.org {
-Server -Server
} }
reverse_proxy 127.0.0.1:8003 { # API JSON -> conteneur PHP (sur réseau Docker local, exposé en 127.0.0.1:8003)
header_up Host {host} @api path /api/*
header_up X-Real-IP {remote_host} handle @api {
header_up X-Forwarded-For {remote_host} reverse_proxy 127.0.0.1:8003 {
header_up X-Forwarded-Proto {scheme} header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
# SPA Vue : sert dist/ avec fallback index.html pour le routing client-side.
handle {
root * /srv/donut/web/infolab-front/dist
try_files {path} /index.html
file_server {
precompressed gzip
}
# Cache long pour les assets fingerprintés Vite, court pour index.html.
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
@html path /index.html
header @html Cache-Control "no-cache, no-store, must-revalidate"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}
+5
View File
@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Le Donut Infolab — plateforme de projets citoyens marseillais, organisée autour du modèle Donut (plancher social / plafond écologique)." />
<title>Le Donut Infolab</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1630
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.34",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/tsconfig": "^0.9.1",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vue-tsc": "^3.2.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#FAFAF6"/>
<circle cx="16" cy="16" r="13" fill="none" stroke="#22C55E" stroke-width="3" stroke-dasharray="20 6"/>
<circle cx="16" cy="16" r="8" fill="none" stroke="#F97316" stroke-width="3" stroke-dasharray="15 4"/>
<circle cx="16" cy="16" r="3" fill="#A855F7"/>
</svg>

After

Width:  |  Height:  |  Size: 378 B

+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
// Racine de l'app : on laisse le router décider du layout.
// AppLayout est appliqué via une route parente (cf. src/router/index.ts).
</script>
<template>
<RouterView />
</template>
+118
View File
@@ -0,0 +1,118 @@
/**
* Petit client HTTP typé pour l'API PHP.
*
* - GET (public) : pas de token nécessaire.
* - POST/PATCH/DELETE : ajoute Authorization: Bearer <token> si le token est
* stocké en localStorage (cf. composables/useAuth.ts).
*
* Lève une ApiError sur tout statut non-2xx. Le payload typé est attaché pour
* que les écrans puissent afficher fields:{...} sur les 422.
*/
import type { ApiErrorPayload, ErrorCode } from '@/types/api'
const TOKEN_STORAGE_KEY = 'infolab.admin_token'
/** Récupère le token admin depuis localStorage (ou null). */
export function getAdminToken(): string | null {
try {
return localStorage.getItem(TOKEN_STORAGE_KEY)
} catch {
return null
}
}
export function setAdminToken(token: string): void {
localStorage.setItem(TOKEN_STORAGE_KEY, token)
}
export function clearAdminToken(): void {
localStorage.removeItem(TOKEN_STORAGE_KEY)
}
export class ApiError extends Error {
// En TS 6 + erasableSyntaxOnly, on ne peut plus déclarer les champs via param
// properties (`constructor(public readonly x: ...)`). Déclaration explicite.
readonly status: number
readonly code: ErrorCode | string
readonly payload: ApiErrorPayload
constructor(status: number, code: ErrorCode | string, payload: ApiErrorPayload) {
super(payload.message ?? code)
this.status = status
this.code = code
this.payload = payload
}
/** Erreur de validation côté serveur : champ → code d'erreur. */
get fields(): Record<string, string> {
return this.payload.fields ?? {}
}
}
type Json = Record<string, unknown> | unknown[] | null
interface RequestOptions {
/** Si true, ajoute le header Authorization même sur un GET. Sinon, ne l'ajoute jamais sur GET. */
withAuth?: boolean
/** Paramètres de query string. null/undefined sont sautés. */
query?: Record<string, string | number | boolean | null | undefined>
}
function buildUrl(path: string, query?: RequestOptions['query']): string {
if (!query) return path
const params = new URLSearchParams()
for (const [k, v] of Object.entries(query)) {
if (v === undefined || v === null || v === '') continue
params.append(k, String(v))
}
const qs = params.toString()
return qs ? `${path}?${qs}` : path
}
async function request<T>(
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
path: string,
body?: Json,
opts: RequestOptions = {},
): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (body !== undefined) {
headers['Content-Type'] = 'application/json'
}
// Token Bearer sur tous les writes (et optionnellement les reads via withAuth).
if (method !== 'GET' || opts.withAuth) {
const token = getAdminToken()
if (token) headers['Authorization'] = `Bearer ${token}`
}
const url = buildUrl(path, opts.query)
const res = await fetch(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
})
// 204 No Content -> on retourne undefined as T (utile pour DELETE).
if (res.status === 204) {
return undefined as T
}
const text = await res.text()
const data = text === '' ? null : (JSON.parse(text) as unknown)
if (!res.ok) {
const payload = (data ?? { error: 'internal_server_error' }) as ApiErrorPayload
throw new ApiError(res.status, payload.error ?? `http_${res.status}`, payload)
}
return data as T
}
export const api = {
get: <T>(path: string, opts?: RequestOptions) => request<T>('GET', path, undefined, opts),
post: <T>(path: string, body: Json, opts?: RequestOptions) => request<T>('POST', path, body, opts),
patch: <T>(path: string, body: Json, opts?: RequestOptions) => request<T>('PATCH', path, body, opts),
delete: <T>(path: string, opts?: RequestOptions) => request<T>('DELETE', path, undefined, opts),
}
+136
View File
@@ -0,0 +1,136 @@
<script setup lang="ts">
/**
* Sidebar de navigation principale (fixée à gauche, 240px).
* Reproduit la maquette : logo + CTA Nouveau Projet + nav + badge bas de page.
*/
import { RouterLink } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
const { isAuthenticated, logout } = useAuth()
interface NavLink {
to: string
label: string
iconPath: string
}
// Icônes : paths SVG sobres (style lucide), 24x24 viewBox.
const navItems: NavLink[] = [
{
to: '/',
label: 'Accueil',
iconPath: 'M3 12 12 3l9 9M5 10v10a1 1 0 0 0 1 1h3v-6h6v6h3a1 1 0 0 0 1-1V10',
},
{
to: '/dashboard',
label: 'Tableau de bord',
iconPath: 'M3 3h7v9H3zM14 3h7v5h-7zM14 12h7v9h-7zM3 16h7v5H3z',
},
{
to: '/projects',
label: 'Projets Actifs',
iconPath: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
},
{
to: '/archives',
label: 'Archives',
iconPath: 'M21 8H3M5 8v11a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8M10 12h4M3 4h18v4H3z',
},
{
to: '/categories',
label: 'Catégories',
iconPath: 'M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0L2 12V2h10l8.6 8.6a2 2 0 0 1 0 2.8zM7 7h.01',
},
{
to: '/donut',
label: 'Vision Donut',
iconPath: 'M12 3a9 9 0 1 0 9 9M12 3v9h9M12 7a5 5 0 1 0 5 5',
},
]
</script>
<template>
<aside
class="w-60 shrink-0 border-r border-donut-border bg-donut-surface flex flex-col"
>
<!-- Logo / nom de l'app -->
<div class="px-5 py-5 border-b border-donut-border">
<RouterLink to="/" class="flex items-center gap-2.5 group">
<!-- Mini-donut SVG -->
<svg viewBox="0 0 32 32" class="w-7 h-7" aria-hidden="true">
<circle cx="16" cy="16" r="13" fill="none" stroke="#22C55E" stroke-width="3" stroke-dasharray="20 6" />
<circle cx="16" cy="16" r="8" fill="none" stroke="#F97316" stroke-width="3" stroke-dasharray="15 4" />
<circle cx="16" cy="16" r="3" fill="#A855F7" />
</svg>
<span class="font-semibold text-donut-text group-hover:text-black">
Le Donut <span class="text-donut-muted font-normal">Infolab</span>
</span>
</RouterLink>
</div>
<!-- CTA Nouveau Projet -->
<div class="px-3 pt-4 pb-2">
<RouterLink
:to="{ name: 'project-new' }"
class="flex items-center justify-center gap-1.5 w-full bg-donut-text text-white rounded-lg px-3 py-2.5 text-sm font-medium hover:bg-black transition-colors"
>
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
Nouveau Projet
</RouterLink>
</div>
<!-- Navigation -->
<nav class="px-3 py-2 flex-1">
<div class="px-2 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-wider text-donut-muted">
Navigation
</div>
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
v-slot="{ isActive, isExactActive }"
>
<a
:href="item.to"
@click.prevent
:class="[
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
(item.to === '/' ? isExactActive : isActive)
? 'bg-orange-50 text-status-active font-medium'
: 'text-donut-text hover:bg-gray-50',
]"
>
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="item.iconPath" />
</svg>
{{ item.label }}
</a>
</RouterLink>
</nav>
<!-- Badge bas de sidebar : statut admin -->
<div class="px-3 pb-4 pt-2 border-t border-donut-border">
<div v-if="isAuthenticated" class="flex items-center justify-between text-xs">
<span class="flex items-center gap-1.5 text-donut-muted">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
Admin connecté
</span>
<button @click="logout" class="text-donut-muted hover:text-donut-text underline">
Quitter
</button>
</div>
<RouterLink
v-else
:to="{ name: 'admin-login' }"
class="text-xs text-donut-muted hover:text-donut-text underline"
>
Connexion admin
</RouterLink>
<div class="mt-3 text-[10px] font-bold uppercase tracking-widest text-donut-muted">
Le Donut <span class="text-status-active">INFOLAB</span>
</div>
</div>
</aside>
</template>
+56
View File
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
name: string
initials?: string | null
avatarUrl?: string | null
/** Taille en px (carré). Défaut 36. */
size?: number
}>()
const sizePx = computed(() => props.size ?? 36)
const computedInitials = computed(() => {
if (props.initials && props.initials.length) return props.initials.slice(0, 3).toUpperCase()
// Fallback : 1re lettre de chaque mot, max 2.
return props.name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map(w => w[0]?.toUpperCase() ?? '')
.join('')
|| '?'
})
// Couleur stable par hash du nom -> palette pastel.
const palette = ['#3B82F6', '#EF4444', '#14B8A6', '#F97316', '#22C55E', '#A855F7', '#0EA5E9', '#EAB308']
const color = computed(() => {
let h = 0
for (const c of props.name) h = (h * 31 + c.charCodeAt(0)) | 0
return palette[Math.abs(h) % palette.length]
})
</script>
<template>
<span
v-if="avatarUrl"
class="inline-block rounded-full bg-gray-100 bg-center bg-cover shrink-0"
:style="{ width: `${sizePx}px`, height: `${sizePx}px`, backgroundImage: `url(${avatarUrl})` }"
:title="name"
/>
<span
v-else
class="inline-flex items-center justify-center rounded-full font-medium shrink-0"
:style="{
width: `${sizePx}px`,
height: `${sizePx}px`,
fontSize: `${Math.round(sizePx * 0.4)}px`,
background: color + '1A',
color: color,
}"
:title="name"
>
{{ computedInitials }}
</span>
</template>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{ name: string; colorHex: string }>()
</script>
<template>
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:style="{ background: colorHex + '1A', color: colorHex }"
>
{{ name }}
</span>
</template>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
defineProps<{ message: string; retry?: () => void }>()
</script>
<template>
<div class="rounded-lg border border-red-200 bg-red-50 text-red-700 px-4 py-3">
<div class="flex items-start gap-3">
<svg viewBox="0 0 24 24" class="w-5 h-5 mt-0.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/>
</svg>
<div class="flex-1">
<div class="font-medium">Une erreur est survenue</div>
<div class="text-sm mt-0.5 opacity-90">{{ message }}</div>
</div>
<button
v-if="retry"
type="button"
class="text-sm font-medium underline hover:no-underline"
@click="retry"
>
Réessayer
</button>
</div>
</div>
</template>
@@ -0,0 +1,158 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { api, ApiError } from '@/api/client'
import { useAuth } from '@/composables/useAuth'
import type { Member } from '@/types/api'
/**
* Modal "Rejoindre le projet".
*
* Stratégie V1 (sans auth user) :
* - Si admin connecté : POST /api/members (créé un membre "person") puis
* POST /api/projects/:slug/members pour l'attacher.
* - Sinon : affiche un avertissement + lien vers /admin/login. On ne fait
* pas de fake-auth ni de bypass du token.
*
* V2 prévue : auth OIDC Nextcloud -> l'utilisateur se rejoint lui-même
* via un endpoint public dédié.
*/
const props = defineProps<{ projectSlug: string }>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'joined', payload: { member: Member; role: string }): void
}>()
const { isAuthenticated } = useAuth()
const name = ref('')
const email = ref('')
const submitting = ref(false)
const error = ref<string | null>(null)
const fieldErrors = ref<Record<string, string>>({})
async function submit() {
error.value = null
fieldErrors.value = {}
if (!isAuthenticated.value) return
if (!name.value.trim()) {
fieldErrors.value.name = 'required'
return
}
submitting.value = true
try {
// 1) Crée le membre (idempotent côté slug : si collision, suffixe -2 etc.).
const member = await api.post<Member>('/api/members', {
display_name: name.value.trim(),
kind: 'person',
contact_email: email.value.trim() || undefined,
})
// 2) L'attache au projet en rôle contributor.
await api.post<{ project_id: number; role: string }>(
`/api/projects/${encodeURIComponent(props.projectSlug)}/members`,
{ member_slug: member.slug, role: 'contributor' },
)
emit('joined', { member, role: 'contributor' })
emit('close')
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 422) {
fieldErrors.value = e.fields
} else if (e.status === 409) {
error.value = e.payload.message ?? 'Ce membre fait déjà partie du projet.'
} else {
error.value = e.payload.message ?? e.code
}
} else {
error.value = 'Erreur réseau'
}
} finally {
submitting.value = false
}
}
</script>
<template>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
@click.self="emit('close')"
>
<div class="bg-donut-surface rounded-2xl shadow-xl w-full max-w-md p-6">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<h2 class="text-xl font-semibold text-donut-text">Rejoindre le projet</h2>
<p class="text-sm text-donut-muted mt-1">Indique ton nom tu seras ajouté en tant que contributeur.</p>
</div>
<button
type="button"
class="text-donut-muted hover:text-donut-text"
@click="emit('close')"
aria-label="Fermer"
>
<svg viewBox="0 0 24 24" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M18 6 6 18M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Pas de token : avertissement + lien login. -->
<div v-if="!isAuthenticated" class="rounded-lg border border-amber-200 bg-amber-50 text-amber-800 px-4 py-3 text-sm">
L'ajout de membres est réservé à l'admin en V1.
<RouterLink :to="{ name: 'admin-login' }" class="underline font-medium ml-1">
Se connecter
</RouterLink>
la V2 ouvrira l'auto-rejoint via Nextcloud.
</div>
<!-- Form admin -->
<form v-else @submit.prevent="submit" class="space-y-3">
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Nom *</label>
<input
v-model="name"
type="text"
placeholder="ex: Camille D."
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
:class="fieldErrors.display_name || fieldErrors.name ? 'border-red-400' : ''"
/>
<p v-if="fieldErrors.display_name || fieldErrors.name" class="text-xs text-red-600 mt-1">
Le nom est requis.
</p>
</div>
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Email (optionnel)</label>
<input
v-model="email"
type="email"
placeholder="camille@example.org"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
:class="fieldErrors.contact_email ? 'border-red-400' : ''"
/>
<p v-if="fieldErrors.contact_email" class="text-xs text-red-600 mt-1">Email invalide.</p>
</div>
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
{{ error }}
</div>
<div class="flex items-center justify-end gap-2 pt-2">
<button
type="button"
class="text-sm px-3 py-2 rounded-lg hover:bg-gray-50 text-donut-text"
@click="emit('close')"
>
Annuler
</button>
<button
type="submit"
:disabled="submitting"
class="text-sm px-4 py-2 rounded-lg bg-donut-text text-white hover:bg-black disabled:opacity-50"
>
{{ submitting ? 'Ajout' : 'Rejoindre' }}
</button>
</div>
</form>
</div>
</div>
</template>
+12
View File
@@ -0,0 +1,12 @@
<script setup lang="ts">
defineProps<{ label?: string }>()
</script>
<template>
<div class="flex items-center gap-3 text-donut-muted">
<svg viewBox="0 0 24 24" class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span>{{ label ?? 'Chargement…' }}</span>
</div>
</template>
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
value: number
/** Couleur de la barre (défaut noir-donut). Accepte n'importe quel CSS color. */
color?: string
/** Affiche `XX%` à droite. */
showLabel?: boolean
}>()
const safe = computed(() => Math.max(0, Math.min(100, props.value)))
</script>
<template>
<div class="flex items-center gap-3">
<div class="h-1.5 flex-1 bg-gray-100 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-300"
:style="{ width: `${safe}%`, background: color ?? '#111827' }"
/>
</div>
<span v-if="showLabel" class="text-xs font-medium text-donut-muted tabular-nums">
{{ safe }}%
</span>
</div>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import type { ProjectListRow } from '@/types/api'
import StatusBadge from './StatusBadge.vue'
import CategoryBadge from './CategoryBadge.vue'
import ProgressBar from './ProgressBar.vue'
defineProps<{ project: ProjectListRow }>()
</script>
<template>
<RouterLink
:to="{ name: 'project-detail', params: { slug: project.slug } }"
class="group block rounded-xl border border-donut-border bg-donut-surface overflow-hidden hover:shadow-md transition-shadow"
>
<!-- Cover image ou placeholder dégradé -->
<div
class="h-32 w-full bg-gray-100 bg-center bg-cover"
:style="project.cover_image_url
? { backgroundImage: `url(${project.cover_image_url})` }
: { background: `linear-gradient(135deg, ${project.category.color_hex}33, ${project.category.color_hex}11)` }
"
/>
<div class="p-5">
<div class="flex items-center gap-2 mb-3 flex-wrap">
<StatusBadge :status="project.status" />
<CategoryBadge :name="project.category.name" :color-hex="project.category.color_hex" />
</div>
<h3 class="text-base font-semibold text-donut-text group-hover:text-black line-clamp-2">
{{ project.title }}
</h3>
<p
v-if="project.short_description"
class="text-sm text-donut-muted mt-2 line-clamp-3 leading-relaxed"
>
{{ project.short_description }}
</p>
<div class="mt-4">
<div class="flex items-center justify-between text-xs text-donut-muted mb-1">
<span>Progression</span>
<span class="tabular-nums font-medium">{{ project.progress_percent }}%</span>
</div>
<ProgressBar :value="project.progress_percent" :color="project.category.color_hex" />
</div>
</div>
</RouterLink>
</template>
<style scoped>
/* Tailwind v4 fournit déjà line-clamp via les utilitaires. */
</style>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { ProjectStatus } from '@/types/api'
const props = defineProps<{ status: ProjectStatus }>()
// On utilise des hex avec alpha (RRGGBBAA) pour avoir des pastels sans
// dépendre d'une palette Tailwind. Approche fragile pour des couleurs trop
// claires mais OK pour nos 3 statuts.
const config: Record<ProjectStatus, { label: string; bg: string; fg: string }> = {
active: { label: 'Actif', bg: '#F973161A', fg: '#C2410C' },
completed: { label: 'Réalisé', bg: '#A855F71A', fg: '#7E22CE' },
archived: { label: 'Archivé', bg: '#6B72801A', fg: '#374151' },
}
</script>
<template>
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
:style="{ background: config[props.status].bg, color: config[props.status].fg }"
>
{{ config[props.status].label }}
</span>
</template>
+35
View File
@@ -0,0 +1,35 @@
/**
* Composable d'auth admin minimal.
*
* Pas de mécanique de session côté API (l'API est stateless). On stocke
* juste le token Bearer en localStorage et on l'envoie sur les writes.
*
* - isAuthenticated : "j'ai un token, donc je passe pour admin côté front"
* - login(token) : stocke + déclenche reactivité
* - logout() : efface
*
* Une vraie validation du token se fait quand on appelle un endpoint protégé
* (l'API renvoie 401 si le token est faux). C'est le client api.ts qui
* laisse remonter cette erreur et le router peut alors rediriger vers /admin/login.
*/
import { computed, ref } from 'vue'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/api/client'
const tokenRef = ref<string | null>(getAdminToken())
export function useAuth() {
return {
token: computed(() => tokenRef.value),
isAuthenticated: computed(() => tokenRef.value !== null && tokenRef.value !== ''),
login(token: string) {
const trimmed = token.trim()
setAdminToken(trimmed)
tokenRef.value = trimmed
},
logout() {
clearAdminToken()
tokenRef.value = null
},
}
}
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import AppSidebar from '@/components/AppSidebar.vue'
</script>
<template>
<div class="flex h-full min-h-screen bg-donut-bg">
<AppSidebar />
<main class="flex-1 overflow-y-auto">
<RouterView v-slot="{ Component }">
<component :is="Component" />
</RouterView>
</main>
</div>
</template>
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import { router } from './router'
import './style.css'
import App from './App.vue'
createApp(App).use(router).mount('#app')
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api, ApiError } from '@/api/client'
import { useAuth } from '@/composables/useAuth'
const router = useRouter()
const { login, isAuthenticated, logout } = useAuth()
const token = ref('')
const submitting = ref(false)
const error = ref<string | null>(null)
async function submit() {
error.value = null
const t = token.value.trim()
if (!t) return
submitting.value = true
// On stocke le token, puis on tente un appel protégé pour le valider.
// Si 401 -> on l'efface et on affiche l'erreur. Si OK -> on redirige.
login(t)
try {
// Le plus léger : POST sur une route protégée avec un body invalide volontairement
// pour récupérer 422 (auth OK) ou 401 (auth KO) sans créer de ressource.
// Mais c'est sale. Mieux : faire un GET protégé. On n'a pas de "whoami".
// Solution : POST /api/members avec body vide -> renvoie 422 si admin OK, 401 sinon.
await api.post('/api/members', {})
router.push({ name: 'dashboard' })
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 401) {
logout()
error.value = 'Token invalide.'
} else {
// 422 (validation_failed) signifie que l'auth est OK, on continue.
router.push({ name: 'dashboard' })
}
} else {
logout()
error.value = 'Erreur réseau — réessaie.'
}
} finally {
submitting.value = false
}
}
</script>
<template>
<section class="min-h-full flex items-start justify-center px-6 py-16">
<div class="w-full max-w-md">
<header class="text-center mb-6">
<h1 class="text-2xl font-semibold text-donut-text">Connexion admin</h1>
<p class="text-donut-muted text-sm mt-2">
Saisis le token Bearer pour débloquer la création de projets, l'édition et la modération.
</p>
</header>
<div v-if="isAuthenticated" class="rounded-xl border border-green-200 bg-green-50 text-green-800 p-4 text-sm">
Tu es déjà connecté en admin.
<button @click="logout" class="ml-2 underline font-medium">Se déconnecter</button>
</div>
<form v-else @submit.prevent="submit" class="rounded-xl border border-donut-border bg-donut-surface p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Token admin</label>
<input
v-model="token"
type="password"
autocomplete="off"
placeholder="64 caractères hex"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm font-mono outline-none focus:border-donut-text"
:class="error ? 'border-red-400' : ''"
/>
<p class="text-xs text-donut-muted mt-1">
Visible dans <code>.env</code> sous <code>ADMIN_TOKEN</code>.
</p>
</div>
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
{{ error }}
</div>
<button
type="submit"
:disabled="submitting || !token.trim()"
class="w-full bg-donut-text text-white rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-black disabled:opacity-50"
>
{{ submitting ? 'Vérification' : 'Se connecter' }}
</button>
</form>
<p class="text-xs text-donut-muted text-center mt-4">
Pas de session côté serveur : le token est stocké dans <code>localStorage</code>.
</p>
</div>
</section>
</template>
+55
View File
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api, ApiError } from '@/api/client'
import type { Paginated, ProjectListRow } from '@/types/api'
import ProjectCard from '@/components/ProjectCard.vue'
import LoadingState from '@/components/LoadingState.vue'
import ErrorState from '@/components/ErrorState.vue'
const projects = ref<ProjectListRow[]>([])
const total = ref(0)
const loading = ref(true)
const error = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
const r = await api.get<Paginated<ProjectListRow>>('/api/projects', {
query: { status: 'archived', per_page: 50 },
})
projects.value = r.data
total.value = r.meta.total
} catch (e) {
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<section class="px-10 py-8 max-w-7xl">
<header>
<h1 class="text-3xl font-semibold text-donut-text">Archives</h1>
<p class="text-donut-muted mt-1">Les projets archivés de la communauté mémoire du Donut.</p>
</header>
<div class="mt-8">
<LoadingState v-if="loading" />
<ErrorState v-else-if="error" :message="error" :retry="load" />
<div v-else-if="projects.length === 0" class="text-donut-muted text-center py-16">
Aucun projet archivé pour le moment.
</div>
<div v-else>
<div class="text-xs text-donut-muted mb-4">
{{ total }} projet{{ total > 1 ? 's' : '' }} archivé{{ total > 1 ? 's' : '' }}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<ProjectCard v-for="p in projects" :key="p.id" :project="p" />
</div>
</div>
</div>
</section>
</template>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { api, ApiError } from '@/api/client'
import type { Category, Stats, Wrapped } from '@/types/api'
import LoadingState from '@/components/LoadingState.vue'
import ErrorState from '@/components/ErrorState.vue'
const categories = ref<Category[]>([])
const counts = ref<Record<string, number>>({})
const loading = ref(true)
const error = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
// 2 appels en parallèle : la liste des catégories + stats pour les counts par slug.
const [cats, stats] = await Promise.all([
api.get<Wrapped<Category>>('/api/categories'),
api.get<Stats>('/api/stats'),
])
categories.value = cats.data
counts.value = Object.fromEntries(stats.by_category.map(c => [c.slug, c.count]))
} catch (e) {
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
} finally {
loading.value = false
}
}
onMounted(load)
const DIMENSION_LABEL = {
social: 'Plancher social',
ecologique: 'Plafond écologique',
transversal: 'Transversal',
} as const
</script>
<template>
<section class="px-10 py-8 max-w-6xl">
<header>
<h1 class="text-3xl font-semibold text-donut-text">Catégories Thématiques</h1>
<p class="text-donut-muted mt-1">Explorez les initiatives par domaine d'impact.</p>
</header>
<div class="mt-8">
<LoadingState v-if="loading" />
<ErrorState v-else-if="error" :message="error" :retry="load" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<RouterLink
v-for="cat in categories"
:key="cat.id"
:to="{ name: 'projects', query: { category: cat.slug } }"
class="group block rounded-xl border border-donut-border bg-donut-surface overflow-hidden hover:shadow-md transition-shadow"
:style="{ borderTopWidth: '4px', borderTopStyle: 'solid', borderTopColor: cat.color_hex }"
>
<div class="p-6">
<div
class="w-11 h-11 rounded-lg flex items-center justify-center mb-4"
:style="{ background: cat.color_hex + '1A', color: cat.color_hex }"
>
<svg viewBox="0 0 24 24" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
</svg>
</div>
<h3 class="text-lg font-semibold text-donut-text group-hover:text-black">{{ cat.name }}</h3>
<p v-if="cat.description" class="text-sm text-donut-muted mt-2 leading-relaxed">
{{ cat.description }}
</p>
<div class="mt-5 flex items-center justify-between gap-2">
<span class="inline-flex items-center rounded-full bg-orange-50 text-status-active px-2.5 py-1 text-xs font-medium">
{{ counts[cat.slug] ?? 0 }} projet{{ (counts[cat.slug] ?? 0) === 1 ? '' : 's' }}
</span>
<span class="text-[10px] text-donut-muted uppercase tracking-wider">
{{ DIMENSION_LABEL[cat.dimension] }}
</span>
</div>
</div>
</RouterLink>
</div>
</div>
</section>
</template>
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { api, ApiError } from '@/api/client'
import type { ActivityEvent, Stats, Wrapped } from '@/types/api'
import LoadingState from '@/components/LoadingState.vue'
import ErrorState from '@/components/ErrorState.vue'
import { formatRelative } from '@/utils/formatDate'
const stats = ref<Stats | null>(null)
const activity = ref<ActivityEvent[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
const [s, a] = await Promise.all([
api.get<Stats>('/api/stats'),
api.get<Wrapped<ActivityEvent>>('/api/activity', { query: { limit: 12 } }),
])
stats.value = s
activity.value = a.data
} catch (e) {
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
} finally {
loading.value = false
}
}
onMounted(load)
// Verbes -> formulation lisible avec actor_label avant.
const VERB_LABEL: Record<string, string> = {
joined: 'a rejoint',
left: 'a quitté',
created_project: 'a créé',
updated_project: 'a mis à jour',
completed_project: 'a terminé',
added_step: 'a ajouté une étape à',
completed_step: 'a terminé une étape de',
posted_discussion: 'a publié dans',
added_resource: 'a ajouté une ressource à',
}
const kpiBlocks = computed(() => {
if (!stats.value) return []
const total = stats.value.projects_total
return [
{
label: 'Projets Actifs',
value: stats.value.projects_active,
sub: `Sur ${total} projet${total > 1 ? 's' : ''} au total`,
iconPath: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
iconColor: '#F97316',
},
{
label: 'Participants',
value: stats.value.participants_total,
sub: 'Citoyens engagés',
iconPath: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 7a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75',
iconColor: '#EF4444',
},
{
label: 'Projets Réalisés',
value: stats.value.projects_completed,
sub: 'Impacts concrets',
iconPath: 'M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
iconColor: '#22C55E',
},
{
label: 'Projets Historiques',
value: stats.value.projects_archived,
sub: 'Mémoire du Donut',
iconPath: 'M12 8v4l3 3 M21 12a9 9 0 1 1-9-9',
iconColor: '#6B7280',
},
]
})
</script>
<template>
<section class="px-10 py-8 max-w-7xl">
<header>
<h1 class="text-3xl font-semibold text-donut-text">Tableau de bord</h1>
<p class="text-donut-muted mt-1">Vue d'ensemble de l'activité citoyenne et de l'intelligence collective.</p>
</header>
<LoadingState v-if="loading" class="mt-8" />
<ErrorState v-else-if="error" :message="error" :retry="load" class="mt-8" />
<div v-else class="mt-8 space-y-6">
<!-- KPI -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="k in kpiBlocks"
:key="k.label"
class="rounded-xl border border-donut-border bg-donut-surface p-5"
>
<div class="flex items-start justify-between">
<div class="text-sm text-donut-muted">{{ k.label }}</div>
<div
class="w-8 h-8 rounded-md flex items-center justify-center"
:style="{ background: k.iconColor + '1A', color: k.iconColor }"
>
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path :d="k.iconPath"/>
</svg>
</div>
</div>
<div class="mt-2 text-3xl font-semibold text-donut-text">{{ k.value }}</div>
<div class="text-xs text-donut-muted mt-1">{{ k.sub }}</div>
</div>
</div>
<!-- 2 colonnes : Vision Donut (résumé) + Activité Récente -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Vision Donut résumé -->
<div class="lg:col-span-2 rounded-xl border border-donut-border bg-donut-surface p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-donut-text">Vision Donut</h2>
<p class="text-sm text-donut-muted">Plancher social et plafond écologique : nos projets dans l'espace sûr.</p>
</div>
</div>
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6 items-center">
<!-- mini donut -->
<div class="flex items-center justify-center">
<svg viewBox="-100 -100 200 200" class="w-44 h-44">
<circle r="80" fill="#22C55E1A" stroke="#22C55E" stroke-width="0.5" />
<circle r="55" fill="#FAFAF6" />
<circle r="40" fill="#F9731622" stroke="#F97316" stroke-width="0.5" />
<circle r="20" fill="#FAFAF6" />
<text text-anchor="middle" font-size="9" fill="#111827" font-weight="600">LE DONUT</text>
<text y="11" text-anchor="middle" font-size="6" fill="#6B7280">
{{ stats?.projects_total ?? 0 }} projets
</text>
</svg>
</div>
<!-- légende dimensions -->
<div class="space-y-3">
<div>
<div class="text-xs uppercase tracking-wider text-status-active font-semibold">Plancher social</div>
<div class="text-sm text-donut-text mt-1">
{{ stats?.by_dimension.social ?? 0 }} projets sociaux
</div>
</div>
<div>
<div class="text-xs uppercase tracking-wider text-status-completed font-semibold" style="color:#22C55E">Plafond écologique</div>
<div class="text-sm text-donut-text mt-1">
{{ stats?.by_dimension.ecologique ?? 0 }} projets environnementaux
</div>
</div>
<div>
<div class="text-xs uppercase tracking-wider text-donut-muted font-semibold">Transversal</div>
<div class="text-sm text-donut-text mt-1">
{{ stats?.by_dimension.transversal ?? 0 }} projets
</div>
</div>
</div>
</div>
<div class="mt-6 text-right">
<RouterLink
:to="{ name: 'donut' }"
class="text-sm text-status-active font-medium hover:underline inline-flex items-center gap-1"
>
Voir la vision Donut complète
<span aria-hidden="true"></span>
</RouterLink>
</div>
</div>
<!-- Activité Récente -->
<div class="rounded-xl border border-donut-border bg-donut-surface p-6">
<h2 class="text-lg font-semibold text-donut-text">Activité Récente</h2>
<p class="text-sm text-donut-muted">Les dernières dynamiques de la communauté.</p>
<ul v-if="activity.length" class="mt-4 space-y-3">
<li v-for="a in activity" :key="a.id" class="flex items-start gap-3">
<span class="mt-1.5 w-2 h-2 rounded-full bg-status-active shrink-0" aria-hidden="true" />
<div class="flex-1 min-w-0 text-sm">
<div class="text-donut-text">
<span class="font-medium">{{ a.actor_label }}</span>
{{ ' ' }}
<span class="text-donut-muted">{{ VERB_LABEL[a.verb] ?? a.verb }}</span>
</div>
<div class="text-donut-muted text-xs mt-0.5 truncate">{{ a.summary }}</div>
<div class="text-donut-muted text-xs mt-0.5">{{ formatRelative(a.occurred_at) }}</div>
</div>
</li>
</ul>
<p v-else class="text-sm text-donut-muted mt-4">Aucune activité récente.</p>
</div>
</div>
</div>
</section>
</template>
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
/**
* Visualisation Donut — SVG custom.
*
* Modèle Donut Kate Raworth adapté :
* - Anneau extérieur (vert pâle) = "Plafond écologique" qu'on ne doit pas dépasser
* - Anneau intérieur (orange pâle) = "Plancher social" qu'on ne doit pas franchir vers le bas
* - Zone centrale entre les deux = "espace sûr" où vivent les projets citoyens
*
* 6 secteurs colorés (60° chacun) = les 6 catégories thématiques.
* Chaque projet = 1 point coloré (par catégorie) placé dans son secteur,
* à un rayon qui varie légèrement (hash stable de l'id) pour éviter les chevauchements.
* Survol → tooltip avec titre du projet et lien vers sa fiche.
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, ApiError } from '@/api/client'
import type { Category, Dimension, Paginated, ProjectListRow, Wrapped } from '@/types/api'
import LoadingState from '@/components/LoadingState.vue'
import ErrorState from '@/components/ErrorState.vue'
const router = useRouter()
const categories = ref<Category[]>([])
const projects = ref<ProjectListRow[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const hoveredProject = ref<ProjectListRow | null>(null)
async function load() {
loading.value = true
error.value = null
try {
const [cats, projs] = await Promise.all([
api.get<Wrapped<Category>>('/api/categories'),
api.get<Paginated<ProjectListRow>>('/api/projects', { query: { per_page: 100 } }),
])
categories.value = [...cats.data].sort((a, b) => a.sort_order - b.sort_order)
projects.value = projs.data
} catch (e) {
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
} finally {
loading.value = false
}
}
onMounted(load)
// --- Géométrie (rayons en unités du viewBox -250..250) ---
const R_PROJECT_OUTER = 200
const R_PROJECT_INNER = 110
const R_FLOOR_OUTER = 95
const R_FLOOR_INNER = 70
const R_CENTER = 60
/** deg→rad avec offset -90° pour que 0° soit en haut (12 h). */
function rad(deg: number): number {
return (deg - 90) * Math.PI / 180
}
function pt(angleDeg: number, r: number): [number, number] {
const a = rad(angleDeg)
return [Math.cos(a) * r, Math.sin(a) * r]
}
function wedgePath(startDeg: number, endDeg: number, rInner: number, rOuter: number): string {
const [x1, y1] = pt(startDeg, rOuter)
const [x2, y2] = pt(endDeg, rOuter)
const [x3, y3] = pt(endDeg, rInner)
const [x4, y4] = pt(startDeg, rInner)
const largeArc = endDeg - startDeg > 180 ? 1 : 0
return [
`M ${x1.toFixed(2)} ${y1.toFixed(2)}`,
`A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`,
`L ${x3.toFixed(2)} ${y3.toFixed(2)}`,
`A ${rInner} ${rInner} 0 ${largeArc} 0 ${x4.toFixed(2)} ${y4.toFixed(2)}`,
'Z',
].join(' ')
}
interface WedgeView {
cat: Category
startDeg: number
endDeg: number
centerDeg: number
path: string
labelPos: [number, number]
}
const wedges = computed<WedgeView[]>(() => {
const n = categories.value.length || 6
const step = 360 / n
return categories.value.map((cat, i) => {
const startDeg = i * step
const endDeg = (i + 1) * step
const centerDeg = (startDeg + endDeg) / 2
return {
cat,
startDeg,
endDeg,
centerDeg,
path: wedgePath(startDeg, endDeg, R_PROJECT_INNER, R_PROJECT_OUTER),
labelPos: pt(centerDeg, (R_PROJECT_INNER + R_PROJECT_OUTER) / 2 + 25),
}
})
})
interface DotView {
project: ProjectListRow
x: number
y: number
r: number
color: string
}
/** Hash 32-bit basique sur une chaîne (stabilité de positionnement entre rendus). */
function strHash(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
return Math.abs(h)
}
const dots = computed<DotView[]>(() => {
const byCat = new Map<number, ProjectListRow[]>()
for (const p of projects.value) {
const arr = byCat.get(p.category.id) ?? []
arr.push(p)
byCat.set(p.category.id, arr)
}
const out: DotView[] = []
for (const w of wedges.value) {
const ps = byCat.get(w.cat.id) ?? []
const span = w.endDeg - w.startDeg
ps.forEach((p, idx) => {
// Répartit angulairement les projets sur la largeur du secteur (avec marge),
// et ajoute un petit jitter de rayon pour ne pas tous les aligner.
const t = (idx + 1) / (ps.length + 1)
const angleDeg = w.startDeg + span * t
const jitter = ((strHash(p.slug) % 100) / 100 - 0.5) * (R_PROJECT_OUTER - R_PROJECT_INNER - 30)
const r = (R_PROJECT_INNER + R_PROJECT_OUTER) / 2 + jitter
const [x, y] = pt(angleDeg, r)
// Diamètre du point en fonction de la progression (un peu plus gros si avancé).
const radius = 4 + (p.progress_percent / 100) * 4
out.push({ project: p, x, y, r: radius, color: p.category.color_hex })
})
}
return out
})
// Légende groupée par dimension
const DIMENSION_ORDER: Dimension[] = ['social', 'ecologique', 'transversal']
const DIMENSION_META: Record<Dimension, { label: string; color: string }> = {
social: { label: 'Plancher social', color: '#F97316' },
ecologique: { label: 'Plafond écologique', color: '#22C55E' },
transversal: { label: 'Transversal', color: '#6B7280' },
}
const categoriesByDimension = computed(() => {
const map: Record<Dimension, Category[]> = { social: [], ecologique: [], transversal: [] }
for (const c of categories.value) map[c.dimension].push(c)
return map
})
function gotoProject(p: ProjectListRow) {
router.push({ name: 'project-detail', params: { slug: p.slug } })
}
</script>
<template>
<section class="px-10 py-8 max-w-7xl">
<header class="text-center max-w-3xl mx-auto">
<h1 class="text-3xl font-semibold text-donut-text">Le Donut des Projets Citoyens</h1>
<p class="text-donut-muted mt-2">
Inspiré du modèle de l'économie du Donut de Kate Raworth. L'Infolab des projets citoyens
explore les initiatives qui poussent une vie marseillaise dans l'espace sûr : entre
plancher social et plafond écologique.
</p>
</header>
<LoadingState v-if="loading" class="mt-12 justify-center" />
<ErrorState v-else-if="error" :message="error" :retry="load" class="mt-12" />
<div v-else class="mt-10 grid grid-cols-1 lg:grid-cols-3 gap-10 items-start">
<!-- Donut SVG (2/3 sur grands écrans) -->
<div class="lg:col-span-2 flex flex-col items-center">
<svg viewBox="-250 -250 500 500" class="w-full max-w-[560px] aspect-square">
<!-- Plafond écologique (anneau externe) -->
<circle r="240" fill="#22C55E14" />
<circle r="215" fill="#FAFAF6" />
<text
v-for="i in 1" :key="`ceil-${i}`"
x="0" y="-228" text-anchor="middle" font-size="9"
fill="#22C55E" font-weight="600" style="letter-spacing:1px"
>PLAFOND ÉCOLOGIQUE</text>
<!-- Plancher social (anneau interne) -->
<circle :r="R_FLOOR_OUTER" fill="#F9731614" />
<circle :r="R_FLOOR_INNER" fill="#FAFAF6" />
<!-- 6 secteurs catégories -->
<g>
<path
v-for="w in wedges" :key="w.cat.id"
:d="w.path"
:fill="w.cat.color_hex + '14'"
:stroke="w.cat.color_hex + '40'"
stroke-width="0.5"
/>
</g>
<!-- Étiquettes catégories à l'extérieur des secteurs -->
<g>
<text
v-for="w in wedges" :key="`label-${w.cat.id}`"
:x="w.labelPos[0]" :y="w.labelPos[1]"
text-anchor="middle"
font-size="10"
:fill="w.cat.color_hex"
font-weight="600"
>{{ w.cat.name }}</text>
</g>
<!-- Points projets -->
<g>
<circle
v-for="d in dots" :key="d.project.id"
:cx="d.x" :cy="d.y" :r="d.r"
:fill="d.color"
stroke="white" stroke-width="1.5"
class="cursor-pointer transition-transform hover:scale-150 origin-center"
@mouseenter="hoveredProject = d.project"
@mouseleave="hoveredProject = null"
@click="gotoProject(d.project)"
>
<title>{{ d.project.title }}</title>
</circle>
</g>
<!-- Centre -->
<circle :r="R_CENTER" fill="#FFFFFF" stroke="#E5E7EB" />
<text x="0" y="-4" text-anchor="middle" font-size="14" fill="#111827" font-weight="700"
style="letter-spacing:2px">LEDONUT</text>
<text x="0" y="14" text-anchor="middle" font-size="9" fill="#6B7280">
{{ projects.length }} projet{{ projects.length > 1 ? 's' : '' }}
</text>
</svg>
<!-- Tooltip projet survolé -->
<div class="mt-4 h-12 flex items-center justify-center">
<transition name="fade">
<div
v-if="hoveredProject"
class="px-3 py-1.5 rounded-full bg-white border border-donut-border shadow-sm text-sm flex items-center gap-2"
>
<span
class="w-2 h-2 rounded-full"
:style="{ background: hoveredProject.category.color_hex }"
/>
<span class="font-medium">{{ hoveredProject.title }}</span>
<span class="text-donut-muted text-xs"> {{ hoveredProject.progress_percent }}%</span>
</div>
<div v-else class="text-xs text-donut-muted">
Survolez un point pour voir le détail d'un projet
</div>
</transition>
</div>
</div>
<!-- Légende -->
<aside class="space-y-6">
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
<h2 class="text-base font-semibold text-donut-text">Comment lire le donut ?</h2>
<p class="text-sm text-donut-muted mt-2 leading-relaxed">
Chaque <strong class="text-donut-text">point</strong> est un projet citoyen, placé dans son
secteur de catégorie. L'anneau extérieur représente le
<span class="font-medium" style="color:#22C55E">plafond écologique</span> et l'anneau
intérieur le <span class="font-medium" style="color:#F97316">plancher social</span>.
L'objectif : que tous les projets contribuent à rester dans l'espace sûr entre les deux.
</p>
</div>
<div
v-for="dim in DIMENSION_ORDER"
:key="dim"
class="rounded-xl border border-donut-border bg-donut-surface p-5"
>
<div class="flex items-center gap-2 mb-3">
<span class="w-2 h-2 rounded-full" :style="{ background: DIMENSION_META[dim].color }" />
<h3 class="text-xs font-semibold uppercase tracking-wider" :style="{ color: DIMENSION_META[dim].color }">
{{ DIMENSION_META[dim].label }}
</h3>
</div>
<ul v-if="categoriesByDimension[dim].length" class="space-y-2">
<li
v-for="c in categoriesByDimension[dim]"
:key="c.id"
class="flex items-center gap-2 text-sm"
>
<span class="w-2.5 h-2.5 rounded-full shrink-0" :style="{ background: c.color_hex }" />
<RouterLink
:to="{ name: 'projects', query: { category: c.slug } }"
class="text-donut-text hover:underline"
>
{{ c.name }}
</RouterLink>
</li>
</ul>
<p v-else class="text-xs text-donut-muted">Aucune catégorie dans cette dimension.</p>
</div>
</aside>
</div>
</section>
</template>
<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<script setup lang="ts">
/**
* Accueil = redirection immédiate vers /donut (la vision Donut est la "home" du Donut Infolab).
* On pourrait montrer un vrai landing page plus tard.
*/
import { useRouter } from 'vue-router'
import { onMounted } from 'vue'
const router = useRouter()
onMounted(() => router.replace({ name: 'donut' }))
</script>
<template>
<div />
</template>
+17
View File
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
</script>
<template>
<section class="px-10 py-16 text-center">
<div class="text-6xl font-bold text-donut-muted">404</div>
<h1 class="text-2xl font-semibold mt-2 text-donut-text">Page introuvable</h1>
<p class="text-donut-muted mt-2">L'URL demandée n'existe pas (ou plus).</p>
<RouterLink
to="/"
class="inline-block mt-6 bg-donut-text text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-black transition-colors"
>
Retour à l'accueil
</RouterLink>
</section>
</template>
+615
View File
@@ -0,0 +1,615 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { api, ApiError } from '@/api/client'
import { useAuth } from '@/composables/useAuth'
import type { Discussion, ProjectDetail, Resource, RoadmapStep, Wrapped } from '@/types/api'
import StatusBadge from '@/components/StatusBadge.vue'
import CategoryBadge from '@/components/CategoryBadge.vue'
import ProgressBar from '@/components/ProgressBar.vue'
import Avatar from '@/components/Avatar.vue'
import LoadingState from '@/components/LoadingState.vue'
import ErrorState from '@/components/ErrorState.vue'
import JoinProjectModal from '@/components/JoinProjectModal.vue'
import { formatDate, formatMonthYear, formatRelative } from '@/utils/formatDate'
const props = defineProps<{ slug: string }>()
const router = useRouter()
const { isAuthenticated } = useAuth()
// État principal
const project = ref<ProjectDetail | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
// Tabs
type Tab = 'roadmap' | 'discussions' | 'resources'
const activeTab = ref<Tab>('roadmap')
// Discussions et resources : lazy-loaded au 1er affichage du tab.
const discussions = ref<Discussion[] | null>(null)
const discussionsLoading = ref(false)
const resources = ref<Resource[] | null>(null)
const resourcesLoading = ref(false)
// Inputs admin (ajouter étape / ressource / message)
const newStepTitle = ref('')
const newResource = ref({ title: '', url: '', kind: 'link' as Resource['kind'] })
const newMessage = ref({ author_name: '', body: '' })
const adminBusy = ref(false)
// Modal "Rejoindre"
const showJoinModal = ref(false)
async function loadProject() {
loading.value = true
error.value = null
try {
project.value = await api.get<ProjectDetail>(`/api/projects/${encodeURIComponent(props.slug)}`)
} catch (e) {
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
} finally {
loading.value = false
}
}
async function loadDiscussions() {
if (discussions.value !== null) return // déjà chargé
discussionsLoading.value = true
try {
const r = await api.get<Wrapped<Discussion>>(`/api/projects/${encodeURIComponent(props.slug)}/discussions`)
discussions.value = r.data
} catch {
discussions.value = []
} finally {
discussionsLoading.value = false
}
}
async function loadResources() {
if (resources.value !== null) return
resourcesLoading.value = true
try {
const r = await api.get<Wrapped<Resource>>(`/api/projects/${encodeURIComponent(props.slug)}/resources`)
resources.value = r.data
} catch {
resources.value = []
} finally {
resourcesLoading.value = false
}
}
// Charge les onglets à la demande
watch(activeTab, (tab) => {
if (tab === 'discussions') loadDiscussions()
if (tab === 'resources') loadResources()
})
onMounted(loadProject)
// --- Actions admin ---
async function toggleStepDone(step: RoadmapStep) {
if (!isAuthenticated.value || !project.value) return
try {
const updated = await api.patch<RoadmapStep>(
`/api/projects/${encodeURIComponent(props.slug)}/roadmap/${step.id}`,
{ is_done: !step.is_done },
)
const idx = project.value.roadmap.findIndex(s => s.id === step.id)
if (idx >= 0) project.value.roadmap[idx] = updated
} catch (e) {
console.error('toggleStepDone failed', e)
}
}
async function addStep() {
const title = newStepTitle.value.trim()
if (!title || !isAuthenticated.value || !project.value) return
adminBusy.value = true
try {
const step = await api.post<RoadmapStep>(
`/api/projects/${encodeURIComponent(props.slug)}/roadmap`,
{ title, is_done: false },
)
project.value.roadmap.push(step)
newStepTitle.value = ''
} finally {
adminBusy.value = false
}
}
async function deleteStep(step: RoadmapStep) {
if (!isAuthenticated.value || !project.value) return
if (!confirm(`Supprimer l'étape « ${step.title} » ?`)) return
try {
await api.delete(`/api/projects/${encodeURIComponent(props.slug)}/roadmap/${step.id}`)
project.value.roadmap = project.value.roadmap.filter(s => s.id !== step.id)
} catch (e) {
console.error('deleteStep failed', e)
}
}
async function postMessage() {
const author = newMessage.value.author_name.trim()
const body = newMessage.value.body.trim()
if (!author || !body || !isAuthenticated.value) return
adminBusy.value = true
try {
const msg = await api.post<Discussion>(
`/api/projects/${encodeURIComponent(props.slug)}/discussions`,
{ author_name: author, body },
)
if (discussions.value) {
// Ajoute en tête (la liste est triée DESC date par l'API).
discussions.value.unshift({ ...msg, replies: [] })
}
newMessage.value = { author_name: '', body: '' }
if (project.value) project.value.counts.discussions += 1
} finally {
adminBusy.value = false
}
}
async function addResource() {
const { title, url, kind } = newResource.value
if (!title.trim() || !url.trim() || !isAuthenticated.value) return
adminBusy.value = true
try {
const res = await api.post<Resource>(
`/api/projects/${encodeURIComponent(props.slug)}/resources`,
{ title: title.trim(), url: url.trim(), kind },
)
if (resources.value) resources.value.push(res)
newResource.value = { title: '', url: '', kind: 'link' }
if (project.value) project.value.counts.resources += 1
} finally {
adminBusy.value = false
}
}
async function deleteProject() {
if (!isAuthenticated.value || !project.value) return
if (!confirm(`Supprimer définitivement le projet « ${project.value.title} » ?\n(L'historique reste consultable via les archives.)`)) return
try {
await api.delete(`/api/projects/${encodeURIComponent(props.slug)}`)
router.push({ name: 'projects' })
} catch (e) {
console.error('deleteProject failed', e)
}
}
function onJoined(payload: { member: { id: number; slug: string; display_name: string }; role: string }) {
// Optimiste : push dans la liste des membres locale.
if (!project.value) return
project.value.members.push({
id: payload.member.id,
slug: payload.member.slug,
display_name: payload.member.display_name,
initials: null,
kind: 'person',
avatar_url: null,
role: payload.role as 'contributor',
joined_at: new Date().toISOString(),
})
project.value.counts.members = project.value.members.length
}
// Présentations dérivées
const categoryColor = computed(() => project.value?.category?.color_hex ?? '#111827')
const externalDomain = computed(() => {
if (!project.value?.external_url) return ''
try {
return new URL(project.value.external_url).hostname.replace(/^www\./, '')
} catch {
return project.value.external_url
}
})
const ROLE_LABEL = { admin: 'Admin', contributor: 'Contributor', observer: 'Observer' } as const
</script>
<template>
<section class="px-10 py-8 max-w-7xl">
<!-- Retour -->
<RouterLink
:to="{ name: 'projects' }"
class="inline-flex items-center gap-1.5 text-sm text-donut-muted hover:text-donut-text"
>
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 19-7-7 7-7M5 12h14"/>
</svg>
Retour
</RouterLink>
<LoadingState v-if="loading" class="mt-8" />
<ErrorState v-else-if="error" :message="error" :retry="loadProject" class="mt-8" />
<div v-else-if="project" class="mt-4 grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- ============== COLONNE GAUCHE ============== -->
<div class="lg:col-span-2 space-y-8">
<!-- Badges + bouton supprimer -->
<div class="flex items-start justify-between gap-4 flex-wrap">
<div class="flex items-center gap-2 flex-wrap">
<StatusBadge :status="project.status" />
<CategoryBadge
v-if="project.category"
:name="project.category.name"
:color-hex="project.category.color_hex"
/>
</div>
<button
v-if="isAuthenticated"
type="button"
class="flex items-center gap-1.5 bg-red-500 text-white rounded-lg px-3 py-1.5 text-sm font-medium hover:bg-red-600"
@click="deleteProject"
>
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
</svg>
Supprimer
</button>
</div>
<!-- Titre + métadonnées -->
<div>
<h1 class="text-4xl font-bold text-donut-text">{{ project.title }}</h1>
<div class="mt-3 flex items-center gap-4 text-sm text-donut-muted flex-wrap">
<span class="inline-flex items-center gap-1.5">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
</svg>
{{ project.territory }}
</span>
<span class="inline-flex items-center gap-1.5">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>
</svg>
Créé en {{ formatMonthYear(project.created_at) }}
</span>
<a
v-if="project.external_url"
:href="project.external_url"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1.5 text-status-active hover:underline"
>
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>
</svg>
Voir sur {{ externalDomain }}
</a>
</div>
</div>
<!-- Contexte -->
<div v-if="project.context_description">
<h2 class="text-xl font-semibold text-donut-text">Contexte &amp; Description</h2>
<p class="mt-2 text-donut-muted leading-relaxed whitespace-pre-line">
{{ project.context_description }}
</p>
</div>
<!-- Objectifs -->
<div v-if="project.objectives">
<h2 class="text-xl font-semibold text-donut-text">Objectifs</h2>
<p class="mt-2 text-donut-muted leading-relaxed whitespace-pre-line">
{{ project.objectives }}
</p>
</div>
<!-- TABS -->
<div class="border-b border-donut-border">
<nav class="flex items-center gap-6 -mb-px" role="tablist">
<button
v-for="tab in (['roadmap', 'discussions', 'resources'] as const)"
:key="tab"
type="button"
role="tab"
:aria-selected="activeTab === tab"
:class="[
'flex items-center gap-2 pb-3 text-sm font-medium border-b-2 transition-colors',
activeTab === tab
? 'border-donut-text text-donut-text'
: 'border-transparent text-donut-muted hover:text-donut-text',
]"
@click="activeTab = tab"
>
<span v-if="tab === 'roadmap'" class="inline-flex items-center gap-2">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
Feuille de route
</span>
<span v-else-if="tab === 'discussions'" class="inline-flex items-center gap-2">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Discussions ({{ project.counts.discussions }})
</span>
<span v-else class="inline-flex items-center gap-2">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16"/>
</svg>
Ressources ({{ project.counts.resources }})
</span>
</button>
</nav>
</div>
<!-- Tab : Feuille de route -->
<div v-if="activeTab === 'roadmap'" class="space-y-3">
<p v-if="project.roadmap.length === 0" class="text-donut-muted text-sm">
Pas encore d'étape. {{ isAuthenticated ? 'Ajoute-en une ci-dessous.' : '' }}
</p>
<ul v-else class="space-y-3">
<li
v-for="step in project.roadmap"
:key="step.id"
class="flex items-start gap-3 group"
>
<button
type="button"
:class="[
'mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0',
step.is_done
? 'bg-green-500 border-green-500 text-white'
: 'border-gray-300 hover:border-green-500',
]"
:disabled="!isAuthenticated"
:title="isAuthenticated ? (step.is_done ? 'Marquer comme à faire' : 'Marquer comme fait') : 'Admin requis'"
@click="toggleStepDone(step)"
>
<svg v-if="step.is_done" viewBox="0 0 24 24" class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12l5 5L20 7"/>
</svg>
</button>
<div class="flex-1">
<div
:class="[
'text-sm font-medium',
step.is_done ? 'line-through text-donut-muted' : 'text-donut-text',
]"
>
{{ step.title }}
</div>
<div v-if="step.step_date" class="text-xs text-donut-muted mt-0.5">
{{ formatDate(step.step_date) }}
</div>
<div v-if="step.description" class="text-xs text-donut-muted mt-0.5 whitespace-pre-line">
{{ step.description }}
</div>
</div>
<button
v-if="isAuthenticated"
type="button"
class="opacity-0 group-hover:opacity-100 text-donut-muted hover:text-red-500"
title="Supprimer"
@click="deleteStep(step)"
>
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
</svg>
</button>
</li>
</ul>
<form
v-if="isAuthenticated"
class="flex items-center gap-2 pt-2"
@submit.prevent="addStep"
>
<input
v-model="newStepTitle"
type="text"
placeholder="Nouvelle étape…"
class="flex-1 bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
/>
<button
type="submit"
:disabled="adminBusy || !newStepTitle.trim()"
class="px-4 py-2 rounded-lg bg-gray-100 text-donut-text text-sm font-medium hover:bg-gray-200 disabled:opacity-50"
>
Ajouter
</button>
</form>
</div>
<!-- Tab : Discussions -->
<div v-else-if="activeTab === 'discussions'" class="space-y-4">
<LoadingState v-if="discussionsLoading" />
<template v-else>
<p v-if="(discussions ?? []).length === 0" class="text-donut-muted text-sm">
Aucun message pour le moment.
</p>
<ul v-else class="space-y-4">
<li
v-for="d in discussions ?? []"
:key="d.id"
class="rounded-lg border border-donut-border bg-donut-surface p-4"
>
<div class="flex items-center gap-2 mb-1.5">
<Avatar :name="d.author_name" :size="28" />
<div class="text-sm font-medium text-donut-text">{{ d.author_name }}</div>
<div class="text-xs text-donut-muted">· {{ formatRelative(d.created_at) }}</div>
</div>
<p class="text-sm text-donut-text whitespace-pre-line leading-relaxed">{{ d.body }}</p>
<ul v-if="d.replies && d.replies.length" class="mt-3 ml-6 pl-4 border-l border-donut-border space-y-3">
<li v-for="r in d.replies" :key="r.id">
<div class="flex items-center gap-2 mb-0.5">
<Avatar :name="r.author_name" :size="22" />
<div class="text-xs font-medium text-donut-text">{{ r.author_name }}</div>
<div class="text-xs text-donut-muted">· {{ formatRelative(r.created_at) }}</div>
</div>
<p class="text-sm text-donut-muted whitespace-pre-line">{{ r.body }}</p>
</li>
</ul>
</li>
</ul>
<form
v-if="isAuthenticated"
class="space-y-2 pt-4 border-t border-donut-border"
@submit.prevent="postMessage"
>
<div class="text-sm font-medium text-donut-text">Nouveau message</div>
<input
v-model="newMessage.author_name"
type="text"
placeholder="Ton nom"
class="w-full bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
/>
<textarea
v-model="newMessage.body"
rows="3"
placeholder="Ton message…"
class="w-full bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text resize-y"
/>
<div class="flex justify-end">
<button
type="submit"
:disabled="adminBusy || !newMessage.author_name.trim() || !newMessage.body.trim()"
class="px-4 py-2 rounded-lg bg-donut-text text-white text-sm font-medium hover:bg-black disabled:opacity-50"
>
Publier
</button>
</div>
</form>
<p v-else class="text-xs text-donut-muted">Connecte-toi en admin pour publier un message.</p>
</template>
</div>
<!-- Tab : Ressources -->
<div v-else class="space-y-3">
<LoadingState v-if="resourcesLoading" />
<template v-else>
<p v-if="(resources ?? []).length === 0" class="text-donut-muted text-sm">
Aucune ressource pour le moment.
</p>
<ul v-else class="space-y-2">
<li
v-for="r in resources ?? []"
:key="r.id"
class="flex items-start gap-3 rounded-lg border border-donut-border bg-donut-surface p-3"
>
<div class="w-8 h-8 rounded-md bg-gray-100 flex items-center justify-center shrink-0 text-donut-muted">
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<a
:href="r.url"
target="_blank"
rel="noopener"
class="text-sm font-medium text-donut-text hover:underline"
>
{{ r.title }}
</a>
<div class="text-xs text-donut-muted truncate">{{ r.url }}</div>
<div v-if="r.description" class="text-xs text-donut-muted mt-1">{{ r.description }}</div>
</div>
<span class="text-[10px] uppercase tracking-wide text-donut-muted">{{ r.kind }}</span>
</li>
</ul>
<form
v-if="isAuthenticated"
class="space-y-2 pt-4 border-t border-donut-border"
@submit.prevent="addResource"
>
<div class="text-sm font-medium text-donut-text">Nouvelle ressource</div>
<input
v-model="newResource.title"
type="text"
placeholder="Titre"
class="w-full bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
/>
<div class="flex gap-2">
<input
v-model="newResource.url"
type="url"
placeholder="https://…"
class="flex-1 bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
/>
<select
v-model="newResource.kind"
class="bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
>
<option value="link">Lien</option>
<option value="document">Document</option>
<option value="dataset">Dataset</option>
<option value="video">Vidéo</option>
<option value="other">Autre</option>
</select>
</div>
<div class="flex justify-end">
<button
type="submit"
:disabled="adminBusy || !newResource.title.trim() || !newResource.url.trim()"
class="px-4 py-2 rounded-lg bg-donut-text text-white text-sm font-medium hover:bg-black disabled:opacity-50"
>
Ajouter
</button>
</div>
</form>
<p v-else class="text-xs text-donut-muted">Connecte-toi en admin pour ajouter une ressource.</p>
</template>
</div>
</div>
<!-- ============== COLONNE DROITE ============== -->
<aside class="space-y-4">
<!-- Card Progression + Rejoindre -->
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
<div class="text-sm font-medium text-donut-text mb-1">Progression</div>
<div class="flex items-center gap-3 mb-4">
<ProgressBar :value="project.progress_percent" :color="categoryColor" class="flex-1" />
<span class="text-sm font-semibold tabular-nums">{{ project.progress_percent }}%</span>
</div>
<button
type="button"
class="w-full bg-donut-text text-white rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-black transition-colors"
@click="showJoinModal = true"
>
Rejoindre le projet
</button>
</div>
<!-- Card Équipe -->
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
<div class="flex items-center gap-2 mb-4">
<svg viewBox="0 0 24 24" class="w-4 h-4 text-donut-muted" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span class="text-sm font-medium text-donut-text">Équipe ({{ project.members.length }})</span>
</div>
<p v-if="project.members.length === 0" class="text-sm text-donut-muted">
Pas encore de membre. Sois le premier !
</p>
<ul v-else class="space-y-3">
<li
v-for="m in project.members"
:key="m.id"
class="flex items-center gap-3"
>
<Avatar :name="m.display_name" :initials="m.initials" :avatar-url="m.avatar_url" :size="34" />
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-donut-text truncate">{{ m.display_name }}</div>
<div class="text-xs text-donut-muted">{{ ROLE_LABEL[m.role] }}</div>
</div>
</li>
</ul>
</div>
</aside>
</div>
<!-- Modal Rejoindre -->
<JoinProjectModal
v-if="showJoinModal && project"
:project-slug="project.slug"
@close="showJoinModal = false"
@joined="onJoined"
/>
</section>
</template>
+224
View File
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { api, ApiError } from '@/api/client'
import { useAuth } from '@/composables/useAuth'
import type { Category, ProjectDetail, Wrapped } from '@/types/api'
import LoadingState from '@/components/LoadingState.vue'
const router = useRouter()
const { isAuthenticated } = useAuth()
const categories = ref<Category[]>([])
const loadingCategories = ref(true)
const form = ref({
title: '',
category_id: '' as string | number,
territory: 'Marseille',
short_description: '',
context_description: '',
objectives: '',
cover_image_url: '',
})
const submitting = ref(false)
const fieldErrors = ref<Record<string, string>>({})
const generalError = ref<string | null>(null)
onMounted(async () => {
try {
const r = await api.get<Wrapped<Category>>('/api/categories')
categories.value = r.data
} finally {
loadingCategories.value = false
}
})
const canSubmit = computed(() => isAuthenticated.value && form.value.title.trim() && form.value.category_id)
async function submit() {
fieldErrors.value = {}
generalError.value = null
if (!isAuthenticated.value) {
generalError.value = "Tu dois être connecté en admin pour créer un projet."
return
}
submitting.value = true
try {
const body: Record<string, unknown> = {
title: form.value.title.trim(),
category_id: Number(form.value.category_id),
}
if (form.value.territory.trim()) body.territory = form.value.territory.trim()
if (form.value.short_description.trim()) body.short_description = form.value.short_description.trim()
if (form.value.context_description.trim()) body.context_description = form.value.context_description.trim()
if (form.value.objectives.trim()) body.objectives = form.value.objectives.trim()
if (form.value.cover_image_url.trim()) body.cover_image_url = form.value.cover_image_url.trim()
const created = await api.post<ProjectDetail>('/api/projects', body)
router.push({ name: 'project-detail', params: { slug: created.slug } })
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 422) {
fieldErrors.value = e.fields
} else if (e.status === 401) {
generalError.value = 'Token admin invalide ou expiré.'
} else {
generalError.value = e.payload.message ?? e.code
}
} else {
generalError.value = 'Erreur réseau'
}
} finally {
submitting.value = false
}
}
</script>
<template>
<section class="px-10 py-8 max-w-3xl">
<RouterLink
:to="{ name: 'projects' }"
class="inline-flex items-center gap-1.5 text-sm text-donut-muted hover:text-donut-text"
>
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m12 19-7-7 7-7M5 12h14"/>
</svg>
Retour aux projets
</RouterLink>
<header class="mt-4">
<h1 class="text-3xl font-semibold text-donut-text">Nouveau Projet</h1>
<p class="text-donut-muted mt-1">Proposez une nouvelle initiative citoyenne pour la communauté.</p>
</header>
<!-- Avertissement si pas connecté admin -->
<div
v-if="!isAuthenticated"
class="mt-6 rounded-lg border border-amber-200 bg-amber-50 text-amber-800 px-4 py-3 text-sm"
>
La création de projets est réservée à l'admin en V1.
<RouterLink :to="{ name: 'admin-login' }" class="underline font-medium">Se connecter</RouterLink>
pour publier.
</div>
<form
@submit.prevent="submit"
class="mt-6 rounded-xl border border-donut-border bg-donut-surface p-6 space-y-5"
>
<header class="border-b border-donut-border pb-4">
<h2 class="text-base font-semibold text-donut-text">Détails du projet</h2>
<p class="text-sm text-donut-muted mt-0.5">Décrivez clairement votre projet pour encourager la participation.</p>
</header>
<!-- Titre -->
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Titre du projet *</label>
<input
v-model="form.title"
type="text"
placeholder="Ex: Cartographie des îlots de chaleur"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
:class="fieldErrors.title ? 'border-red-400' : ''"
/>
<p v-if="fieldErrors.title" class="text-xs text-red-600 mt-1">Titre requis.</p>
</div>
<!-- Catégorie + Territoire -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Catégorie thématique *</label>
<LoadingState v-if="loadingCategories" />
<select
v-else
v-model="form.category_id"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
:class="fieldErrors.category_id ? 'border-red-400' : ''"
>
<option value="" disabled>Sélectionner une catégorie</option>
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<p v-if="fieldErrors.category_id" class="text-xs text-red-600 mt-1">
{{ fieldErrors.category_id === 'not_found' ? 'Catégorie introuvable.' : 'Catégorie requise.' }}
</p>
</div>
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Territoire / Quartier</label>
<input
v-model="form.territory"
type="text"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
/>
</div>
</div>
<!-- Description courte -->
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Description courte</label>
<textarea
v-model="form.short_description"
rows="2"
placeholder="Un résumé en une ou deux phrases (utilisé sur les cartes)."
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text resize-y"
/>
</div>
<!-- Contexte -->
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Description complète</label>
<textarea
v-model="form.context_description"
rows="5"
placeholder="Présentez le contexte et les enjeux…"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text resize-y"
/>
</div>
<!-- Objectifs -->
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Objectifs et impacts attendus</label>
<textarea
v-model="form.objectives"
rows="3"
placeholder="Quels sont les résultats concrets visés ?"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text resize-y"
/>
</div>
<!-- Image cover -->
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Image de couverture (URL)</label>
<input
v-model="form.cover_image_url"
type="url"
placeholder="https://…"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
:class="fieldErrors.cover_image_url ? 'border-red-400' : ''"
/>
<p class="text-xs text-donut-muted mt-1">Optionnel — une image d'illustration pour votre projet.</p>
</div>
<!-- Erreur générale -->
<div v-if="generalError" class="rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
{{ generalError }}
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-2 pt-2 border-t border-donut-border">
<RouterLink
:to="{ name: 'projects' }"
class="px-4 py-2 rounded-lg text-sm font-medium border border-donut-border hover:bg-gray-50"
>
Annuler
</RouterLink>
<button
type="submit"
:disabled="!canSubmit || submitting"
class="px-4 py-2 rounded-lg bg-donut-text text-white text-sm font-medium hover:bg-black disabled:opacity-50"
>
{{ submitting ? 'Publication…' : 'Publier le projet' }}
</button>
</div>
</form>
</section>
</template>
+156
View File
@@ -0,0 +1,156 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import { api, ApiError } from '@/api/client'
import type { Category, Paginated, ProjectListRow, ProjectStatus, Wrapped } from '@/types/api'
import ProjectCard from '@/components/ProjectCard.vue'
import LoadingState from '@/components/LoadingState.vue'
import ErrorState from '@/components/ErrorState.vue'
const route = useRoute()
const router = useRouter()
// État
const categories = ref<Category[]>([])
const projects = ref<ProjectListRow[]>([])
const totalProjects = ref(0)
const loading = ref(true)
const error = ref<string | null>(null)
// Filtres lus depuis la query string -> source de vérité = l'URL.
const filterCategory = computed(() => (route.query.category as string) || '')
const filterStatus = computed(() => (route.query.status as string) || '')
const filterQ = computed(() => (route.query.q as string) || '')
const searchInput = ref(filterQ.value)
function updateQuery(patch: Record<string, string | null>) {
const next: Record<string, string> = {}
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
if (v && typeof v === 'string') next[k] = v
}
router.replace({ name: 'projects', query: next })
}
async function loadCategories() {
try {
const r = await api.get<Wrapped<Category>>('/api/categories')
categories.value = r.data
} catch {
/* silencieux : on garde les filtres sans label de catégorie. */
}
}
async function loadProjects() {
loading.value = true
error.value = null
try {
const r = await api.get<Paginated<ProjectListRow>>('/api/projects', {
query: {
category: filterCategory.value || undefined,
status: filterStatus.value || undefined,
q: filterQ.value || undefined,
per_page: 50,
},
})
projects.value = r.data
totalProjects.value = r.meta.total
} catch (e) {
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
} finally {
loading.value = false
}
}
onMounted(async () => {
await Promise.all([loadCategories(), loadProjects()])
})
// Re-fetch quand un filtre change dans l'URL (back/forward, RouterLink externe).
watch(() => [filterCategory.value, filterStatus.value, filterQ.value], loadProjects)
// Recherche : on debounce 250ms.
let searchTimer: number | undefined
function onSearchInput() {
window.clearTimeout(searchTimer)
searchTimer = window.setTimeout(() => {
updateQuery({ q: searchInput.value || null })
}, 250)
}
const STATUSES: { value: ProjectStatus | ''; label: string }[] = [
{ value: '', label: 'Tous les statuts' },
{ value: 'active', label: 'Actifs' },
{ value: 'completed', label: 'Réalisés' },
{ value: 'archived', label: 'Archivés' },
]
</script>
<template>
<section class="px-10 py-8 max-w-7xl">
<header class="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 class="text-3xl font-semibold text-donut-text">Projets Actifs</h1>
<p class="text-donut-muted mt-1">Découvrez et rejoignez les initiatives de la communauté.</p>
</div>
<RouterLink
:to="{ name: 'project-new' }"
class="flex items-center gap-1.5 bg-donut-text text-white rounded-lg px-3.5 py-2 text-sm font-medium hover:bg-black transition-colors"
>
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
Nouveau Projet
</RouterLink>
</header>
<!-- Filtres -->
<div class="mt-6 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 bg-donut-surface border border-donut-border rounded-lg px-3 py-2 flex-1 min-w-[240px] max-w-md">
<svg viewBox="0 0 24 24" class="w-4 h-4 text-donut-muted" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
</svg>
<input
v-model="searchInput"
@input="onSearchInput"
type="search"
placeholder="Rechercher un projet…"
class="flex-1 bg-transparent text-sm outline-none placeholder:text-donut-muted"
/>
</div>
<select
:value="filterCategory"
@change="updateQuery({ category: ($event.target as HTMLSelectElement).value || null })"
class="bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
>
<option value="">Toutes les catégories</option>
<option v-for="c in categories" :key="c.id" :value="c.slug">{{ c.name }}</option>
</select>
<select
:value="filterStatus"
@change="updateQuery({ status: ($event.target as HTMLSelectElement).value || null })"
class="bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
>
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
</select>
</div>
<!-- Liste -->
<div class="mt-6">
<LoadingState v-if="loading" />
<ErrorState v-else-if="error" :message="error" :retry="loadProjects" />
<div v-else-if="projects.length === 0" class="text-donut-muted text-center py-16">
Aucun projet ne correspond aux critères.
</div>
<div v-else>
<div class="text-xs text-donut-muted mb-4">
{{ totalProjects }} projet{{ totalProjects > 1 ? 's' : '' }} trouvé{{ totalProjects > 1 ? 's' : '' }}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<ProjectCard v-for="p in projects" :key="p.id" :project="p" />
</div>
</div>
</div>
</section>
</template>
+35
View File
@@ -0,0 +1,35 @@
/**
* Routes de l'app. AppLayout englobe toutes les pages "public" (sidebar visible).
* /admin/login a son propre layout vide (pas de sidebar tant qu'on n'est pas loggé).
*/
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import AppLayout from '@/layouts/AppLayout.vue'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: AppLayout,
children: [
{ path: '', name: 'home', component: () => import('@/pages/HomePage.vue') },
{ path: 'dashboard', name: 'dashboard', component: () => import('@/pages/DashboardPage.vue') },
{ path: 'donut', name: 'donut', component: () => import('@/pages/DonutPage.vue') },
{ path: 'projects', name: 'projects', component: () => import('@/pages/ProjectsPage.vue') },
{ path: 'projects/new', name: 'project-new', component: () => import('@/pages/ProjectNewPage.vue') },
{ path: 'projects/:slug', name: 'project-detail', component: () => import('@/pages/ProjectDetailPage.vue'), props: true },
{ path: 'archives', name: 'archives', component: () => import('@/pages/ArchivesPage.vue') },
{ path: 'categories', name: 'categories', component: () => import('@/pages/CategoriesPage.vue') },
{ path: 'admin/login', name: 'admin-login', component: () => import('@/pages/AdminLoginPage.vue'), meta: { hideForAuthed: true } },
{ path: ':pathMatch(.*)*', name: 'not-found', component: () => import('@/pages/NotFoundPage.vue') },
],
},
]
export const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior() {
// Scroll en haut à chaque navigation (UX par défaut pour une SPA de contenu).
return { top: 0 }
},
})
+66
View File
@@ -0,0 +1,66 @@
@import "tailwindcss";
/*
* Le Donut Infolab — design tokens.
*
* 6 couleurs des catégories (alignées avec l'API color_hex) +
* 2 couleurs de dimension Donut (plancher social, plafond écologique).
*
* Utilisation Tailwind v4 : `bg-donut-data`, `text-donut-ecology`, `border-donut-democracy`...
* Pour les couleurs dynamiques venant de l'API (e.g. catégorie ajoutée à chaud),
* on utilise des styles inline avec category.color_hex.
*/
@theme {
/* Palette Donut — fond crème, texte presque noir, accents catégorie. */
--color-donut-bg: #FAFAF6;
--color-donut-surface: #FFFFFF;
--color-donut-text: #111827;
--color-donut-muted: #6B7280;
--color-donut-border: #E5E7EB;
/* 6 catégories thématiques — couleurs miroirs de migrations/002_seed_categories.sql */
--color-donut-data: #3B82F6; /* Data & Open Data (transversal) */
--color-donut-democracy: #EF4444; /* Démocratie & Citoyenneté (social) */
--color-donut-habitat: #14B8A6; /* Habitat & Solidarité (social) */
--color-donut-territory: #F97316; /* Territoire & Urbanisme (transversal) */
--color-donut-ecology: #22C55E; /* Écologie & Biodiversité (ecologique) */
--color-donut-education: #A855F7; /* Éducation & Écoles (social) */
/* Statuts projets — badges */
--color-status-active: #F97316; /* "Actif" orange */
--color-status-completed: #A855F7; /* "Réalisé" violet */
--color-status-archived: #6B7280; /* "Archivé" gris */
/* Dimension Donut — plancher / plafond */
--color-floor-social: #F97316;
--color-ceiling-eco: #22C55E;
}
html, body, #app {
height: 100%;
}
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--color-donut-bg);
color: var(--color-donut-text);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar discrète sur Webkit. */
*::-webkit-scrollbar {
width: 10px;
height: 10px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: #d4d4d8;
border-radius: 5px;
}
*::-webkit-scrollbar-thumb:hover {
background: #a1a1aa;
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Types TypeScript reflétant les réponses de l'API PHP (cf. src/Controllers/*).
*
* Convention : les ressources retournent l'objet directement ; les listes
* retournent { data, meta? }. Les erreurs sont { error, message?, fields? }.
*/
export type Dimension = 'social' | 'ecologique' | 'transversal'
export type ProjectStatus = 'active' | 'completed' | 'archived'
export type MemberKind = 'person' | 'collective' | 'org'
export type MemberRole = 'admin' | 'contributor' | 'observer'
export type ResourceKind = 'link' | 'document' | 'dataset' | 'video' | 'other'
export interface Category {
id: number
slug: string
name: string
description: string | null
color_hex: string
icon: string | null
dimension: Dimension
sort_order: number
created_at: string
updated_at: string
/** Présent uniquement sur GET /api/categories/:slug */
projects_count?: number
}
export interface Member {
id: number
slug: string
display_name: string
initials: string | null
kind: MemberKind
bio: string | null
avatar_url: string | null
website_url: string | null
contact_email: string | null
created_at: string
updated_at: string
/** Présent uniquement sur GET /api/members/:slug */
projects?: Array<MemberProjectRef>
}
export interface MemberProjectRef {
id: number
slug: string
title: string
status: ProjectStatus
progress_percent: number
role: MemberRole
joined_at: string
category: { slug: string; name: string; color_hex: string }
}
export interface ProjectListRow {
id: number
slug: string
title: string
status: ProjectStatus
territory: string
short_description: string | null
cover_image_url: string | null
external_url: string | null
progress_percent: number
started_at: string | null
completed_at: string | null
created_at: string
updated_at: string
category: {
id: number
slug: string
name: string
color_hex: string
dimension: Dimension
}
}
export interface ProjectDetail {
id: number
slug: string
title: string
status: ProjectStatus
territory: string
short_description: string | null
context_description: string | null
objectives: string | null
cover_image_url: string | null
external_url: string | null
progress_percent: number
started_at: string | null
completed_at: string | null
created_at: string
updated_at: string
category: {
id: number
slug: string
name: string
color_hex: string
icon: string | null
dimension: Dimension
} | null
members: Array<ProjectMemberEntry>
roadmap: Array<RoadmapStep>
counts: {
discussions: number
resources: number
members: number
}
}
export interface ProjectMemberEntry {
id: number
slug: string
display_name: string
initials: string | null
kind: MemberKind
avatar_url: string | null
role: MemberRole
joined_at: string
}
export interface RoadmapStep {
id: number
project_id: number
title: string
description: string | null
step_date: string | null
is_done: boolean
sort_order: number
created_at: string
updated_at: string
}
export interface Discussion {
id: number
project_id: number
parent_id: number | null
author_name: string
author_member_id: number | null
body: string
created_at: string
updated_at: string
/** Présent uniquement sur les messages racines (GET .../discussions). */
replies?: Discussion[]
}
export interface Resource {
id: number
project_id: number
title: string
description: string | null
url: string
kind: ResourceKind
sort_order: number
created_at: string
updated_at: string
}
export type ActivityVerb =
| 'joined'
| 'left'
| 'created_project'
| 'updated_project'
| 'completed_step'
| 'added_step'
| 'posted_discussion'
| 'added_resource'
| 'completed_project'
export interface ActivityEvent {
id: number
project_id: number | null
member_id: number | null
actor_label: string
verb: ActivityVerb
summary: string
occurred_at: string
}
export interface Stats {
projects_active: number
projects_completed: number
projects_archived: number
projects_total: number
participants_total: number
by_category: Array<{ slug: string; name: string; color_hex: string; count: number }>
by_dimension: Record<Dimension, number>
}
/* ------- Réponses enveloppées ------- */
export interface Paginated<T> {
data: T[]
meta: { page: number; per_page: number; total: number; total_pages: number }
}
export interface Wrapped<T> {
data: T[]
}
/* ------- Erreurs ------- */
export type ErrorCode =
| 'bad_request'
| 'unauthorized'
| 'not_found'
| 'method_not_allowed'
| 'conflict'
| 'validation_failed'
| 'internal_server_error'
export interface ApiErrorPayload {
error: ErrorCode
message?: string
fields?: Record<string, string>
allowed?: string[]
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Helpers de formatage de date pour les chaînes renvoyées par l'API PHP.
*
* L'API renvoie soit :
* - DATETIME MySQL : "2026-05-28 17:39:58" (UTC implicite, pas de Z)
* - ISO 8601 : "2026-05-28T17:39:58Z" (ex: /api/health)
* - DATE seule : "2026-05-28"
*
* On normalise vers un format Date JS valide en supposant UTC quand pas de zone.
*/
function toDate(input: string): Date | null {
// DATE seule (YYYY-MM-DD) : on évite l'interprétation UTC qui décalerait
// au jour précédent dans les fuseaux ouest. Construit en local 12:00 pour stabilité.
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) {
const [y, m, d] = input.split('-').map(Number)
return new Date(y, m - 1, d, 12, 0, 0)
}
// Sinon traite la chaîne comme datetime UTC.
const normalized = input.replace(' ', 'T').replace(/\.\d+/, '')
const withZone = /[Zz]$|[+-]\d{2}:?\d{2}$/.test(normalized) ? normalized : normalized + 'Z'
const d = new Date(withZone)
return isNaN(d.getTime()) ? null : d
}
/** "28 mai 2026" — pas d'heure. */
export function formatDate(input: string | null | undefined): string {
if (!input) return ''
const d = toDate(input)
if (!d) return input
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })
}
/** "28 mai 2026, 19:45" — avec heure (locale utilisateur). */
export function formatDateTime(input: string | null | undefined): string {
if (!input) return ''
const d = toDate(input)
if (!d) return input
return d.toLocaleString('fr-FR', {
day: 'numeric', month: 'short', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
/** "il y a 5 min" / "il y a 2 h" / "il y a 3 j" / date courte au-delà. */
export function formatRelative(input: string | null | undefined): string {
if (!input) return ''
const d = toDate(input)
if (!d) return input
const diffSec = (Date.now() - d.getTime()) / 1000
if (diffSec < 60) return "à l'instant"
if (diffSec < 3600) return `il y a ${Math.floor(diffSec / 60)} min`
if (diffSec < 86400) return `il y a ${Math.floor(diffSec / 3600)} h`
if (diffSec < 604800) return `il y a ${Math.floor(diffSec / 86400)} j`
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })
}
/** "septembre 2020" — mois + année (pour "Créé en…"). */
export function formatMonthYear(input: string | null | undefined): string {
if (!input) return ''
const d = toDate(input)
if (!d) return input
return d.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' })
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
/* Path alias miroir de vite.config.ts (resolve.alias).
En TS 6, `baseUrl` est déprécié : les paths sont relatifs au tsconfig. */
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
// En dev, le SPA tourne sur 5173 et l'API PHP sur Apache Laragon (donut-gestprojet.test).
// Vite proxy /api/* -> http://donut-gestprojet.test/api/* pour éviter le CORS.
proxy: {
'/api': {
target: 'http://donut-gestprojet.test',
changeOrigin: true,
},
},
},
})