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,275 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import type { Category, ProjectDetail, Wrapped } from '@/types/api'
|
||||
import AdminSubNav from '@/components/AdminSubNav.vue'
|
||||
import LoadingState from '@/components/LoadingState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const props = defineProps<{ slug: string }>()
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const project = ref<ProjectDetail | null>(null)
|
||||
const categories = ref<Category[]>([])
|
||||
const loading = ref(true)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
slug: '',
|
||||
category_id: 0 as number,
|
||||
status: 'active' as ProjectDetail['status'],
|
||||
territory: 'Marseille',
|
||||
short_description: '',
|
||||
context_description: '',
|
||||
objectives: '',
|
||||
cover_image_url: '',
|
||||
external_url: '',
|
||||
progress_percent: 0,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const fieldErrors = ref<Record<string, string>>({})
|
||||
const generalError = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const [p, cats] = await Promise.all([
|
||||
api.get<ProjectDetail>(`/api/projects/${encodeURIComponent(props.slug)}`),
|
||||
api.get<Wrapped<Category>>('/api/categories'),
|
||||
])
|
||||
project.value = p
|
||||
categories.value = cats.data
|
||||
// Préremplit le formulaire avec les valeurs du projet
|
||||
form.value = {
|
||||
title: p.title,
|
||||
slug: p.slug,
|
||||
category_id: p.category?.id ?? 0,
|
||||
status: p.status,
|
||||
territory: p.territory,
|
||||
short_description: p.short_description ?? '',
|
||||
context_description: p.context_description ?? '',
|
||||
objectives: p.objectives ?? '',
|
||||
cover_image_url: p.cover_image_url ?? '',
|
||||
external_url: p.external_url ?? '',
|
||||
progress_percent: p.progress_percent,
|
||||
}
|
||||
} catch (e) {
|
||||
loadError.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
const slugChanged = computed(() => project.value !== null && form.value.slug !== project.value.slug)
|
||||
|
||||
async function submit() {
|
||||
fieldErrors.value = {}
|
||||
generalError.value = null
|
||||
if (!isAuthenticated.value) {
|
||||
generalError.value = "Tu dois être connecté en admin."
|
||||
return
|
||||
}
|
||||
if (!project.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// Construit le diff : on n'envoie que ce qui a changé.
|
||||
const diff: Record<string, unknown> = {}
|
||||
const p = project.value
|
||||
if (form.value.title.trim() !== p.title) diff.title = form.value.title.trim()
|
||||
if (slugChanged.value) diff.slug = form.value.slug.trim()
|
||||
if (form.value.category_id !== (p.category?.id ?? 0)) diff.category_id = form.value.category_id
|
||||
if (form.value.status !== p.status) diff.status = form.value.status
|
||||
if (form.value.territory.trim() !== p.territory) diff.territory = form.value.territory.trim()
|
||||
if (form.value.short_description !== (p.short_description ?? '')) diff.short_description = form.value.short_description || null
|
||||
if (form.value.context_description !== (p.context_description ?? '')) diff.context_description = form.value.context_description || null
|
||||
if (form.value.objectives !== (p.objectives ?? '')) diff.objectives = form.value.objectives || null
|
||||
if (form.value.cover_image_url !== (p.cover_image_url ?? '')) diff.cover_image_url = form.value.cover_image_url || null
|
||||
if (form.value.external_url !== (p.external_url ?? '')) diff.external_url = form.value.external_url || null
|
||||
if (form.value.progress_percent !== p.progress_percent) diff.progress_percent = form.value.progress_percent
|
||||
|
||||
if (Object.keys(diff).length === 0) {
|
||||
generalError.value = 'Aucune modification à enregistrer.'
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const updated = await api.patch<ProjectDetail>(`/api/projects/${encodeURIComponent(p.slug)}`, diff)
|
||||
router.push({ name: 'admin-projects' })
|
||||
// Si l'utilisateur a changé le slug et reste sur cette page, mettre à jour
|
||||
project.value = updated
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.status === 422) {
|
||||
fieldErrors.value = e.fields
|
||||
} else if (e.status === 401) {
|
||||
generalError.value = 'Session admin expirée. Reconnecte-toi.'
|
||||
} else {
|
||||
generalError.value = e.payload.message ?? e.code
|
||||
}
|
||||
} else {
|
||||
generalError.value = 'Erreur réseau'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-10 py-8 max-w-4xl">
|
||||
<header class="mb-6">
|
||||
<h1 class="text-3xl font-semibold text-donut-text">Administration</h1>
|
||||
<p class="text-donut-muted mt-1">Édition d'un projet.</p>
|
||||
</header>
|
||||
<AdminSubNav />
|
||||
|
||||
<LoadingState v-if="loading" class="mt-8" />
|
||||
<ErrorState v-else-if="loadError" :message="loadError" :retry="load" class="mt-8" />
|
||||
|
||||
<div v-else-if="project" class="mt-6 space-y-4">
|
||||
<RouterLink :to="{ name: 'admin-projects' }" class="inline-flex items-center gap-1.5 text-sm text-donut-muted hover:text-donut-text">
|
||||
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m12 19-7-7 7-7M5 12h14"/>
|
||||
</svg>
|
||||
Retour à la liste
|
||||
</RouterLink>
|
||||
|
||||
<form @submit.prevent="submit" class="rounded-xl border border-donut-border bg-donut-surface p-6 space-y-5">
|
||||
<header class="border-b border-donut-border pb-4">
|
||||
<h2 class="text-base font-semibold text-donut-text">{{ project.title }}</h2>
|
||||
<p class="text-sm text-donut-muted mt-0.5">Édition du projet — les champs envoyés sont ceux qui ont changé.</p>
|
||||
</header>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Titre du projet *</label>
|
||||
<input
|
||||
v-model="form.title" type="text"
|
||||
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
|
||||
:class="fieldErrors.title ? 'border-red-400' : ''"
|
||||
/>
|
||||
<p v-if="fieldErrors.title" class="text-xs text-red-600 mt-1">Titre requis.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Slug</label>
|
||||
<input
|
||||
v-model="form.slug" type="text"
|
||||
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="fieldErrors.slug ? 'border-red-400' : ''"
|
||||
/>
|
||||
<p class="text-xs text-donut-muted mt-1">Modifier change l'URL publique du projet.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Statut</label>
|
||||
<select
|
||||
v-model="form.status"
|
||||
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
|
||||
>
|
||||
<option value="active">Actif</option>
|
||||
<option value="completed">Réalisé</option>
|
||||
<option value="archived">Archivé</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Catégorie *</label>
|
||||
<select
|
||||
v-model="form.category_id"
|
||||
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
|
||||
:class="fieldErrors.category_id ? 'border-red-400' : ''"
|
||||
>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Territoire</label>
|
||||
<input
|
||||
v-model="form.territory" type="text"
|
||||
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Progression : {{ form.progress_percent }} %</label>
|
||||
<input
|
||||
v-model.number="form.progress_percent" type="range" min="0" max="100" step="5"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Description courte</label>
|
||||
<textarea
|
||||
v-model="form.short_description" rows="2"
|
||||
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Description complète</label>
|
||||
<textarea
|
||||
v-model="form.context_description" rows="5"
|
||||
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Objectifs</label>
|
||||
<textarea
|
||||
v-model="form.objectives" rows="3"
|
||||
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Image de cover (URL)</label>
|
||||
<input
|
||||
v-model="form.cover_image_url" type="url"
|
||||
placeholder="https://…"
|
||||
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
|
||||
/>
|
||||
<p class="text-xs text-donut-muted mt-1">Pour une galerie complète, voir l'onglet Images sur la fiche projet.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">URL externe</label>
|
||||
<input
|
||||
v-model="form.external_url" type="url"
|
||||
placeholder="https://…"
|
||||
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm outline-none focus:border-donut-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="generalError" class="rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
|
||||
{{ generalError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2 pt-2 border-t border-donut-border">
|
||||
<RouterLink
|
||||
:to="{ name: 'admin-projects' }"
|
||||
class="px-4 py-2 rounded-lg text-sm font-medium border border-donut-border hover:bg-gray-50"
|
||||
>Annuler</RouterLink>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
class="px-4 py-2 rounded-lg bg-donut-text text-white text-sm font-medium hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
{{ submitting ? 'Enregistrement…' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user