feat(admin): création de membre via dashboard

Nouvelle page /admin/members/new, jumeau de l'écran d'édition :
  - Form complet : display_name, slug (auto), initials (auto), kind, bio,
    email, website, avatar URL
  - Avatar preview live (initiales / URL d'avatar mis à jour à la saisie)
  - Slug auto-généré depuis display_name (NFD + lowercase + tirets)
    tant que l'utilisateur n'a pas édité manuellement (flag slugDirty)
  - Initiales auto-suggérées (1re lettre de chaque mot, max 3, upper),
    flag initialsDirty distinct -> tu peux laisser le slug auto mais
    forcer des initiales manuelles ou vice-versa
  - Body construit en omettant les champs vides -> l'API génère ses propres
    initiales si vides, slug auto-uniqueifie sur collision (-2/-3...)
  - 201 -> redirige vers /admin/members/:slug/edit pour voir le résultat
  - 422/409/401 gérés comme les autres écrans admin

UI
  - AdminMembersPage : CTA "Nouveau membre" en haut à droite (à côté de
    la recherche, justify-between sur le wrap parent)
  - Router : route nommée admin-member-new avec guard admin

Backend
  - Aucune nouvelle route : POST /api/members existait déjà
  - Le contrôleur valide display_name (required), kind enum, email format,
    URLs ; auto-génère initiales si non fournies (1res lettres de chaque mot)
  - Slug auto avec suffixe en cas de collision (cf. Slugger::unique)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 13:47:42 +02:00
parent dcf99f011a
commit 97c68531ed
3 changed files with 254 additions and 1 deletions
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { api, ApiError } from '@/api/client'
import { useAuth } from '@/composables/useAuth'
import type { Member } from '@/types/api'
import AdminSubNav from '@/components/AdminSubNav.vue'
import Avatar from '@/components/Avatar.vue'
const router = useRouter()
const { isAuthenticated } = useAuth()
const form = ref({
display_name: '',
slug: '',
initials: '',
kind: 'person' as Member['kind'],
bio: '',
avatar_url: '',
website_url: '',
contact_email: '',
})
const submitting = ref(false)
const fieldErrors = ref<Record<string, string>>({})
const generalError = ref<string | null>(null)
// Slug + initiales auto-suggérés depuis display_name tant que l'utilisateur
// n'a pas touché manuellement à ces champs. Dès qu'il édite l'un, on coupe
// l'auto sur ce champ-là seulement.
const slugDirty = ref(false)
const initialsDirty = ref(false)
function slugify(s: string): string {
return s
.normalize('NFD').replace(/[̀-ͯ]/g, '') // strip diacritics
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 96)
}
function autoInitials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 3)
.map(w => w[0]?.toUpperCase() ?? '')
.join('')
.slice(0, 8)
}
watch(() => form.value.display_name, (n) => {
if (!slugDirty.value) form.value.slug = slugify(n)
if (!initialsDirty.value) form.value.initials = autoInitials(n)
})
async function submit() {
fieldErrors.value = {}
generalError.value = null
if (!isAuthenticated.value) {
generalError.value = "Tu dois être connecté en admin."
return
}
if (!form.value.display_name.trim()) {
fieldErrors.value.display_name = 'required'
return
}
submitting.value = true
try {
const body: Record<string, unknown> = {
display_name: form.value.display_name.trim(),
kind: form.value.kind,
}
if (form.value.slug.trim()) body.slug = form.value.slug.trim()
if (form.value.initials.trim()) body.initials = form.value.initials.trim()
if (form.value.bio.trim()) body.bio = form.value.bio.trim()
if (form.value.avatar_url.trim()) body.avatar_url = form.value.avatar_url.trim()
if (form.value.website_url.trim()) body.website_url = form.value.website_url.trim()
if (form.value.contact_email.trim()) body.contact_email = form.value.contact_email.trim()
const created = await api.post<Member>('/api/members', body)
// Redirige vers l'écran d'édition de la fiche fraîche pour que l'admin voie
// tout de suite l'avatar/initiales et puisse compléter.
router.push({ name: 'admin-member-edit', params: { slug: created.slug } })
} 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 if (e.status === 409) generalError.value = e.payload.message ?? 'Conflit (slug déjà pris ?)'
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">Création d'un nouveau membre dans l'annuaire.</p>
</header>
<AdminSubNav />
<div class="mt-6 space-y-4">
<RouterLink :to="{ name: 'admin-members' }" 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 à l'annuaire
</RouterLink>
<form @submit.prevent="submit" class="rounded-xl border border-donut-border bg-donut-surface p-6 space-y-5">
<!-- Preview avatar live -->
<header class="flex items-center gap-3 pb-4 border-b border-donut-border">
<Avatar
:name="form.display_name || 'Nouveau membre'"
:initials="form.initials || null"
:avatar-url="form.avatar_url || null"
:size="44"
/>
<div class="min-w-0">
<h2 class="text-base font-semibold text-donut-text truncate">
{{ form.display_name || 'Nouveau membre' }}
</h2>
<p class="text-sm text-donut-muted">
{{ form.kind === 'person' ? 'Personne' : form.kind === 'collective' ? 'Collectif' : 'Organisation' }}
</p>
</div>
</header>
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Nom affiché *</label>
<input
v-model="form.display_name" type="text" maxlength="128" autofocus
placeholder="Ex: Camille Dupont / Collectif des écoles / ATD Quart Monde"
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.display_name ? 'border-red-400' : ''"
/>
<p v-if="fieldErrors.display_name" class="text-xs text-red-600 mt-1">Nom requis.</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-[1fr,140px,160px] gap-4">
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Slug</label>
<input
v-model="form.slug" type="text" maxlength="96"
placeholder="auto-généré depuis le nom"
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' : ''"
@input="slugDirty = true"
/>
<p class="text-xs text-donut-muted mt-1">Identifiant URL (auto si vide).</p>
</div>
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Initiales</label>
<input
v-model="form.initials" type="text" maxlength="8"
placeholder="Auto"
class="w-full bg-white border border-donut-border rounded-lg px-3 py-2.5 text-sm uppercase outline-none focus:border-donut-text"
@input="initialsDirty = true"
/>
</div>
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Type</label>
<select
v-model="form.kind"
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="person">Personne</option>
<option value="collective">Collectif</option>
<option value="org">Organisation</option>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Bio</label>
<textarea
v-model="form.bio" rows="3" maxlength="5000"
placeholder="Une courte présentation du membre (optionnel)."
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">Email</label>
<input
v-model="form.contact_email" type="email" maxlength="255"
placeholder="contact@example.org"
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.contact_email ? 'border-red-400' : ''"
/>
<p v-if="fieldErrors.contact_email" class="text-xs text-red-600 mt-1">Email invalide.</p>
</div>
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Site web</label>
<input
v-model="form.website_url" type="url" maxlength="512"
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"
:class="fieldErrors.website_url ? 'border-red-400' : ''"
/>
<p v-if="fieldErrors.website_url" class="text-xs text-red-600 mt-1">URL invalide.</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-donut-text mb-1">Avatar (URL)</label>
<input
v-model="form.avatar_url" type="url" maxlength="512"
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"
:class="fieldErrors.avatar_url ? 'border-red-400' : ''"
/>
<p class="text-xs text-donut-muted mt-1">Image externe. Si vide, on génère l'avatar depuis les initiales.</p>
</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-members' }"
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 ? 'Création…' : 'Créer le membre' }}
</button>
</div>
</form>
</div>
</section>
</template>
+10 -1
View File
@@ -62,13 +62,22 @@ const KIND_LABEL = {
</header>
<AdminSubNav />
<div class="mt-6 flex items-center gap-2">
<div class="mt-6 flex items-center justify-between gap-2 flex-wrap">
<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"
/>
<RouterLink
:to="{ name: 'admin-member-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 membre
</RouterLink>
</div>
<LoadingState v-if="loading" class="mt-8" />
+1
View File
@@ -38,6 +38,7 @@ const routes: RouteRecordRaw[] = [
{ 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/members/new', name: 'admin-member-new', component: () => import('@/pages/admin/AdminMemberNewPage.vue'), beforeEnter: requireAdmin },
{ path: 'admin/members/:slug/edit', name: 'admin-member-edit', component: () => import('@/pages/admin/AdminMemberEditPage.vue'), props: true, beforeEnter: requireAdmin },
{ path: 'admin/categories', name: 'admin-categories', component: () => import('@/pages/admin/AdminCategoriesPage.vue'), beforeEnter: requireAdmin },
{ path: 'admin/categories/new', name: 'admin-category-new', component: () => import('@/pages/admin/AdminCategoryNewPage.vue'), beforeEnter: requireAdmin },