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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 20:40:39 +02:00
parent 3a32e93ab1
commit 7fb22bcb29
49 changed files with 4754 additions and 13 deletions
+318
View File
@@ -0,0 +1,318 @@
<script setup lang="ts">
/**
* Visualisation Donut — SVG custom.
*
* Modèle Donut Kate Raworth adapté :
* - Anneau extérieur (vert pâle) = "Plafond écologique" qu'on ne doit pas dépasser
* - Anneau intérieur (orange pâle) = "Plancher social" qu'on ne doit pas franchir vers le bas
* - Zone centrale entre les deux = "espace sûr" où vivent les projets citoyens
*
* 6 secteurs colorés (60° chacun) = les 6 catégories thématiques.
* Chaque projet = 1 point coloré (par catégorie) placé dans son secteur,
* à un rayon qui varie légèrement (hash stable de l'id) pour éviter les chevauchements.
* Survol → tooltip avec titre du projet et lien vers sa fiche.
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { api, ApiError } from '@/api/client'
import type { Category, Dimension, Paginated, ProjectListRow, Wrapped } from '@/types/api'
import LoadingState from '@/components/LoadingState.vue'
import ErrorState from '@/components/ErrorState.vue'
const router = useRouter()
const categories = ref<Category[]>([])
const projects = ref<ProjectListRow[]>([])
const loading = ref(true)
const error = ref<string | null>(null)
const hoveredProject = ref<ProjectListRow | null>(null)
async function load() {
loading.value = true
error.value = null
try {
const [cats, projs] = await Promise.all([
api.get<Wrapped<Category>>('/api/categories'),
api.get<Paginated<ProjectListRow>>('/api/projects', { query: { per_page: 100 } }),
])
categories.value = [...cats.data].sort((a, b) => a.sort_order - b.sort_order)
projects.value = projs.data
} catch (e) {
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
} finally {
loading.value = false
}
}
onMounted(load)
// --- Géométrie (rayons en unités du viewBox -250..250) ---
const R_PROJECT_OUTER = 200
const R_PROJECT_INNER = 110
const R_FLOOR_OUTER = 95
const R_FLOOR_INNER = 70
const R_CENTER = 60
/** deg→rad avec offset -90° pour que 0° soit en haut (12 h). */
function rad(deg: number): number {
return (deg - 90) * Math.PI / 180
}
function pt(angleDeg: number, r: number): [number, number] {
const a = rad(angleDeg)
return [Math.cos(a) * r, Math.sin(a) * r]
}
function wedgePath(startDeg: number, endDeg: number, rInner: number, rOuter: number): string {
const [x1, y1] = pt(startDeg, rOuter)
const [x2, y2] = pt(endDeg, rOuter)
const [x3, y3] = pt(endDeg, rInner)
const [x4, y4] = pt(startDeg, rInner)
const largeArc = endDeg - startDeg > 180 ? 1 : 0
return [
`M ${x1.toFixed(2)} ${y1.toFixed(2)}`,
`A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`,
`L ${x3.toFixed(2)} ${y3.toFixed(2)}`,
`A ${rInner} ${rInner} 0 ${largeArc} 0 ${x4.toFixed(2)} ${y4.toFixed(2)}`,
'Z',
].join(' ')
}
interface WedgeView {
cat: Category
startDeg: number
endDeg: number
centerDeg: number
path: string
labelPos: [number, number]
}
const wedges = computed<WedgeView[]>(() => {
const n = categories.value.length || 6
const step = 360 / n
return categories.value.map((cat, i) => {
const startDeg = i * step
const endDeg = (i + 1) * step
const centerDeg = (startDeg + endDeg) / 2
return {
cat,
startDeg,
endDeg,
centerDeg,
path: wedgePath(startDeg, endDeg, R_PROJECT_INNER, R_PROJECT_OUTER),
labelPos: pt(centerDeg, (R_PROJECT_INNER + R_PROJECT_OUTER) / 2 + 25),
}
})
})
interface DotView {
project: ProjectListRow
x: number
y: number
r: number
color: string
}
/** Hash 32-bit basique sur une chaîne (stabilité de positionnement entre rendus). */
function strHash(s: string): number {
let h = 0
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
return Math.abs(h)
}
const dots = computed<DotView[]>(() => {
const byCat = new Map<number, ProjectListRow[]>()
for (const p of projects.value) {
const arr = byCat.get(p.category.id) ?? []
arr.push(p)
byCat.set(p.category.id, arr)
}
const out: DotView[] = []
for (const w of wedges.value) {
const ps = byCat.get(w.cat.id) ?? []
const span = w.endDeg - w.startDeg
ps.forEach((p, idx) => {
// Répartit angulairement les projets sur la largeur du secteur (avec marge),
// et ajoute un petit jitter de rayon pour ne pas tous les aligner.
const t = (idx + 1) / (ps.length + 1)
const angleDeg = w.startDeg + span * t
const jitter = ((strHash(p.slug) % 100) / 100 - 0.5) * (R_PROJECT_OUTER - R_PROJECT_INNER - 30)
const r = (R_PROJECT_INNER + R_PROJECT_OUTER) / 2 + jitter
const [x, y] = pt(angleDeg, r)
// Diamètre du point en fonction de la progression (un peu plus gros si avancé).
const radius = 4 + (p.progress_percent / 100) * 4
out.push({ project: p, x, y, r: radius, color: p.category.color_hex })
})
}
return out
})
// Légende groupée par dimension
const DIMENSION_ORDER: Dimension[] = ['social', 'ecologique', 'transversal']
const DIMENSION_META: Record<Dimension, { label: string; color: string }> = {
social: { label: 'Plancher social', color: '#F97316' },
ecologique: { label: 'Plafond écologique', color: '#22C55E' },
transversal: { label: 'Transversal', color: '#6B7280' },
}
const categoriesByDimension = computed(() => {
const map: Record<Dimension, Category[]> = { social: [], ecologique: [], transversal: [] }
for (const c of categories.value) map[c.dimension].push(c)
return map
})
function gotoProject(p: ProjectListRow) {
router.push({ name: 'project-detail', params: { slug: p.slug } })
}
</script>
<template>
<section class="px-10 py-8 max-w-7xl">
<header class="text-center max-w-3xl mx-auto">
<h1 class="text-3xl font-semibold text-donut-text">Le Donut des Projets Citoyens</h1>
<p class="text-donut-muted mt-2">
Inspiré du modèle de l'économie du Donut de Kate Raworth. L'Infolab des projets citoyens
explore les initiatives qui poussent une vie marseillaise dans l'espace sûr : entre
plancher social et plafond écologique.
</p>
</header>
<LoadingState v-if="loading" class="mt-12 justify-center" />
<ErrorState v-else-if="error" :message="error" :retry="load" class="mt-12" />
<div v-else class="mt-10 grid grid-cols-1 lg:grid-cols-3 gap-10 items-start">
<!-- Donut SVG (2/3 sur grands écrans) -->
<div class="lg:col-span-2 flex flex-col items-center">
<svg viewBox="-250 -250 500 500" class="w-full max-w-[560px] aspect-square">
<!-- Plafond écologique (anneau externe) -->
<circle r="240" fill="#22C55E14" />
<circle r="215" fill="#FAFAF6" />
<text
v-for="i in 1" :key="`ceil-${i}`"
x="0" y="-228" text-anchor="middle" font-size="9"
fill="#22C55E" font-weight="600" style="letter-spacing:1px"
>PLAFOND ÉCOLOGIQUE</text>
<!-- Plancher social (anneau interne) -->
<circle :r="R_FLOOR_OUTER" fill="#F9731614" />
<circle :r="R_FLOOR_INNER" fill="#FAFAF6" />
<!-- 6 secteurs catégories -->
<g>
<path
v-for="w in wedges" :key="w.cat.id"
:d="w.path"
:fill="w.cat.color_hex + '14'"
:stroke="w.cat.color_hex + '40'"
stroke-width="0.5"
/>
</g>
<!-- Étiquettes catégories à l'extérieur des secteurs -->
<g>
<text
v-for="w in wedges" :key="`label-${w.cat.id}`"
:x="w.labelPos[0]" :y="w.labelPos[1]"
text-anchor="middle"
font-size="10"
:fill="w.cat.color_hex"
font-weight="600"
>{{ w.cat.name }}</text>
</g>
<!-- Points projets -->
<g>
<circle
v-for="d in dots" :key="d.project.id"
:cx="d.x" :cy="d.y" :r="d.r"
:fill="d.color"
stroke="white" stroke-width="1.5"
class="cursor-pointer transition-transform hover:scale-150 origin-center"
@mouseenter="hoveredProject = d.project"
@mouseleave="hoveredProject = null"
@click="gotoProject(d.project)"
>
<title>{{ d.project.title }}</title>
</circle>
</g>
<!-- Centre -->
<circle :r="R_CENTER" fill="#FFFFFF" stroke="#E5E7EB" />
<text x="0" y="-4" text-anchor="middle" font-size="14" fill="#111827" font-weight="700"
style="letter-spacing:2px">LEDONUT</text>
<text x="0" y="14" text-anchor="middle" font-size="9" fill="#6B7280">
{{ projects.length }} projet{{ projects.length > 1 ? 's' : '' }}
</text>
</svg>
<!-- Tooltip projet survolé -->
<div class="mt-4 h-12 flex items-center justify-center">
<transition name="fade">
<div
v-if="hoveredProject"
class="px-3 py-1.5 rounded-full bg-white border border-donut-border shadow-sm text-sm flex items-center gap-2"
>
<span
class="w-2 h-2 rounded-full"
:style="{ background: hoveredProject.category.color_hex }"
/>
<span class="font-medium">{{ hoveredProject.title }}</span>
<span class="text-donut-muted text-xs"> {{ hoveredProject.progress_percent }}%</span>
</div>
<div v-else class="text-xs text-donut-muted">
Survolez un point pour voir le détail d'un projet
</div>
</transition>
</div>
</div>
<!-- Légende -->
<aside class="space-y-6">
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
<h2 class="text-base font-semibold text-donut-text">Comment lire le donut ?</h2>
<p class="text-sm text-donut-muted mt-2 leading-relaxed">
Chaque <strong class="text-donut-text">point</strong> est un projet citoyen, placé dans son
secteur de catégorie. L'anneau extérieur représente le
<span class="font-medium" style="color:#22C55E">plafond écologique</span> et l'anneau
intérieur le <span class="font-medium" style="color:#F97316">plancher social</span>.
L'objectif : que tous les projets contribuent à rester dans l'espace sûr entre les deux.
</p>
</div>
<div
v-for="dim in DIMENSION_ORDER"
:key="dim"
class="rounded-xl border border-donut-border bg-donut-surface p-5"
>
<div class="flex items-center gap-2 mb-3">
<span class="w-2 h-2 rounded-full" :style="{ background: DIMENSION_META[dim].color }" />
<h3 class="text-xs font-semibold uppercase tracking-wider" :style="{ color: DIMENSION_META[dim].color }">
{{ DIMENSION_META[dim].label }}
</h3>
</div>
<ul v-if="categoriesByDimension[dim].length" class="space-y-2">
<li
v-for="c in categoriesByDimension[dim]"
:key="c.id"
class="flex items-center gap-2 text-sm"
>
<span class="w-2.5 h-2.5 rounded-full shrink-0" :style="{ background: c.color_hex }" />
<RouterLink
:to="{ name: 'projects', query: { category: c.slug } }"
class="text-donut-text hover:underline"
>
{{ c.name }}
</RouterLink>
</li>
</ul>
<p v-else class="text-xs text-donut-muted">Aucune catégorie dans cette dimension.</p>
</div>
</aside>
</div>
</section>
</template>
<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>