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