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:
@@ -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>
|
||||
Reference in New Issue
Block a user