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,128 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import type { Member, Paginated } from '@/types/api'
|
||||
import AdminSubNav from '@/components/AdminSubNav.vue'
|
||||
import LoadingState from '@/components/LoadingState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
import Avatar from '@/components/Avatar.vue'
|
||||
import { formatDate } from '@/utils/formatDate'
|
||||
|
||||
const members = ref<Member[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const searchQ = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const r = await api.get<Paginated<Member>>('/api/members', { query: { per_page: 100 } })
|
||||
members.value = r.data
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!searchQ.value.trim()) return members.value
|
||||
const q = searchQ.value.trim().toLowerCase()
|
||||
return members.value.filter(m =>
|
||||
m.display_name.toLowerCase().includes(q) ||
|
||||
(m.bio ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
async function destroy(m: Member) {
|
||||
if (!confirm(`Supprimer définitivement « ${m.display_name} » ?\nLe membre sera retiré de tous les projets.`)) return
|
||||
try {
|
||||
await api.delete(`/api/members/${encodeURIComponent(m.slug)}`)
|
||||
members.value = members.value.filter(x => x.id !== m.id)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) alert(`Erreur : ${e.payload.message ?? e.code}`)
|
||||
}
|
||||
}
|
||||
|
||||
const KIND_LABEL = {
|
||||
person: 'Personne',
|
||||
collective: 'Collectif',
|
||||
org: 'Organisation',
|
||||
} as const
|
||||
</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">Annuaire des membres.</p>
|
||||
</header>
|
||||
<AdminSubNav />
|
||||
|
||||
<div class="mt-6 flex items-center gap-2">
|
||||
<input
|
||||
v-model="searchQ"
|
||||
type="search"
|
||||
placeholder="Rechercher un membre…"
|
||||
class="bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<LoadingState v-if="loading" class="mt-8" />
|
||||
<ErrorState v-else-if="error" :message="error" :retry="load" class="mt-8" />
|
||||
|
||||
<div v-else class="mt-4 rounded-xl border border-donut-border bg-donut-surface overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 border-b border-donut-border text-xs uppercase tracking-wider text-donut-muted">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-medium">Nom</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Type</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Contact</th>
|
||||
<th class="text-left px-4 py-3 font-medium">Ajouté</th>
|
||||
<th class="text-right px-4 py-3 font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="filtered.length === 0">
|
||||
<td colspan="5" class="px-4 py-8 text-center text-donut-muted text-sm">
|
||||
Aucun membre.
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="m in filtered" :key="m.id" class="border-t border-donut-border hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<Avatar :name="m.display_name" :initials="m.initials" :avatar-url="m.avatar_url" :size="32" />
|
||||
<div>
|
||||
<RouterLink
|
||||
:to="{ name: 'project-detail', params: { slug: m.slug } }"
|
||||
class="font-medium text-donut-text hover:underline"
|
||||
>{{ m.display_name }}</RouterLink>
|
||||
<div v-if="m.bio" class="text-xs text-donut-muted truncate max-w-md">{{ m.bio }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-donut-muted">{{ KIND_LABEL[m.kind] }}</td>
|
||||
<td class="px-4 py-3 text-donut-muted text-xs">
|
||||
<a v-if="m.contact_email" :href="`mailto:${m.contact_email}`" class="hover:underline">{{ m.contact_email }}</a>
|
||||
<a v-if="m.website_url" :href="m.website_url" target="_blank" rel="noopener" class="hover:underline block">{{ m.website_url }}</a>
|
||||
<span v-if="!m.contact_email && !m.website_url" class="text-donut-muted">—</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-donut-muted">{{ formatDate(m.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-500 hover:text-red-700 text-xs font-medium"
|
||||
@click="destroy(m)"
|
||||
>Supprimer</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs text-donut-muted">{{ filtered.length }} membre{{ filtered.length > 1 ? 's' : '' }}.</p>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user