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

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

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

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

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

==== Dashboard /admin ====

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 23:07:59 +02:00
parent 034cb8ba99
commit adb2bd3fc1
20 changed files with 1671 additions and 12 deletions
+6 -3
View File
@@ -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}
+42
View File
@@ -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
}
+41
View File
@@ -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>
+27
View File
@@ -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 -->
+195
View File
@@ -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>
+137
View File
@@ -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>
+16 -7
View File
@@ -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()
+14 -2
View File
@@ -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>
+21
View File
@@ -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') },
], ],
+16
View File
@@ -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'
+5
View File
@@ -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,
},
}, },
}, },
}) })
+27
View File
@@ -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;
+6
View File
@@ -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.
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
+271
View File
@@ -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'],
];
}
}
+36
View File
@@ -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;
}
}