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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Sous-nav du dashboard admin : 4 onglets visuels en haut de chaque page /admin/*.
|
||||
* On reste dans AppLayout (sidebar principale visible), c'est seulement une
|
||||
* convention de styling pour signaler qu'on est en "mode admin".
|
||||
*/
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
const items = [
|
||||
{ to: { name: 'admin-overview' }, label: "Vue d'ensemble" },
|
||||
{ to: { name: 'admin-projects' }, label: 'Projets' },
|
||||
{ to: { name: 'admin-members' }, label: 'Membres' },
|
||||
{ to: { name: 'admin-categories' }, label: 'Catégories' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border-b border-donut-border">
|
||||
<nav class="flex items-center gap-1 -mb-px">
|
||||
<RouterLink
|
||||
v-for="it in items" :key="it.to.name"
|
||||
:to="it.to"
|
||||
custom
|
||||
v-slot="{ href, navigate, isActive }"
|
||||
>
|
||||
<a
|
||||
:href="href"
|
||||
@click="navigate"
|
||||
:class="[
|
||||
'inline-flex items-center px-4 py-2.5 text-sm font-medium border-b-2 transition-colors',
|
||||
isActive
|
||||
? 'border-donut-text text-donut-text'
|
||||
: 'border-transparent text-donut-muted hover:text-donut-text',
|
||||
]"
|
||||
>
|
||||
{{ it.label }}
|
||||
</a>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -121,6 +121,33 @@ const navItems: NavLink[] = [
|
||||
{{ item.label }}
|
||||
</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 -->
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Galerie d'images d'un projet.
|
||||
*
|
||||
* - Grille de miniatures (lazy-load).
|
||||
* - Click thumb -> overlay plein écran avec navigation gauche/droite,
|
||||
* fermeture par clic hors image / Échap.
|
||||
* - Mode admin (token Bearer présent) : bouton Supprimer par image + uploader.
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import type { ProjectImage, Wrapped } from '@/types/api'
|
||||
import ImageUploader from './ImageUploader.vue'
|
||||
import LoadingState from './LoadingState.vue'
|
||||
|
||||
const props = defineProps<{ projectSlug: string }>()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const images = ref<ProjectImage[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const lightboxIndex = ref<number | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const r = await api.get<Wrapped<ProjectImage>>(`/api/projects/${encodeURIComponent(props.projectSlug)}/images`)
|
||||
images.value = r.data
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
watch(() => props.projectSlug, load)
|
||||
|
||||
function openLightbox(idx: number) {
|
||||
lightboxIndex.value = idx
|
||||
}
|
||||
function closeLightbox() {
|
||||
lightboxIndex.value = null
|
||||
}
|
||||
function next() {
|
||||
if (lightboxIndex.value === null) return
|
||||
lightboxIndex.value = (lightboxIndex.value + 1) % images.value.length
|
||||
}
|
||||
function prev() {
|
||||
if (lightboxIndex.value === null) return
|
||||
lightboxIndex.value = (lightboxIndex.value - 1 + images.value.length) % images.value.length
|
||||
}
|
||||
|
||||
// Navigation clavier dans la lightbox.
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (lightboxIndex.value === null) return
|
||||
if (e.key === 'Escape') closeLightbox()
|
||||
if (e.key === 'ArrowRight') next()
|
||||
if (e.key === 'ArrowLeft') prev()
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onKey))
|
||||
onUnmounted(() => window.removeEventListener('keydown', onKey))
|
||||
|
||||
async function remove(img: ProjectImage) {
|
||||
if (!confirm(`Supprimer l'image « ${img.caption ?? 'sans légende'} » ?`)) return
|
||||
try {
|
||||
await api.delete(`/api/projects/${encodeURIComponent(props.projectSlug)}/images/${img.id}`)
|
||||
images.value = images.value.filter(i => i.id !== img.id)
|
||||
if (lightboxIndex.value !== null && lightboxIndex.value >= images.value.length) {
|
||||
lightboxIndex.value = images.value.length === 0 ? null : images.value.length - 1
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('image delete failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function onUploaded(newImg: ProjectImage) {
|
||||
images.value.push(newImg)
|
||||
}
|
||||
|
||||
const currentImage = computed(() =>
|
||||
lightboxIndex.value === null ? null : images.value[lightboxIndex.value],
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LoadingState v-if="loading" />
|
||||
<div v-else-if="error" class="text-sm text-red-700">{{ error }}</div>
|
||||
|
||||
<div v-else-if="images.length === 0" class="text-sm text-donut-muted">
|
||||
Pas encore d'image.
|
||||
<span v-if="!isAuthenticated">Connecte-toi en admin pour en ajouter.</span>
|
||||
</div>
|
||||
|
||||
<!-- Grille -->
|
||||
<div v-else class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div
|
||||
v-for="(img, idx) in images" :key="img.id"
|
||||
class="group relative aspect-square rounded-lg overflow-hidden bg-gray-100 cursor-pointer"
|
||||
@click="openLightbox(idx)"
|
||||
>
|
||||
<img
|
||||
:src="img.url"
|
||||
:alt="img.caption ?? `Image ${idx + 1} du projet`"
|
||||
loading="lazy"
|
||||
class="absolute inset-0 w-full h-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
<div
|
||||
v-if="img.caption"
|
||||
class="absolute inset-x-0 bottom-0 px-2.5 py-1.5 bg-gradient-to-t from-black/70 to-transparent text-white text-xs"
|
||||
>
|
||||
{{ img.caption }}
|
||||
</div>
|
||||
<button
|
||||
v-if="isAuthenticated"
|
||||
type="button"
|
||||
class="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity bg-red-500/90 hover:bg-red-600 text-white rounded-md p-1.5"
|
||||
title="Supprimer"
|
||||
@click.stop="remove(img)"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M18 6 6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uploader (admin uniquement) -->
|
||||
<div v-if="isAuthenticated" class="mt-4 pt-4 border-t border-donut-border">
|
||||
<ImageUploader :project-slug="projectSlug" @uploaded="onUploaded" />
|
||||
</div>
|
||||
|
||||
<!-- Lightbox -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="currentImage"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 px-4"
|
||||
@click.self="closeLightbox"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 text-white/80 hover:text-white"
|
||||
@click="closeLightbox"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="w-7 h-7" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M18 6 6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="images.length > 1"
|
||||
type="button"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2"
|
||||
@click="prev"
|
||||
aria-label="Image précédente"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="w-8 h-8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15 18-6-6 6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="max-w-5xl max-h-full flex flex-col items-center">
|
||||
<img
|
||||
:src="currentImage.url"
|
||||
:alt="currentImage.caption ?? 'Image projet'"
|
||||
class="max-h-[80vh] max-w-full object-contain rounded-lg"
|
||||
/>
|
||||
<div v-if="currentImage.caption" class="mt-3 text-white/90 text-sm">
|
||||
{{ currentImage.caption }}
|
||||
</div>
|
||||
<div class="mt-1 text-white/40 text-xs">
|
||||
{{ (lightboxIndex ?? 0) + 1 }} / {{ images.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="images.length > 1"
|
||||
type="button"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-white/80 hover:text-white p-2"
|
||||
@click="next"
|
||||
aria-label="Image suivante"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="w-8 h-8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Uploader d'images : drag-and-drop OU file picker, + champ légende optionnel.
|
||||
* Accepte plusieurs fichiers, les upload en série pour conserver l'ordre.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { uploadFile, ApiError } from '@/api/client'
|
||||
import type { ProjectImage } from '@/types/api'
|
||||
|
||||
const props = defineProps<{ projectSlug: string }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'uploaded', img: ProjectImage): void
|
||||
}>()
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const dragOver = ref(false)
|
||||
const caption = ref('')
|
||||
const uploading = ref(false)
|
||||
const queueSize = ref(0)
|
||||
const queueDone = ref(0)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const ACCEPT = 'image/jpeg,image/png,image/webp,image/gif'
|
||||
const MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
async function uploadOne(file: File): Promise<void> {
|
||||
if (file.size > MAX_BYTES) {
|
||||
error.value = `« ${file.name } » fait ${(file.size / 1024 / 1024).toFixed(1)} Mo (max 5 Mo)`
|
||||
return
|
||||
}
|
||||
try {
|
||||
const img = await uploadFile<ProjectImage>(
|
||||
`/api/projects/${encodeURIComponent(props.projectSlug)}/images`,
|
||||
file,
|
||||
{ caption: caption.value.trim() || undefined },
|
||||
)
|
||||
emit('uploaded', img)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.status === 422 && e.fields.file === 'too_large') {
|
||||
error.value = `« ${file.name} » est trop volumineux (max 5 Mo)`
|
||||
} else if (e.status === 422 && e.fields.file === 'unsupported_mime') {
|
||||
error.value = `« ${file.name} » : type non supporté (JPG/PNG/WebP/GIF uniquement)`
|
||||
} else if (e.status === 401) {
|
||||
error.value = 'Tu dois être admin pour uploader.'
|
||||
} else {
|
||||
error.value = e.payload.message ?? `Échec de l'upload de « ${file.name} »`
|
||||
}
|
||||
} else {
|
||||
error.value = `Erreur réseau pendant l'upload de « ${file.name} »`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processFiles(files: FileList | File[]) {
|
||||
error.value = null
|
||||
const list = Array.from(files).filter(f => f.type.startsWith('image/'))
|
||||
if (list.length === 0) {
|
||||
error.value = "Aucun fichier image dans la sélection."
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
queueSize.value = list.length
|
||||
queueDone.value = 0
|
||||
for (const f of list) {
|
||||
await uploadOne(f)
|
||||
queueDone.value++
|
||||
}
|
||||
uploading.value = false
|
||||
caption.value = '' // reset après une session d'upload
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
function onFileChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files) processFiles(target.files)
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
dragOver.value = false
|
||||
if (e.dataTransfer?.files) processFiles(e.dataTransfer.files)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="rounded-lg border-2 border-dashed transition-colors p-5 text-center cursor-pointer"
|
||||
:class="dragOver ? 'border-donut-text bg-gray-50' : 'border-donut-border hover:border-gray-400 hover:bg-gray-50'"
|
||||
@click="fileInput?.click()"
|
||||
@dragenter.prevent="dragOver = true"
|
||||
@dragover.prevent="dragOver = true"
|
||||
@dragleave="dragOver = false"
|
||||
@drop="onDrop"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="w-7 h-7 mx-auto text-donut-muted" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="9" cy="9" r="2"/>
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>
|
||||
</svg>
|
||||
<div class="mt-2 text-sm text-donut-text">
|
||||
<span class="font-medium">Déposer des images ici</span> ou cliquer pour parcourir
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-donut-muted">JPG / PNG / WebP / GIF — 5 Mo max</div>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
:accept="ACCEPT"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
v-model="caption"
|
||||
type="text"
|
||||
placeholder="Légende commune à cette série (optionnel)"
|
||||
class="flex-1 bg-donut-surface border border-donut-border rounded-md px-2.5 py-1.5 text-xs outline-none focus:border-donut-text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="uploading" class="mt-2 flex items-center gap-2 text-xs text-donut-muted">
|
||||
<svg viewBox="0 0 24 24" class="w-3.5 h-3.5 animate-spin" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Upload {{ queueDone + 1 }}/{{ queueSize }}…
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mt-2 rounded-md border border-red-200 bg-red-50 text-red-700 px-2.5 py-1.5 text-xs">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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') },
|
||||
],
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user