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
+42
View File
@@ -116,3 +116,45 @@ export const api = {
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),
}
/**
* 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 }}
</a>
</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>
<!-- 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">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { api, ApiError } from '@/api/client'
import { useAuth } from '@/composables/useAuth'
const router = useRouter()
const route = useRoute()
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 submitting = ref(false)
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.
login(t)
try {
// Le plus léger : POST sur une route protégée avec un body invalide volontairement
// pour récupérer 422 (auth OK) ou 401 (auth KO) sans créer de ressource.
// 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.
// L'API n'a pas de route "whoami". Trick : POST /api/members avec body vide
// -> renvoie 422 si l'auth est OK (validation_failed), 401 si le token est faux.
await api.post('/api/members', {})
router.push({ name: 'dashboard' })
router.push(postLoginTarget())
} catch (e) {
if (e instanceof ApiError) {
if (e.status === 401) {
@@ -32,7 +41,7 @@ async function submit() {
error.value = 'Token invalide.'
} else {
// 422 (validation_failed) signifie que l'auth est OK, on continue.
router.push({ name: 'dashboard' })
router.push(postLoginTarget())
}
} else {
logout()
+14 -2
View File
@@ -12,6 +12,7 @@ import Avatar from '@/components/Avatar.vue'
import LoadingState from '@/components/LoadingState.vue'
import ErrorState from '@/components/ErrorState.vue'
import JoinProjectModal from '@/components/JoinProjectModal.vue'
import ImageGallery from '@/components/ImageGallery.vue'
import { formatDate, formatMonthYear, formatRelative } from '@/utils/formatDate'
const props = defineProps<{ slug: string }>()
@@ -24,7 +25,7 @@ const loading = ref(true)
const error = ref<string | null>(null)
// Tabs
type Tab = 'roadmap' | 'discussions' | 'resources'
type Tab = 'roadmap' | 'discussions' | 'resources' | 'images'
const activeTab = ref<Tab>('roadmap')
// 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">
<nav class="flex items-center gap-6 -mb-px" role="tablist">
<button
v-for="tab in (['roadmap', 'discussions', 'resources'] as const)"
v-for="tab in (['roadmap', 'images', 'discussions', 'resources'] as const)"
:key="tab"
type="button"
role="tab"
@@ -321,6 +322,12 @@ const ROLE_LABEL = { admin: 'Admin', contributor: 'Contributor', observer: 'Obse
</svg>
Feuille de route
</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">
<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"/>
@@ -415,6 +422,11 @@ const ROLE_LABEL = { admin: 'Admin', contributor: 'Contributor', observer: 'Obse
</form>
</div>
<!-- Tab : Images -->
<div v-else-if="activeTab === 'images'">
<ImageGallery :project-slug="project.slug" />
</div>
<!-- Tab : Discussions -->
<div v-else-if="activeTab === 'discussions'" class="space-y-4">
<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 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[] = [
{
@@ -19,6 +32,14 @@ const routes: RouteRecordRaw[] = [
{ path: 'projects/:slug', name: 'project-detail', component: () => import('@/pages/ProjectDetailPage.vue'), props: true },
{ path: 'archives', name: 'archives', component: () => import('@/pages/ArchivesPage.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: ':pathMatch(.*)*', name: 'not-found', component: () => import('@/pages/NotFoundPage.vue') },
],
+16
View File
@@ -157,6 +157,22 @@ export interface Resource {
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 =
| 'joined'
| 'left'
+5
View File
@@ -20,6 +20,11 @@ export default defineConfig({
target: 'http://donut-gestprojet.test',
changeOrigin: true,
},
// Images uploadées (galerie projet) servies par Apache/Caddy en static.
'/uploads': {
target: 'http://donut-gestprojet.test',
changeOrigin: true,
},
},
},
})