feat: galerie d'images projet (upload disque) + dashboard /admin

==== Galerie d'images ====

Backend
  - Migration 003 : table project_images (FK CASCADE sur projects)
  - ImagesController : CRUD complet, deux modes de création
      * URL externe via JSON body
      * Upload disque via multipart/form-data avec :
          - validation MIME RÉELLE via finfo (jpeg/png/webp/gif)
          - taille max 5 Mo, vérifiée explicitement (au-delà du php.ini)
          - vérification UPLOAD_ERR_* avec messages clairs
          - stockage dans public/uploads/projects/:slug/<hash>.<ext>
          - dimensions image extraites via getimagesize (best-effort)
          - row DB avec mime_type / size_bytes / width / height
  - Request : ajout isMultipart(), formField(), file()
  - public/index.php : 3 routes (index public, store/update/destroy admin)

Proxy
  - caddy.conf : /uploads/* ajouté au reverse_proxy comme /api/*
  - vite.config.ts : pareil pour le dev local

Frontend
  - Type ProjectImage (api.ts) avec url + storage_path + dimensions
  - uploadFile() helper dans client.ts (FormData + Bearer)
  - ImageGallery.vue : grille thumbnails + lightbox plein-écran avec
    nav clavier (← → Esc), bouton supprimer par image en mode admin
  - ImageUploader.vue : drag-drop + file picker, multi-fichiers en série,
    validation taille côté client, légende commune optionnelle
  - ProjectDetailPage : nouvel onglet Images entre Roadmap et Discussions

==== Dashboard /admin ====

5 nouvelles pages sous /admin :
  - /admin                           : KPI + répartition catégorie + flux activité
  - /admin/projects                  : table CRUD (recherche + filtres status/categorie),
                                       actions Éditer / Archiver / Supprimer par ligne
  - /admin/projects/:slug/edit       : form complet d'édition (envoie un diff au PATCH)
  - /admin/members                   : annuaire avec recherche + Supprimer
  - /admin/categories                : table avec compte de projets ;
                                       Supprimer désactivé si la catégorie est utilisée
  - AdminSubNav.vue                  : 4 onglets en haut de chaque page admin

Garde d'auth :
  - beforeEnter sur les routes /admin/* (sauf /admin/login)
  - Redirige vers /admin/login?next=<url> si pas de token
  - Après login, retour automatique vers next= (sinon /admin)

Sidebar :
  - Nouvelle section "Administration" visible UNIQUEMENT quand le token
    est en localStorage, avec un lien vers le dashboard

Vérifié localement
  - Build vue-tsc : 91 modules, OK
  - Upload disque : fichier PNG stocké dans public/uploads/, accessible via /uploads/
  - GET /api/projects/nos-ecoles/images : 2 entrées renvoyées (1 URL + 1 disque)
  - Toutes les routes /admin/* répondent 200 via le proxy Vite

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 23:07:59 +02:00
parent 034cb8ba99
commit adb2bd3fc1
20 changed files with 1671 additions and 12 deletions
@@ -0,0 +1,99 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { api, ApiError } from '@/api/client'
import type { ActivityEvent, Stats, Wrapped } from '@/types/api'
import AdminSubNav from '@/components/AdminSubNav.vue'
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: 15 } }),
])
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)
</script>
<template>
<section class="px-10 py-8 max-w-7xl">
<header class="mb-6">
<h1 class="text-3xl font-semibold text-donut-text">Administration</h1>
<p class="text-donut-muted mt-1">Vue d'ensemble du Donut Infolab — gestion des projets, membres et catégories.</p>
</header>
<AdminSubNav />
<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-2 sm:grid-cols-4 gap-4">
<RouterLink
v-for="kpi in [
{ label: 'Projets actifs', value: stats?.projects_active ?? 0, to: '/admin/projects', color: '#F97316' },
{ label: 'Projets réalisés', value: stats?.projects_completed ?? 0, to: '/admin/projects', color: '#A855F7' },
{ label: 'Archives', value: stats?.projects_archived ?? 0, to: '/admin/projects', color: '#6B7280' },
{ label: 'Participants', value: stats?.participants_total ?? 0, to: '/admin/members', color: '#EF4444' },
]"
:key="kpi.label"
:to="kpi.to"
class="rounded-xl border border-donut-border bg-donut-surface p-5 hover:shadow-md transition-shadow"
>
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full" :style="{ background: kpi.color }" />
<div class="text-sm text-donut-muted">{{ kpi.label }}</div>
</div>
<div class="mt-2 text-3xl font-semibold text-donut-text tabular-nums">{{ kpi.value }}</div>
</RouterLink>
</div>
<!-- Répartition par catégorie -->
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
<h2 class="text-base font-semibold text-donut-text mb-3">Répartition des projets par catégorie</h2>
<ul class="space-y-2">
<li v-for="c in stats?.by_category" :key="c.slug" class="flex items-center gap-3">
<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="flex-1 text-sm text-donut-text hover:underline truncate"
>{{ c.name }}</RouterLink>
<span class="text-sm text-donut-muted tabular-nums">{{ c.count }} projet{{ c.count > 1 ? 's' : '' }}</span>
</li>
</ul>
</div>
<!-- Flux d'activité -->
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
<h2 class="text-base font-semibold text-donut-text mb-3">Activité récente</h2>
<ul v-if="activity.length" class="space-y-2.5">
<li v-for="a in activity" :key="a.id" class="flex items-start gap-3 text-sm">
<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">
<span class="text-donut-text">{{ a.summary }}</span>
<span class="text-donut-muted text-xs ml-2"> {{ formatRelative(a.occurred_at) }}</span>
</div>
</li>
</ul>
<p v-else class="text-sm text-donut-muted">Aucune activité récente.</p>
</div>
</div>
</section>
</template>