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:
+6
-3
@@ -15,9 +15,12 @@ infolab.ledonut-marseille.org {
|
|||||||
-Server
|
-Server
|
||||||
}
|
}
|
||||||
|
|
||||||
# API JSON -> conteneur PHP (sur réseau Docker local, exposé en 127.0.0.1:8003)
|
# API JSON et images uploadées -> conteneur PHP (sur donut-net, exposé 127.0.0.1:8003).
|
||||||
@api path /api/*
|
# /uploads/* contient les images de la galerie projet stockées sur disque par
|
||||||
handle @api {
|
# l'admin via POST /api/projects/:slug/images (multipart). Apache du conteneur
|
||||||
|
# les sert en static — pas besoin de PHP juste pour servir un fichier.
|
||||||
|
@apiOrUploads path /api/* /uploads/*
|
||||||
|
handle @apiOrUploads {
|
||||||
reverse_proxy 127.0.0.1:8003 {
|
reverse_proxy 127.0.0.1:8003 {
|
||||||
header_up Host {host}
|
header_up Host {host}
|
||||||
header_up X-Real-IP {remote_host}
|
header_up X-Real-IP {remote_host}
|
||||||
|
|||||||
@@ -116,3 +116,45 @@ export const api = {
|
|||||||
patch: <T>(path: string, body: Json, opts?: RequestOptions) => request<T>('PATCH', path, body, opts),
|
patch: <T>(path: string, body: Json, opts?: RequestOptions) => request<T>('PATCH', path, body, opts),
|
||||||
delete: <T>(path: string, opts?: RequestOptions) => request<T>('DELETE', path, undefined, opts),
|
delete: <T>(path: string, opts?: RequestOptions) => request<T>('DELETE', path, undefined, opts),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload multipart/form-data — utilisé par la galerie projet.
|
||||||
|
*
|
||||||
|
* Le navigateur fixe lui-même le Content-Type avec le bon boundary, on ne le
|
||||||
|
* définit donc PAS à la main (sinon le boundary serait absent et le backend ne
|
||||||
|
* saurait pas découper le body).
|
||||||
|
*
|
||||||
|
* @param path path vers l'endpoint, ex: '/api/projects/nos-ecoles/images'
|
||||||
|
* @param fields champs texte additionnels (caption, sort_order, ...)
|
||||||
|
* @param file le fichier (issu d'un <input type="file"> ou drop)
|
||||||
|
* @param fileFieldName nom du champ côté serveur (défaut 'file')
|
||||||
|
*/
|
||||||
|
export async function uploadFile<T>(
|
||||||
|
path: string,
|
||||||
|
file: File,
|
||||||
|
fields: Record<string, string | number | undefined | null> = {},
|
||||||
|
fileFieldName = 'file',
|
||||||
|
): Promise<T> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append(fileFieldName, file, file.name)
|
||||||
|
for (const [k, v] of Object.entries(fields)) {
|
||||||
|
if (v !== undefined && v !== null && v !== '') {
|
||||||
|
form.append(k, String(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||||
|
const token = getAdminToken()
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
|
||||||
|
const res = await fetch(path, { method: 'POST', headers, body: form })
|
||||||
|
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
const text = await res.text()
|
||||||
|
const data = text === '' ? null : (JSON.parse(text) as unknown)
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = (data ?? { error: 'internal_server_error' }) as ApiErrorPayload
|
||||||
|
throw new ApiError(res.status, payload.error ?? `http_${res.status}`, payload)
|
||||||
|
}
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Sous-nav du dashboard admin : 4 onglets visuels en haut de chaque page /admin/*.
|
||||||
|
* On reste dans AppLayout (sidebar principale visible), c'est seulement une
|
||||||
|
* convention de styling pour signaler qu'on est en "mode admin".
|
||||||
|
*/
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ to: { name: 'admin-overview' }, label: "Vue d'ensemble" },
|
||||||
|
{ to: { name: 'admin-projects' }, label: 'Projets' },
|
||||||
|
{ to: { name: 'admin-members' }, label: 'Membres' },
|
||||||
|
{ to: { name: 'admin-categories' }, label: 'Catégories' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border-b border-donut-border">
|
||||||
|
<nav class="flex items-center gap-1 -mb-px">
|
||||||
|
<RouterLink
|
||||||
|
v-for="it in items" :key="it.to.name"
|
||||||
|
:to="it.to"
|
||||||
|
custom
|
||||||
|
v-slot="{ href, navigate, isActive }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="href"
|
||||||
|
@click="navigate"
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center px-4 py-2.5 text-sm font-medium border-b-2 transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'border-donut-text text-donut-text'
|
||||||
|
: 'border-transparent text-donut-muted hover:text-donut-text',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ it.label }}
|
||||||
|
</a>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -121,6 +121,33 @@ const navItems: NavLink[] = [
|
|||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</a>
|
</a>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<!-- Section admin (visible uniquement quand on a un token) -->
|
||||||
|
<template v-if="isAuthenticated">
|
||||||
|
<div class="px-2 pt-5 pb-2 text-[10px] font-semibold uppercase tracking-wider text-donut-muted">
|
||||||
|
Administration
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'admin-overview' }"
|
||||||
|
custom
|
||||||
|
v-slot="{ href, navigate, isActive }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="href" @click="navigate"
|
||||||
|
:class="[
|
||||||
|
'flex items-center gap-2.5 px-3 py-2 rounded-md text-sm transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-orange-50 text-status-active font-medium'
|
||||||
|
: 'text-donut-text hover:bg-gray-50',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard admin
|
||||||
|
</a>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Badge bas de sidebar : statut admin -->
|
<!-- Badge bas de sidebar : statut admin -->
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Galerie d'images d'un projet.
|
||||||
|
*
|
||||||
|
* - Grille de miniatures (lazy-load).
|
||||||
|
* - Click thumb -> overlay plein écran avec navigation gauche/droite,
|
||||||
|
* fermeture par clic hors image / Échap.
|
||||||
|
* - Mode admin (token Bearer présent) : bouton Supprimer par image + uploader.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { api, ApiError } from '@/api/client'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
import type { ProjectImage, Wrapped } from '@/types/api'
|
||||||
|
import ImageUploader from './ImageUploader.vue'
|
||||||
|
import LoadingState from './LoadingState.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ projectSlug: string }>()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
const images = ref<ProjectImage[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const lightboxIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const r = await api.get<Wrapped<ProjectImage>>(`/api/projects/${encodeURIComponent(props.projectSlug)}/images`)
|
||||||
|
images.value = r.data
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
watch(() => props.projectSlug, load)
|
||||||
|
|
||||||
|
function openLightbox(idx: number) {
|
||||||
|
lightboxIndex.value = idx
|
||||||
|
}
|
||||||
|
function closeLightbox() {
|
||||||
|
lightboxIndex.value = null
|
||||||
|
}
|
||||||
|
function next() {
|
||||||
|
if (lightboxIndex.value === null) return
|
||||||
|
lightboxIndex.value = (lightboxIndex.value + 1) % images.value.length
|
||||||
|
}
|
||||||
|
function prev() {
|
||||||
|
if (lightboxIndex.value === null) return
|
||||||
|
lightboxIndex.value = (lightboxIndex.value - 1 + images.value.length) % images.value.length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation clavier dans la lightbox.
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (lightboxIndex.value === null) return
|
||||||
|
if (e.key === 'Escape') closeLightbox()
|
||||||
|
if (e.key === 'ArrowRight') next()
|
||||||
|
if (e.key === 'ArrowLeft') prev()
|
||||||
|
}
|
||||||
|
onMounted(() => window.addEventListener('keydown', onKey))
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||||
|
|
||||||
|
async function remove(img: ProjectImage) {
|
||||||
|
if (!confirm(`Supprimer l'image « ${img.caption ?? 'sans légende'} » ?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/projects/${encodeURIComponent(props.projectSlug)}/images/${img.id}`)
|
||||||
|
images.value = images.value.filter(i => i.id !== img.id)
|
||||||
|
if (lightboxIndex.value !== null && lightboxIndex.value >= images.value.length) {
|
||||||
|
lightboxIndex.value = images.value.length === 0 ? null : images.value.length - 1
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('image delete failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUploaded(newImg: ProjectImage) {
|
||||||
|
images.value.push(newImg)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentImage = computed(() =>
|
||||||
|
lightboxIndex.value === null ? null : images.value[lightboxIndex.value],
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<LoadingState v-if="loading" />
|
||||||
|
<div v-else-if="error" class="text-sm text-red-700">{{ error }}</div>
|
||||||
|
|
||||||
|
<div v-else-if="images.length === 0" class="text-sm text-donut-muted">
|
||||||
|
Pas encore d'image.
|
||||||
|
<span v-if="!isAuthenticated">Connecte-toi en admin pour en ajouter.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grille -->
|
||||||
|
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="(img, idx) in images" :key="img.id"
|
||||||
|
class="group relative aspect-square rounded-lg overflow-hidden bg-gray-100 cursor-pointer"
|
||||||
|
@click="openLightbox(idx)"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="img.url"
|
||||||
|
:alt="img.caption ?? `Image ${idx + 1} du projet`"
|
||||||
|
loading="lazy"
|
||||||
|
class="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="img.caption"
|
||||||
|
class="absolute inset-x-0 bottom-0 px-2.5 py-1.5 bg-gradient-to-t from-black/70 to-transparent text-white text-xs"
|
||||||
|
>
|
||||||
|
{{ img.caption }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="isAuthenticated"
|
||||||
|
type="button"
|
||||||
|
class="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity bg-red-500/90 hover:bg-red-600 text-white rounded-md p-1.5"
|
||||||
|
title="Supprimer"
|
||||||
|
@click.stop="remove(img)"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<path d="M18 6 6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Uploader (admin uniquement) -->
|
||||||
|
<div v-if="isAuthenticated" class="mt-4 pt-4 border-t border-donut-border">
|
||||||
|
<ImageUploader :project-slug="projectSlug" @uploaded="onUploaded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="currentImage"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 px-4"
|
||||||
|
@click.self="closeLightbox"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute top-4 right-4 text-white/80 hover:text-white"
|
||||||
|
@click="closeLightbox"
|
||||||
|
aria-label="Fermer"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-7 h-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||||
|
<path d="M18 6 6 18M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="images.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="absolute left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2"
|
||||||
|
@click="prev"
|
||||||
|
aria-label="Image précédente"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-8 h-8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m15 18-6-6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="max-w-5xl max-h-full flex flex-col items-center">
|
||||||
|
<img
|
||||||
|
:src="currentImage.url"
|
||||||
|
:alt="currentImage.caption ?? 'Image projet'"
|
||||||
|
class="max-h-[80vh] max-w-full object-contain rounded-lg"
|
||||||
|
/>
|
||||||
|
<div v-if="currentImage.caption" class="mt-3 text-white/90 text-sm">
|
||||||
|
{{ currentImage.caption }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 text-white/40 text-xs">
|
||||||
|
{{ (lightboxIndex ?? 0) + 1 }} / {{ images.length }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="images.length > 1"
|
||||||
|
type="button"
|
||||||
|
class="absolute right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2"
|
||||||
|
@click="next"
|
||||||
|
aria-label="Image suivante"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-8 h-8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="m9 18 6-6-6-6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* Uploader d'images : drag-and-drop OU file picker, + champ légende optionnel.
|
||||||
|
* Accepte plusieurs fichiers, les upload en série pour conserver l'ordre.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { uploadFile, ApiError } from '@/api/client'
|
||||||
|
import type { ProjectImage } from '@/types/api'
|
||||||
|
|
||||||
|
const props = defineProps<{ projectSlug: string }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'uploaded', img: ProjectImage): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const dragOver = ref(false)
|
||||||
|
const caption = ref('')
|
||||||
|
const uploading = ref(false)
|
||||||
|
const queueSize = ref(0)
|
||||||
|
const queueDone = ref(0)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const ACCEPT = 'image/jpeg,image/png,image/webp,image/gif'
|
||||||
|
const MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
async function uploadOne(file: File): Promise<void> {
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
error.value = `« ${file.name } » fait ${(file.size / 1024 / 1024).toFixed(1)} Mo (max 5 Mo)`
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const img = await uploadFile<ProjectImage>(
|
||||||
|
`/api/projects/${encodeURIComponent(props.projectSlug)}/images`,
|
||||||
|
file,
|
||||||
|
{ caption: caption.value.trim() || undefined },
|
||||||
|
)
|
||||||
|
emit('uploaded', img)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) {
|
||||||
|
if (e.status === 422 && e.fields.file === 'too_large') {
|
||||||
|
error.value = `« ${file.name} » est trop volumineux (max 5 Mo)`
|
||||||
|
} else if (e.status === 422 && e.fields.file === 'unsupported_mime') {
|
||||||
|
error.value = `« ${file.name} » : type non supporté (JPG/PNG/WebP/GIF uniquement)`
|
||||||
|
} else if (e.status === 401) {
|
||||||
|
error.value = 'Tu dois être admin pour uploader.'
|
||||||
|
} else {
|
||||||
|
error.value = e.payload.message ?? `Échec de l'upload de « ${file.name} »`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error.value = `Erreur réseau pendant l'upload de « ${file.name} »`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFiles(files: FileList | File[]) {
|
||||||
|
error.value = null
|
||||||
|
const list = Array.from(files).filter(f => f.type.startsWith('image/'))
|
||||||
|
if (list.length === 0) {
|
||||||
|
error.value = "Aucun fichier image dans la sélection."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploading.value = true
|
||||||
|
queueSize.value = list.length
|
||||||
|
queueDone.value = 0
|
||||||
|
for (const f of list) {
|
||||||
|
await uploadOne(f)
|
||||||
|
queueDone.value++
|
||||||
|
}
|
||||||
|
uploading.value = false
|
||||||
|
caption.value = '' // reset après une session d'upload
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.files) processFiles(target.files)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
dragOver.value = false
|
||||||
|
if (e.dataTransfer?.files) processFiles(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg border-2 border-dashed transition-colors p-5 text-center cursor-pointer"
|
||||||
|
:class="dragOver ? 'border-donut-text bg-gray-50' : 'border-donut-border hover:border-gray-400 hover:bg-gray-50'"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
@dragenter.prevent="dragOver = true"
|
||||||
|
@dragover.prevent="dragOver = true"
|
||||||
|
@dragleave="dragOver = false"
|
||||||
|
@drop="onDrop"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-7 h-7 mx-auto text-donut-muted" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||||
|
<circle cx="9" cy="9" r="2"/>
|
||||||
|
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
||||||
|
</svg>
|
||||||
|
<div class="mt-2 text-sm text-donut-text">
|
||||||
|
<span class="font-medium">Déposer des images ici</span> ou cliquer pour parcourir
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-xs text-donut-muted">JPG / PNG / WebP / GIF — 5 Mo max</div>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
:accept="ACCEPT"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model="caption"
|
||||||
|
type="text"
|
||||||
|
placeholder="Légende commune à cette série (optionnel)"
|
||||||
|
class="flex-1 bg-donut-surface border border-donut-border rounded-md px-2.5 py-1.5 text-xs outline-none focus:border-donut-text"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploading" class="mt-2 flex items-center gap-2 text-xs text-donut-muted">
|
||||||
|
<svg viewBox="0 0 24 24" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||||
|
</svg>
|
||||||
|
Upload {{ queueDone + 1 }}/{{ queueSize }}…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="mt-2 rounded-md border border-red-200 bg-red-50 text-red-700 px-2.5 py-1.5 text-xs">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api, ApiError } from '@/api/client'
|
import { api, ApiError } from '@/api/client'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const { login, isAuthenticated, logout } = useAuth()
|
const { login, isAuthenticated, logout } = useAuth()
|
||||||
|
|
||||||
|
// Après login, on retourne à `next` si fourni (cas où l'utilisateur a tenté
|
||||||
|
// d'accéder à /admin/X sans token et a été redirigé ici).
|
||||||
|
function postLoginTarget(): { name: string } | string {
|
||||||
|
const next = route.query.next
|
||||||
|
if (typeof next === 'string' && next.startsWith('/') && next !== '/admin/login') {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return { name: 'admin-overview' }
|
||||||
|
}
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
@@ -19,12 +30,10 @@ async function submit() {
|
|||||||
// Si 401 -> on l'efface et on affiche l'erreur. Si OK -> on redirige.
|
// Si 401 -> on l'efface et on affiche l'erreur. Si OK -> on redirige.
|
||||||
login(t)
|
login(t)
|
||||||
try {
|
try {
|
||||||
// Le plus léger : POST sur une route protégée avec un body invalide volontairement
|
// L'API n'a pas de route "whoami". Trick : POST /api/members avec body vide
|
||||||
// pour récupérer 422 (auth OK) ou 401 (auth KO) sans créer de ressource.
|
// -> renvoie 422 si l'auth est OK (validation_failed), 401 si le token est faux.
|
||||||
// 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', {})
|
await api.post('/api/members', {})
|
||||||
router.push({ name: 'dashboard' })
|
router.push(postLoginTarget())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError) {
|
if (e instanceof ApiError) {
|
||||||
if (e.status === 401) {
|
if (e.status === 401) {
|
||||||
@@ -32,7 +41,7 @@ async function submit() {
|
|||||||
error.value = 'Token invalide.'
|
error.value = 'Token invalide.'
|
||||||
} else {
|
} else {
|
||||||
// 422 (validation_failed) signifie que l'auth est OK, on continue.
|
// 422 (validation_failed) signifie que l'auth est OK, on continue.
|
||||||
router.push({ name: 'dashboard' })
|
router.push(postLoginTarget())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logout()
|
logout()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Avatar from '@/components/Avatar.vue'
|
|||||||
import LoadingState from '@/components/LoadingState.vue'
|
import LoadingState from '@/components/LoadingState.vue'
|
||||||
import ErrorState from '@/components/ErrorState.vue'
|
import ErrorState from '@/components/ErrorState.vue'
|
||||||
import JoinProjectModal from '@/components/JoinProjectModal.vue'
|
import JoinProjectModal from '@/components/JoinProjectModal.vue'
|
||||||
|
import ImageGallery from '@/components/ImageGallery.vue'
|
||||||
import { formatDate, formatMonthYear, formatRelative } from '@/utils/formatDate'
|
import { formatDate, formatMonthYear, formatRelative } from '@/utils/formatDate'
|
||||||
|
|
||||||
const props = defineProps<{ slug: string }>()
|
const props = defineProps<{ slug: string }>()
|
||||||
@@ -24,7 +25,7 @@ const loading = ref(true)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
// Tabs
|
// Tabs
|
||||||
type Tab = 'roadmap' | 'discussions' | 'resources'
|
type Tab = 'roadmap' | 'discussions' | 'resources' | 'images'
|
||||||
const activeTab = ref<Tab>('roadmap')
|
const activeTab = ref<Tab>('roadmap')
|
||||||
|
|
||||||
// Discussions et resources : lazy-loaded au 1er affichage du tab.
|
// Discussions et resources : lazy-loaded au 1er affichage du tab.
|
||||||
@@ -302,7 +303,7 @@ const ROLE_LABEL = { admin: 'Admin', contributor: 'Contributor', observer: 'Obse
|
|||||||
<div class="border-b border-donut-border">
|
<div class="border-b border-donut-border">
|
||||||
<nav class="flex items-center gap-6 -mb-px" role="tablist">
|
<nav class="flex items-center gap-6 -mb-px" role="tablist">
|
||||||
<button
|
<button
|
||||||
v-for="tab in (['roadmap', 'discussions', 'resources'] as const)"
|
v-for="tab in (['roadmap', 'images', 'discussions', 'resources'] as const)"
|
||||||
:key="tab"
|
:key="tab"
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -321,6 +322,12 @@ const ROLE_LABEL = { admin: 'Admin', contributor: 'Contributor', observer: 'Obse
|
|||||||
</svg>
|
</svg>
|
||||||
Feuille de route
|
Feuille de route
|
||||||
</span>
|
</span>
|
||||||
|
<span v-else-if="tab === 'images'" class="inline-flex items-center gap-2">
|
||||||
|
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
||||||
|
</svg>
|
||||||
|
Images
|
||||||
|
</span>
|
||||||
<span v-else-if="tab === 'discussions'" class="inline-flex items-center gap-2">
|
<span v-else-if="tab === 'discussions'" class="inline-flex items-center gap-2">
|
||||||
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
@@ -415,6 +422,11 @@ const ROLE_LABEL = { admin: 'Admin', contributor: 'Contributor', observer: 'Obse
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab : Images -->
|
||||||
|
<div v-else-if="activeTab === 'images'">
|
||||||
|
<ImageGallery :project-slug="project.slug" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab : Discussions -->
|
<!-- Tab : Discussions -->
|
||||||
<div v-else-if="activeTab === 'discussions'" class="space-y-4">
|
<div v-else-if="activeTab === 'discussions'" class="space-y-4">
|
||||||
<LoadingState v-if="discussionsLoading" />
|
<LoadingState v-if="discussionsLoading" />
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { api, ApiError } from '@/api/client'
|
||||||
|
import type { Category, Stats, Wrapped } from '@/types/api'
|
||||||
|
import AdminSubNav from '@/components/AdminSubNav.vue'
|
||||||
|
import LoadingState from '@/components/LoadingState.vue'
|
||||||
|
import ErrorState from '@/components/ErrorState.vue'
|
||||||
|
|
||||||
|
const categories = ref<Category[]>([])
|
||||||
|
const counts = ref<Record<string, number>>({})
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const [cats, stats] = await Promise.all([
|
||||||
|
api.get<Wrapped<Category>>('/api/categories'),
|
||||||
|
api.get<Stats>('/api/stats'),
|
||||||
|
])
|
||||||
|
categories.value = cats.data
|
||||||
|
counts.value = Object.fromEntries(stats.by_category.map(c => [c.slug, c.count]))
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(load)
|
||||||
|
|
||||||
|
const DIMENSION_LABEL = {
|
||||||
|
social: 'Plancher social',
|
||||||
|
ecologique: 'Plafond écologique',
|
||||||
|
transversal: 'Transversal',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
async function destroy(c: Category) {
|
||||||
|
const count = counts.value[c.slug] ?? 0
|
||||||
|
if (count > 0) {
|
||||||
|
alert(`Impossible de supprimer "${c.name}" : ${count} projet(s) utilise(nt) cette catégorie.\nDéplace-les ou archive-les d'abord.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!confirm(`Supprimer la catégorie "${c.name}" ?`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/categories/${encodeURIComponent(c.slug)}`)
|
||||||
|
categories.value = categories.value.filter(x => x.id !== c.id)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) alert(`Erreur : ${e.payload.message ?? e.code}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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">Catégories thématiques du Donut.</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-6 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">Catégorie</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Dimension</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Couleur</th>
|
||||||
|
<th class="text-right px-4 py-3 font-medium">Ordre</th>
|
||||||
|
<th class="text-right px-4 py-3 font-medium">Projets</th>
|
||||||
|
<th class="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="c in [...categories].sort((a, b) => a.sort_order - b.sort_order)"
|
||||||
|
:key="c.id"
|
||||||
|
class="border-t border-donut-border hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="font-medium text-donut-text">{{ c.name }}</div>
|
||||||
|
<div v-if="c.description" class="text-xs text-donut-muted truncate max-w-md">{{ c.description }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-donut-muted">{{ DIMENSION_LABEL[c.dimension] }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-5 h-5 rounded-md shrink-0" :style="{ background: c.color_hex }" />
|
||||||
|
<code class="text-xs">{{ c.color_hex }}</code>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums">{{ c.sort_order }}</td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums">{{ counts[c.slug] ?? 0 }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-500 hover:text-red-700 text-xs font-medium disabled:opacity-30"
|
||||||
|
:disabled="(counts[c.slug] ?? 0) > 0"
|
||||||
|
:title="(counts[c.slug] ?? 0) > 0 ? 'Catégorie utilisée par des projets' : 'Supprimer'"
|
||||||
|
@click="destroy(c)"
|
||||||
|
>Supprimer</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { api, ApiError } from '@/api/client'
|
||||||
|
import type { Category, Paginated, ProjectListRow, ProjectStatus, Wrapped } from '@/types/api'
|
||||||
|
import AdminSubNav from '@/components/AdminSubNav.vue'
|
||||||
|
import LoadingState from '@/components/LoadingState.vue'
|
||||||
|
import ErrorState from '@/components/ErrorState.vue'
|
||||||
|
import StatusBadge from '@/components/StatusBadge.vue'
|
||||||
|
import CategoryBadge from '@/components/CategoryBadge.vue'
|
||||||
|
import { formatDate } from '@/utils/formatDate'
|
||||||
|
|
||||||
|
const projects = ref<ProjectListRow[]>([])
|
||||||
|
const categories = ref<Category[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
const filterStatus = ref<'' | ProjectStatus>('')
|
||||||
|
const filterCategory = ref('')
|
||||||
|
const searchQ = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const [projs, cats] = await Promise.all([
|
||||||
|
api.get<Paginated<ProjectListRow>>('/api/projects', { query: { per_page: 100 } }),
|
||||||
|
api.get<Wrapped<Category>>('/api/categories'),
|
||||||
|
])
|
||||||
|
projects.value = projs.data
|
||||||
|
categories.value = cats.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(() => {
|
||||||
|
let r = projects.value
|
||||||
|
if (filterStatus.value) r = r.filter(p => p.status === filterStatus.value)
|
||||||
|
if (filterCategory.value) r = r.filter(p => p.category.slug === filterCategory.value)
|
||||||
|
if (searchQ.value.trim()) {
|
||||||
|
const q = searchQ.value.trim().toLowerCase()
|
||||||
|
r = r.filter(p =>
|
||||||
|
p.title.toLowerCase().includes(q) ||
|
||||||
|
(p.short_description ?? '').toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
})
|
||||||
|
|
||||||
|
async function destroy(p: ProjectListRow) {
|
||||||
|
if (!confirm(`Supprimer le projet « ${p.title} » ?\n(Soft delete, le projet sera retiré des listes publiques.)`)) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/projects/${encodeURIComponent(p.slug)}`)
|
||||||
|
projects.value = projects.value.filter(x => x.id !== p.id)
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) alert(`Erreur : ${e.payload.message ?? e.code}`)
|
||||||
|
else alert('Erreur réseau')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archive(p: ProjectListRow) {
|
||||||
|
if (!confirm(`Archiver le projet « ${p.title} » ?`)) return
|
||||||
|
try {
|
||||||
|
await api.patch<ProjectListRow>(`/api/projects/${encodeURIComponent(p.slug)}`, { status: 'archived' })
|
||||||
|
p.status = 'archived'
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) alert(`Erreur : ${e.payload.message ?? e.code}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unarchive(p: ProjectListRow) {
|
||||||
|
try {
|
||||||
|
await api.patch<ProjectListRow>(`/api/projects/${encodeURIComponent(p.slug)}`, { status: 'active' })
|
||||||
|
p.status = 'active'
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError) alert(`Erreur : ${e.payload.message ?? e.code}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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 />
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-end justify-between gap-4 flex-wrap">
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<input
|
||||||
|
v-model="searchQ"
|
||||||
|
type="search"
|
||||||
|
placeholder="Rechercher un projet…"
|
||||||
|
class="bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text w-64"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-model="filterStatus"
|
||||||
|
class="bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
|
||||||
|
>
|
||||||
|
<option value="">Tous statuts</option>
|
||||||
|
<option value="active">Actifs</option>
|
||||||
|
<option value="completed">Réalisés</option>
|
||||||
|
<option value="archived">Archivés</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
v-model="filterCategory"
|
||||||
|
class="bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
|
||||||
|
>
|
||||||
|
<option value="">Toutes catégories</option>
|
||||||
|
<option v-for="c in categories" :key="c.id" :value="c.slug">{{ c.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'project-new' }"
|
||||||
|
class="flex items-center gap-1.5 bg-donut-text text-white rounded-lg px-3.5 py-2 text-sm font-medium hover:bg-black transition-colors"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||||
|
<path d="M12 5v14M5 12h14" />
|
||||||
|
</svg>
|
||||||
|
Nouveau projet
|
||||||
|
</RouterLink>
|
||||||
|
</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">Titre</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Catégorie</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Statut</th>
|
||||||
|
<th class="text-right px-4 py-3 font-medium tabular-nums">Progression</th>
|
||||||
|
<th class="text-left px-4 py-3 font-medium">Créé</th>
|
||||||
|
<th class="text-right px-4 py-3 font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="filtered.length === 0">
|
||||||
|
<td colspan="6" class="px-4 py-8 text-center text-donut-muted text-sm">
|
||||||
|
Aucun projet ne correspond aux filtres.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr
|
||||||
|
v-for="p in filtered" :key="p.id"
|
||||||
|
class="border-t border-donut-border hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'project-detail', params: { slug: p.slug } }"
|
||||||
|
class="font-medium text-donut-text hover:underline"
|
||||||
|
>{{ p.title }}</RouterLink>
|
||||||
|
<div class="text-xs text-donut-muted truncate max-w-md">{{ p.short_description }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<CategoryBadge :name="p.category.name" :color-hex="p.category.color_hex" />
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3"><StatusBadge :status="p.status" /></td>
|
||||||
|
<td class="px-4 py-3 text-right tabular-nums">{{ p.progress_percent }}%</td>
|
||||||
|
<td class="px-4 py-3 text-donut-muted">{{ formatDate(p.created_at) }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<div class="inline-flex items-center gap-2">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'admin-project-edit', params: { slug: p.slug } }"
|
||||||
|
class="text-donut-text hover:underline text-xs font-medium"
|
||||||
|
>Éditer</RouterLink>
|
||||||
|
<button
|
||||||
|
v-if="p.status !== 'archived'"
|
||||||
|
type="button"
|
||||||
|
class="text-donut-muted hover:text-donut-text text-xs"
|
||||||
|
@click="archive(p)"
|
||||||
|
>Archiver</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="text-donut-muted hover:text-donut-text text-xs"
|
||||||
|
@click="unarchive(p)"
|
||||||
|
>Réactiver</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-500 hover:text-red-700 text-xs font-medium"
|
||||||
|
@click="destroy(p)"
|
||||||
|
>Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-donut-muted">{{ filtered.length }} projet{{ filtered.length > 1 ? 's' : '' }} affiché{{ filtered.length > 1 ? 's' : '' }} sur {{ projects.length }} total.</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -5,6 +5,19 @@
|
|||||||
|
|
||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import AppLayout from '@/layouts/AppLayout.vue'
|
import AppLayout from '@/layouts/AppLayout.vue'
|
||||||
|
import { getAdminToken } from '@/api/client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard pour les routes /admin/* (hors /admin/login). Si pas de token Bearer
|
||||||
|
* en localStorage, redirige vers /admin/login (en mémorisant la destination
|
||||||
|
* via query.next pour qu'on y retourne après auth).
|
||||||
|
*/
|
||||||
|
function requireAdmin(to: { fullPath: string }) {
|
||||||
|
if (!getAdminToken()) {
|
||||||
|
return { name: 'admin-login', query: { next: to.fullPath } }
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -19,6 +32,14 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'projects/:slug', name: 'project-detail', component: () => import('@/pages/ProjectDetailPage.vue'), props: true },
|
{ path: 'projects/:slug', name: 'project-detail', component: () => import('@/pages/ProjectDetailPage.vue'), props: true },
|
||||||
{ path: 'archives', name: 'archives', component: () => import('@/pages/ArchivesPage.vue') },
|
{ path: 'archives', name: 'archives', component: () => import('@/pages/ArchivesPage.vue') },
|
||||||
{ path: 'categories', name: 'categories', component: () => import('@/pages/CategoriesPage.vue') },
|
{ path: 'categories', name: 'categories', component: () => import('@/pages/CategoriesPage.vue') },
|
||||||
|
|
||||||
|
// --- Admin dashboard (token Bearer requis pour toutes les sous-pages sauf /admin/login) ---
|
||||||
|
{ path: 'admin', name: 'admin-overview', component: () => import('@/pages/admin/AdminOverviewPage.vue'), beforeEnter: requireAdmin },
|
||||||
|
{ path: 'admin/projects', name: 'admin-projects', component: () => import('@/pages/admin/AdminProjectsPage.vue'), beforeEnter: requireAdmin },
|
||||||
|
{ path: 'admin/projects/:slug/edit', name: 'admin-project-edit', component: () => import('@/pages/admin/AdminProjectEditPage.vue'), props: true, beforeEnter: requireAdmin },
|
||||||
|
{ path: 'admin/members', name: 'admin-members', component: () => import('@/pages/admin/AdminMembersPage.vue'), beforeEnter: requireAdmin },
|
||||||
|
{ path: 'admin/categories', name: 'admin-categories', component: () => import('@/pages/admin/AdminCategoriesPage.vue'), beforeEnter: requireAdmin },
|
||||||
|
|
||||||
{ path: 'admin/login', name: 'admin-login', component: () => import('@/pages/AdminLoginPage.vue'), meta: { hideForAuthed: true } },
|
{ path: 'admin/login', name: 'admin-login', component: () => import('@/pages/AdminLoginPage.vue'), meta: { hideForAuthed: true } },
|
||||||
{ path: ':pathMatch(.*)*', name: 'not-found', component: () => import('@/pages/NotFoundPage.vue') },
|
{ path: ':pathMatch(.*)*', name: 'not-found', component: () => import('@/pages/NotFoundPage.vue') },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -157,6 +157,22 @@ export interface Resource {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectImage {
|
||||||
|
id: number
|
||||||
|
project_id: number
|
||||||
|
url: string
|
||||||
|
caption: string | null
|
||||||
|
sort_order: number
|
||||||
|
/** Présent si upload disque, null si URL externe. */
|
||||||
|
storage_path: string | null
|
||||||
|
mime_type: string | null
|
||||||
|
size_bytes: number | null
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
export type ActivityVerb =
|
export type ActivityVerb =
|
||||||
| 'joined'
|
| 'joined'
|
||||||
| 'left'
|
| 'left'
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ export default defineConfig({
|
|||||||
target: 'http://donut-gestprojet.test',
|
target: 'http://donut-gestprojet.test',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
// Images uploadées (galerie projet) servies par Apache/Caddy en static.
|
||||||
|
'/uploads': {
|
||||||
|
target: 'http://donut-gestprojet.test',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Migration 003 — galerie d'images par projet.
|
||||||
|
--
|
||||||
|
-- Sous Windows + Git Bash, appliquer avec :
|
||||||
|
-- mysql --default-character-set=utf8mb4 -uinfolab -p infolab < migrations/003_add_project_images.sql
|
||||||
|
-- En prod, c'est apply.sh dans le conteneur (charset utf8mb4 OK par défaut).
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_images (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
project_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
url VARCHAR(1024) NOT NULL,
|
||||||
|
caption VARCHAR(255) NULL,
|
||||||
|
sort_order INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
-- Métadonnées remplies par le backend quand l'image est uploadée
|
||||||
|
-- depuis le disque (sinon URL externe → ces colonnes restent NULL).
|
||||||
|
storage_path VARCHAR(512) NULL, -- chemin relatif au docroot Apache, ex: uploads/projects/nos-ecoles/abc123.jpg
|
||||||
|
mime_type VARCHAR(64) NULL,
|
||||||
|
size_bytes INT UNSIGNED NULL,
|
||||||
|
width INT UNSIGNED NULL,
|
||||||
|
height INT UNSIGNED NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_image_project (project_id, sort_order),
|
||||||
|
CONSTRAINT fk_image_project FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -21,6 +21,7 @@ use Infolab\Controllers\DiscussionsController;
|
|||||||
use Infolab\Controllers\ResourcesController;
|
use Infolab\Controllers\ResourcesController;
|
||||||
use Infolab\Controllers\ActivityController;
|
use Infolab\Controllers\ActivityController;
|
||||||
use Infolab\Controllers\StatsController;
|
use Infolab\Controllers\StatsController;
|
||||||
|
use Infolab\Controllers\ImagesController;
|
||||||
|
|
||||||
// CORS systématique, y compris sur la preflight OPTIONS.
|
// CORS systématique, y compris sur la preflight OPTIONS.
|
||||||
Response::sendCorsHeaders();
|
Response::sendCorsHeaders();
|
||||||
@@ -51,6 +52,7 @@ $router->get('/api/projects/:slug/roadmap', [RoadmapController::class,
|
|||||||
$router->get('/api/projects/:slug/members', [ProjectMembersController::class, 'index']);
|
$router->get('/api/projects/:slug/members', [ProjectMembersController::class, 'index']);
|
||||||
$router->get('/api/projects/:slug/discussions', [DiscussionsController::class, 'index']);
|
$router->get('/api/projects/:slug/discussions', [DiscussionsController::class, 'index']);
|
||||||
$router->get('/api/projects/:slug/resources', [ResourcesController::class, 'index']);
|
$router->get('/api/projects/:slug/resources', [ResourcesController::class, 'index']);
|
||||||
|
$router->get('/api/projects/:slug/images', [ImagesController::class, 'index']);
|
||||||
|
|
||||||
$router->get('/api/activity', [ActivityController::class, 'index']);
|
$router->get('/api/activity', [ActivityController::class, 'index']);
|
||||||
$router->get('/api/stats', [StatsController::class, 'index']);
|
$router->get('/api/stats', [StatsController::class, 'index']);
|
||||||
@@ -86,6 +88,10 @@ $router->post ('/api/projects/:slug/resources', [ResourcesControll
|
|||||||
$router->patch ('/api/projects/:slug/resources/:id', [ResourcesController::class, 'update']);
|
$router->patch ('/api/projects/:slug/resources/:id', [ResourcesController::class, 'update']);
|
||||||
$router->delete('/api/projects/:slug/resources/:id', [ResourcesController::class, 'destroy']);
|
$router->delete('/api/projects/:slug/resources/:id', [ResourcesController::class, 'destroy']);
|
||||||
|
|
||||||
|
$router->post ('/api/projects/:slug/images', [ImagesController::class, 'store']);
|
||||||
|
$router->patch ('/api/projects/:slug/images/:id', [ImagesController::class, 'update']);
|
||||||
|
$router->delete('/api/projects/:slug/images/:id', [ImagesController::class, 'destroy']);
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// Dispatch + handler global d'exceptions.
|
// Dispatch + handler global d'exceptions.
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Infolab\Controllers;
|
||||||
|
|
||||||
|
use Infolab\Database;
|
||||||
|
use Infolab\Request;
|
||||||
|
use Infolab\Response;
|
||||||
|
use Infolab\Validator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Galerie d'images par projet.
|
||||||
|
*
|
||||||
|
* Deux modes de création :
|
||||||
|
* - URL externe (Content-Type: application/json + body {"url":"https://..."})
|
||||||
|
* - Upload disque (Content-Type: multipart/form-data + file=<binaire>)
|
||||||
|
*
|
||||||
|
* Les uploads vont dans `public/uploads/projects/:slug/<hash>.<ext>`, accessibles
|
||||||
|
* en direct via Apache/Caddy. Le DELETE supprime aussi le fichier sur disque.
|
||||||
|
*/
|
||||||
|
final class ImagesController
|
||||||
|
{
|
||||||
|
private const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||||
|
private const ALLOWED_MIMES = [
|
||||||
|
'image/jpeg' => 'jpg',
|
||||||
|
'image/png' => 'png',
|
||||||
|
'image/webp' => 'webp',
|
||||||
|
'image/gif' => 'gif',
|
||||||
|
];
|
||||||
|
private const KINDS = ['link', 'document', 'dataset', 'video', 'other'];
|
||||||
|
|
||||||
|
public function index(Request $req, array $params): void
|
||||||
|
{
|
||||||
|
$p = ProjectsController::findOr404($params['slug']);
|
||||||
|
$rows = Database::fetchAll(
|
||||||
|
'SELECT id, project_id, url, caption, sort_order, storage_path, mime_type, size_bytes, width, height, created_at, updated_at
|
||||||
|
FROM project_images WHERE project_id = :pid ORDER BY sort_order ASC, id ASC',
|
||||||
|
[':pid' => (int) $p['id']],
|
||||||
|
);
|
||||||
|
Response::json(['data' => array_map([self::class, 'present'], $rows)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $req, array $params): void
|
||||||
|
{
|
||||||
|
$p = ProjectsController::findOr404($params['slug']);
|
||||||
|
|
||||||
|
// Branche A : upload disque (multipart/form-data)
|
||||||
|
if ($req->isMultipart() && $req->file('file') !== null) {
|
||||||
|
$this->storeUpload($req, $p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branche B : URL externe (JSON body)
|
||||||
|
$this->storeUrl($req, $p);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $req, array $params): void
|
||||||
|
{
|
||||||
|
$p = ProjectsController::findOr404($params['slug']);
|
||||||
|
$img = self::findOr404((int) $params['id'], (int) $p['id']);
|
||||||
|
|
||||||
|
$body = $req->json();
|
||||||
|
$v = new Validator($body);
|
||||||
|
$v->string('caption', 255);
|
||||||
|
$v->integer('sort_order', 0);
|
||||||
|
$v->check();
|
||||||
|
|
||||||
|
$update = [];
|
||||||
|
foreach (['caption', 'sort_order'] as $f) {
|
||||||
|
if (array_key_exists($f, $body)) {
|
||||||
|
$update[$f] = $body[$f];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($update['sort_order'])) {
|
||||||
|
$update['sort_order'] = (int) $update['sort_order'];
|
||||||
|
}
|
||||||
|
if ($update !== []) {
|
||||||
|
Database::update('project_images', $update, 'id = :id', [':id' => (int) $img['id']]);
|
||||||
|
}
|
||||||
|
$fresh = Database::fetchOne('SELECT * FROM project_images WHERE id = :id', [':id' => (int) $img['id']]);
|
||||||
|
Response::json(self::present($fresh));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $req, array $params): void
|
||||||
|
{
|
||||||
|
$p = ProjectsController::findOr404($params['slug']);
|
||||||
|
$img = self::findOr404((int) $params['id'], (int) $p['id']);
|
||||||
|
|
||||||
|
// Supprime la ligne en DB d'abord, puis le fichier disque (best-effort).
|
||||||
|
Database::delete('project_images', 'id = :id', [':id' => (int) $img['id']]);
|
||||||
|
if (!empty($img['storage_path'])) {
|
||||||
|
$absolute = $this->docroot() . '/' . ltrim((string) $img['storage_path'], '/');
|
||||||
|
if (is_file($absolute)) {
|
||||||
|
@unlink($absolute);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Response::noContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers privés
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $project */
|
||||||
|
private function storeUrl(Request $req, array $project): void
|
||||||
|
{
|
||||||
|
$body = $req->json();
|
||||||
|
$v = new Validator($body);
|
||||||
|
$v->required('url')->url('url', 1024);
|
||||||
|
$v->string('caption', 255);
|
||||||
|
$v->integer('sort_order', 0);
|
||||||
|
$v->check();
|
||||||
|
|
||||||
|
$sortOrder = isset($body['sort_order'])
|
||||||
|
? (int) $body['sort_order']
|
||||||
|
: self::nextSortOrder((int) $project['id']);
|
||||||
|
|
||||||
|
$id = Database::insert('project_images', [
|
||||||
|
'project_id' => (int) $project['id'],
|
||||||
|
'url' => (string) $body['url'],
|
||||||
|
'caption' => $body['caption'] ?? null,
|
||||||
|
'sort_order' => $sortOrder,
|
||||||
|
]);
|
||||||
|
$row = Database::fetchOne('SELECT * FROM project_images WHERE id = :id', [':id' => $id]);
|
||||||
|
Response::json(self::present($row), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $project */
|
||||||
|
private function storeUpload(Request $req, array $project): void
|
||||||
|
{
|
||||||
|
$file = $req->file('file');
|
||||||
|
if ($file === null) {
|
||||||
|
throw new \ApiException(400, ['error' => 'bad_request', 'message' => 'no file uploaded']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1) Erreur PHP d'upload ?
|
||||||
|
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
$msg = match ($file['error']) {
|
||||||
|
UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => 'file too large (PHP upload limit)',
|
||||||
|
UPLOAD_ERR_PARTIAL => 'upload incomplete',
|
||||||
|
UPLOAD_ERR_NO_TMP_DIR => 'server tmp dir missing',
|
||||||
|
UPLOAD_ERR_CANT_WRITE => 'server cannot write to disk',
|
||||||
|
UPLOAD_ERR_EXTENSION => 'PHP extension blocked the upload',
|
||||||
|
default => 'upload failed',
|
||||||
|
};
|
||||||
|
throw new \ApiException(400, ['error' => 'bad_request', 'message' => $msg]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Taille
|
||||||
|
if ($file['size'] > self::MAX_BYTES) {
|
||||||
|
throw new \ApiException(422, [
|
||||||
|
'error' => 'validation_failed',
|
||||||
|
'fields' => ['file' => 'too_large'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) MIME type RÉEL (pas l'header fourni par le client, qui est falsifiable)
|
||||||
|
$tmp = $file['tmp_name'];
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$detected = (string) $finfo->file($tmp);
|
||||||
|
if (!isset(self::ALLOWED_MIMES[$detected])) {
|
||||||
|
throw new \ApiException(422, [
|
||||||
|
'error' => 'validation_failed',
|
||||||
|
'fields' => ['file' => 'unsupported_mime'],
|
||||||
|
'message' => sprintf('MIME %s non autorisé (autorisés : %s)', $detected, implode(', ', array_keys(self::ALLOWED_MIMES))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$ext = self::ALLOWED_MIMES[$detected];
|
||||||
|
|
||||||
|
// 4) Dimensions image (peut échouer sur GIF/webp anciens, best-effort)
|
||||||
|
$width = null;
|
||||||
|
$height = null;
|
||||||
|
$info = @getimagesize($tmp);
|
||||||
|
if (is_array($info)) {
|
||||||
|
$width = (int) $info[0];
|
||||||
|
$height = (int) $info[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Construit le chemin cible : public/uploads/projects/:slug/<random>.<ext>
|
||||||
|
$slug = preg_replace('/[^a-z0-9\-]/', '', strtolower((string) $project['slug'])) ?? 'project';
|
||||||
|
$dirRelative = "uploads/projects/$slug";
|
||||||
|
$dirAbsolute = $this->docroot() . '/' . $dirRelative;
|
||||||
|
if (!is_dir($dirAbsolute) && !@mkdir($dirAbsolute, 0755, true) && !is_dir($dirAbsolute)) {
|
||||||
|
error_log("[Images] cannot create $dirAbsolute");
|
||||||
|
throw new \ApiException(500, ['error' => 'internal_server_error']);
|
||||||
|
}
|
||||||
|
$basename = bin2hex(random_bytes(12)) . '.' . $ext;
|
||||||
|
$pathRelative = "$dirRelative/$basename";
|
||||||
|
$pathAbsolute = "$dirAbsolute/$basename";
|
||||||
|
|
||||||
|
// 6) Déplace le fichier upload
|
||||||
|
if (!move_uploaded_file($tmp, $pathAbsolute)) {
|
||||||
|
error_log("[Images] move_uploaded_file failed -> $pathAbsolute");
|
||||||
|
throw new \ApiException(500, ['error' => 'internal_server_error']);
|
||||||
|
}
|
||||||
|
// chmod défensif pour Apache.
|
||||||
|
@chmod($pathAbsolute, 0644);
|
||||||
|
|
||||||
|
// 7) Lit la légende & sort_order optionnels passés en multipart
|
||||||
|
$caption = $req->formField('caption');
|
||||||
|
$sortOrderRaw = $req->formField('sort_order');
|
||||||
|
$sortOrder = $sortOrderRaw !== null && $sortOrderRaw !== ''
|
||||||
|
? (int) $sortOrderRaw
|
||||||
|
: self::nextSortOrder((int) $project['id']);
|
||||||
|
|
||||||
|
$publicUrl = '/' . $pathRelative;
|
||||||
|
|
||||||
|
$id = Database::insert('project_images', [
|
||||||
|
'project_id' => (int) $project['id'],
|
||||||
|
'url' => $publicUrl,
|
||||||
|
'caption' => $caption,
|
||||||
|
'sort_order' => $sortOrder,
|
||||||
|
'storage_path' => $pathRelative,
|
||||||
|
'mime_type' => $detected,
|
||||||
|
'size_bytes' => (int) $file['size'],
|
||||||
|
'width' => $width,
|
||||||
|
'height' => $height,
|
||||||
|
]);
|
||||||
|
$row = Database::fetchOne('SELECT * FROM project_images WHERE id = :id', [':id' => $id]);
|
||||||
|
Response::json(self::present($row), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function findOr404(int $id, int $projectId): array
|
||||||
|
{
|
||||||
|
$row = Database::fetchOne(
|
||||||
|
'SELECT * FROM project_images WHERE id = :id AND project_id = :pid',
|
||||||
|
[':id' => $id, ':pid' => $projectId],
|
||||||
|
);
|
||||||
|
if ($row === null) {
|
||||||
|
throw new \ApiException(404, ['error' => 'not_found']);
|
||||||
|
}
|
||||||
|
return $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function nextSortOrder(int $projectId): int
|
||||||
|
{
|
||||||
|
return (int) Database::fetchValue(
|
||||||
|
'SELECT COALESCE(MAX(sort_order), 0) + 10 FROM project_images WHERE project_id = :pid',
|
||||||
|
[':pid' => $projectId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Racine document du serveur web (Apache DocumentRoot = .../public).
|
||||||
|
* Le contrôleur tourne depuis public/index.php donc __DIR__ . '/../..' = public/
|
||||||
|
* Sauf qu'on est dans src/Controllers/ → racine = src/../../public = public.
|
||||||
|
*/
|
||||||
|
private function docroot(): string
|
||||||
|
{
|
||||||
|
return dirname(__DIR__, 2) . '/public';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,mixed> $row */
|
||||||
|
private static function present(array $row): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'project_id' => (int) $row['project_id'],
|
||||||
|
'url' => $row['url'],
|
||||||
|
'caption' => $row['caption'],
|
||||||
|
'sort_order' => (int) $row['sort_order'],
|
||||||
|
'storage_path' => $row['storage_path'],
|
||||||
|
'mime_type' => $row['mime_type'],
|
||||||
|
'size_bytes' => $row['size_bytes'] !== null ? (int) $row['size_bytes'] : null,
|
||||||
|
'width' => $row['width'] !== null ? (int) $row['width'] : null,
|
||||||
|
'height' => $row['height'] !== null ? (int) $row['height'] : null,
|
||||||
|
'created_at' => $row['created_at'],
|
||||||
|
'updated_at' => $row['updated_at'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,4 +128,40 @@ final class Request
|
|||||||
{
|
{
|
||||||
return $this->jsonBody[$key] ?? $default;
|
return $this->jsonBody[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// multipart/form-data — utilisé par les endpoints d'upload (galerie images).
|
||||||
|
// PHP peuple lui-même $_POST et $_FILES quand le Content-Type est multipart,
|
||||||
|
// on se contente d'exposer ça proprement.
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function isMultipart(): bool
|
||||||
|
{
|
||||||
|
$ct = $this->header('content-type', '') ?? '';
|
||||||
|
return str_contains(strtolower($ct), 'multipart/form-data');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un champ du form (multipart ou x-www-form-urlencoded).
|
||||||
|
*/
|
||||||
|
public function formField(string $name, ?string $default = null): ?string
|
||||||
|
{
|
||||||
|
$v = $_POST[$name] ?? null;
|
||||||
|
return $v === null ? $default : (string) $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un fichier uploadé. Retourne null si absent ou si erreur d'upload.
|
||||||
|
*
|
||||||
|
* @return array{name:string,type:string,tmp_name:string,error:int,size:int}|null
|
||||||
|
*/
|
||||||
|
public function file(string $name): ?array
|
||||||
|
{
|
||||||
|
$f = $_FILES[$name] ?? null;
|
||||||
|
if (!is_array($f) || !isset($f['error']) || $f['error'] === UPLOAD_ERR_NO_FILE) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user