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>
@@ -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)
|
||||
- BDD : conteneur existant `donut-mariadb`, réseau Docker `donut-net`
|
||||
- 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`
|
||||
> **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) :
|
||||
|
||||
```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 \
|
||||
--exclude '.git' --exclude '.idea' --exclude '.env' --exclude 'tests' \
|
||||
--exclude 'docker-compose.yml' --exclude 'Dockerfile' --exclude 'caddy.conf' \
|
||||
--exclude 'migrations' --exclude 'DEPLOY.md' --exclude 'INSTRUCTIONS.md' \
|
||||
--exclude 'frontend' --exclude 'docs' \
|
||||
./ arnaud.donut@82.66.40.209:/tmp/infolab-app/
|
||||
|
||||
# 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`
|
||||
|
||||
```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`.
|
||||
Vérifier qu'il est bien `import`-é par `/etc/caddy/Caddyfile` :
|
||||
Le `caddy.conf` fait deux choses :
|
||||
- `/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
|
||||
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
|
||||
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
|
||||
@@ -186,10 +231,15 @@ Si tout est vert : `OK : 11 / FAIL : 0`.
|
||||
## 7. Cycle de mise à jour (pour plus tard)
|
||||
|
||||
```bash
|
||||
# Code app
|
||||
# Backend PHP
|
||||
rsync -avz --delete --exclude '.git' --exclude '.env' \
|
||||
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 :
|
||||
rsync -avz docker-compose.yml Dockerfile migrations/ \
|
||||
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
|
||||
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 caddy:caddy /srv/donut/web/infolab-front
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -210,5 +261,7 @@ EOF
|
||||
- [ ] Certificat Let's Encrypt négocié OK (`caddy.log` côté hôte)
|
||||
- [ ] `ADMIN_TOKEN` archivé dans le gestionnaire de mots de passe partagé
|
||||
- [ ] `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)
|
||||
- [ ] Pas de fuite : `curl -I https://infolab.ledonut-marseille.org/.env` → 403/404
|
||||
|
||||
@@ -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é
|
||||
|
||||
1. Écrire la migration SQL dans `migrations/00X_xxx.sql` (CREATE TABLE).
|
||||
|
||||
@@ -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 {
|
||||
encode gzip zstd
|
||||
|
||||
@@ -12,6 +15,9 @@ infolab.ledonut-marseille.org {
|
||||
-Server
|
||||
}
|
||||
|
||||
# API JSON -> conteneur PHP (sur réseau Docker local, exposé en 127.0.0.1:8003)
|
||||
@api path /api/*
|
||||
handle @api {
|
||||
reverse_proxy 127.0.0.1:8003 {
|
||||
header_up Host {host}
|
||||
header_up X-Real-IP {remote_host}
|
||||
@@ -19,3 +25,18 @@ infolab.ledonut-marseille.org {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 247 KiB |
@@ -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?
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[]
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||