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
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api, ApiError } from '@/api/client'
import { useAuth } from '@/composables/useAuth'
const router = useRouter()
const { login, isAuthenticated, logout } = useAuth()
const token = ref('')
const submitting = ref(false)
const error = ref<string | null>(null)
async function submit() {
error.value = null
const t = token.value.trim()
if (!t) return
submitting.value = true
// On stocke le token, puis on tente un appel protégé pour le valider.
// Si 401 -> on l'efface et on affiche l'erreur. Si OK -> on redirige.
login(t)
try {
// Le plus léger : POST sur une route protégée avec un body invalide volontairement
// pour récupérer 422 (auth OK) ou 401 (auth KO) sans créer de ressource.
// Mais c'est sale. Mieux : faire un GET protégé. On n'a pas de "whoami".
// Solution : POST /api/members avec body vide -> renvoie 422 si admin OK, 401 sinon.
await api.post('/api/members', {})
router.push({ name: 'dashboard' })
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 401) {
logout()
error.value = 'Token invalide.'
} else {
// 422 (validation_failed) signifie que l'auth est OK, on continue.
router.push({ name: 'dashboard' })
}
} else {
logout()
error.value = 'Erreur réseau — réessaie.'
}
} finally {
submitting.value = false
}
}
</script>
<template>
<section class="min-h-full flex items-start justify-center px-6 py-16">
<div class="w-full max-w-md">
<header class="text-center mb-6">
<h1 class="text-2xl font-semibold text-donut-text">Connexion admin</h1>
<p class="text-donut-muted text-sm mt-2">
Saisis le token Bearer pour débloquer la création de projets, l'édition et la modération.
</p>
</header>
<div v-if="isAuthenticated" class="rounded-xl border border-green-200 bg-green-50 text-green-800 p-4 text-sm">
Tu es déjà connecté en admin.
<button @click="logout" class="ml-2 underline font-medium">Se déconnecter</button>
</div>
<form v-else @submit.prevent="submit" class="rounded-xl border border-donut-border bg-donut-surface p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Token admin</label>
<input
v-model="token"
type="password"
autocomplete="off"
placeholder="64 caractères hex"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm font-mono outline-none focus:border-donut-text"
:class="error ? 'border-red-400' : ''"
/>
<p class="text-xs text-donut-muted mt-1">
Visible dans <code>.env</code> sous <code>ADMIN_TOKEN</code>.
</p>
</div>
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
{{ error }}
</div>
<button
type="submit"
:disabled="submitting || !token.trim()"
class="w-full bg-donut-text text-white rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-black disabled:opacity-50"
>
{{ submitting ? 'Vérification' : 'Se connecter' }}
</button>
</form>
<p class="text-xs text-donut-muted text-center mt-4">
Pas de session côté serveur : le token est stocké dans <code>localStorage</code>.
</p>
</div>
</section>
</template>