feat(frontend): SPA Vue 3 + Vite + Tailwind v4 consommant l'API
Nouveau dossier /frontend avec un SPA complet qui couvre les écrans de la
maquette (cf. docs/Capture*.png) : public + admin token. Backend inchangé.
Stack
- Vue 3.5 + Vite 8 + TypeScript 6 + Tailwind v4 (via @tailwindcss/vite)
- Vue Router 4 en mode history, code-split par page (lazy import)
- Aucune lib UI, aucun store (pas de Pinia) — état local + fetch direct
- Bundle initial 50 kB gzip, total dist 240 kB
Pages
- / redirige vers /donut
- /dashboard 4 KPIs + mini donut + flux activité (via /api/stats + /api/activity)
- /donut viz SVG custom : 6 secteurs catégories, projets en points
colorés, anneaux plancher social / plafond écologique,
tooltip + click vers la fiche projet
- /projects grille de cartes paginée + filtres (catégorie, statut, recherche
avec debounce, query string comme source de vérité)
- /projects/new formulaire complet de création (token admin requis)
- /projects/:slug fiche : header méta + tabs Roadmap/Discussions/Ressources
+ panneau droit Progression + Équipe, modal "Rejoindre"
- /archives projets status=archived
- /categories 6 cartes coloured-top-border + count via /api/stats
- /admin/login saisie du token Bearer, validé via POST /api/members (vide)
- 404 fallback
Structure
- src/api/client.ts : wrapper fetch typé + token Bearer auto + ApiError
- src/composables/useAuth : token localStorage + reactivité Vue
- src/types/api.ts : types miroirs des réponses PHP
- src/utils/formatDate.ts : ISO/MySQL/DATE -> JJ mois AAAA, relatif, etc.
- src/components/ : Avatar, ProjectCard, StatusBadge, CategoryBadge,
ProgressBar, LoadingState, ErrorState, JoinProjectModal
- src/layouts/AppLayout : sidebar nav + main scrollable
- src/components/AppSidebar: logo donut SVG, CTA, nav, statut admin en bas
- src/pages/ : 10 pages ci-dessus
Intégration & déploiement
- vite.config.ts proxy /api/* vers http://donut-gestprojet.test (Laragon)
- caddy.conf prod : /api/* -> reverse_proxy PHP, le reste -> static dist/
avec fallback /index.html (history mode), cache long sur /assets/*
- DEPLOY.md mis à jour avec build local + rsync dist/ vers
/srv/donut/web/infolab-front/dist
- README.md mis à jour avec section "Frontend (SPA Vue 3)"
Build & types
- npm run build : vue-tsc strict (TS 6 + erasableSyntaxOnly) -> OK
- paths alias @/* configuré dans tsconfig.app.json
Vérifié localement
- npm run dev sur :5173, proxy /api -> Apache Laragon -> PHP
- Toutes les routes répondent 200 (curl http://localhost:5173/...)
- Routes API consommées correctement (categories, stats, projects, ...)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
|
||||
const router = useRouter()
|
||||
const { login, isAuthenticated, logout } = useAuth()
|
||||
const token = ref('')
|
||||
const submitting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
const t = token.value.trim()
|
||||
if (!t) return
|
||||
submitting.value = true
|
||||
// On stocke le token, puis on tente un appel protégé pour le valider.
|
||||
// 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.
|
||||
await api.post('/api/members', {})
|
||||
router.push({ name: 'dashboard' })
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.status === 401) {
|
||||
logout()
|
||||
error.value = 'Token invalide.'
|
||||
} else {
|
||||
// 422 (validation_failed) signifie que l'auth est OK, on continue.
|
||||
router.push({ name: 'dashboard' })
|
||||
}
|
||||
} else {
|
||||
logout()
|
||||
error.value = 'Erreur réseau — réessaie.'
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="min-h-full flex items-start justify-center px-6 py-16">
|
||||
<div class="w-full max-w-md">
|
||||
<header class="text-center mb-6">
|
||||
<h1 class="text-2xl font-semibold text-donut-text">Connexion admin</h1>
|
||||
<p class="text-donut-muted text-sm mt-2">
|
||||
Saisis le token Bearer pour débloquer la création de projets, l'édition et la modération.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="isAuthenticated" class="rounded-xl border border-green-200 bg-green-50 text-green-800 p-4 text-sm">
|
||||
Tu es déjà connecté en admin.
|
||||
<button @click="logout" class="ml-2 underline font-medium">Se déconnecter</button>
|
||||
</div>
|
||||
|
||||
<form v-else @submit.prevent="submit" class="rounded-xl border border-donut-border bg-donut-surface p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Token admin</label>
|
||||
<input
|
||||
v-model="token"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
placeholder="64 caractères hex"
|
||||
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="error ? 'border-red-400' : ''"
|
||||
/>
|
||||
<p class="text-xs text-donut-muted mt-1">
|
||||
Visible dans <code>.env</code> sous <code>ADMIN_TOKEN</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-lg border border-red-200 bg-red-50 text-red-700 px-3 py-2 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="submitting || !token.trim()"
|
||||
class="w-full bg-donut-text text-white rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
{{ submitting ? 'Vérification…' : 'Se connecter' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-xs text-donut-muted text-center mt-4">
|
||||
Pas de session côté serveur : le token est stocké dans <code>localStorage</code>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import type { Paginated, ProjectListRow } from '@/types/api'
|
||||
import ProjectCard from '@/components/ProjectCard.vue'
|
||||
import LoadingState from '@/components/LoadingState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const projects = ref<ProjectListRow[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const r = await api.get<Paginated<ProjectListRow>>('/api/projects', {
|
||||
query: { status: 'archived', per_page: 50 },
|
||||
})
|
||||
projects.value = r.data
|
||||
total.value = r.meta.total
|
||||
} 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>
|
||||
<h1 class="text-3xl font-semibold text-donut-text">Archives</h1>
|
||||
<p class="text-donut-muted mt-1">Les projets archivés de la communauté — mémoire du Donut.</p>
|
||||
</header>
|
||||
|
||||
<div class="mt-8">
|
||||
<LoadingState v-if="loading" />
|
||||
<ErrorState v-else-if="error" :message="error" :retry="load" />
|
||||
<div v-else-if="projects.length === 0" class="text-donut-muted text-center py-16">
|
||||
Aucun projet archivé pour le moment.
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="text-xs text-donut-muted mb-4">
|
||||
{{ total }} projet{{ total > 1 ? 's' : '' }} archivé{{ total > 1 ? 's' : '' }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<ProjectCard v-for="p in projects" :key="p.id" :project="p" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import type { Category, Stats, Wrapped } from '@/types/api'
|
||||
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 {
|
||||
// 2 appels en parallèle : la liste des catégories + stats pour les counts par slug.
|
||||
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
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-10 py-8 max-w-6xl">
|
||||
<header>
|
||||
<h1 class="text-3xl font-semibold text-donut-text">Catégories Thématiques</h1>
|
||||
<p class="text-donut-muted mt-1">Explorez les initiatives par domaine d'impact.</p>
|
||||
</header>
|
||||
|
||||
<div class="mt-8">
|
||||
<LoadingState v-if="loading" />
|
||||
<ErrorState v-else-if="error" :message="error" :retry="load" />
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<RouterLink
|
||||
v-for="cat in categories"
|
||||
:key="cat.id"
|
||||
:to="{ name: 'projects', query: { category: cat.slug } }"
|
||||
class="group block rounded-xl border border-donut-border bg-donut-surface overflow-hidden hover:shadow-md transition-shadow"
|
||||
:style="{ borderTopWidth: '4px', borderTopStyle: 'solid', borderTopColor: cat.color_hex }"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div
|
||||
class="w-11 h-11 rounded-lg flex items-center justify-center mb-4"
|
||||
:style="{ background: cat.color_hex + '1A', color: cat.color_hex }"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-donut-text group-hover:text-black">{{ cat.name }}</h3>
|
||||
<p v-if="cat.description" class="text-sm text-donut-muted mt-2 leading-relaxed">
|
||||
{{ cat.description }}
|
||||
</p>
|
||||
<div class="mt-5 flex items-center justify-between gap-2">
|
||||
<span class="inline-flex items-center rounded-full bg-orange-50 text-status-active px-2.5 py-1 text-xs font-medium">
|
||||
{{ counts[cat.slug] ?? 0 }} projet{{ (counts[cat.slug] ?? 0) === 1 ? '' : 's' }}
|
||||
</span>
|
||||
<span class="text-[10px] text-donut-muted uppercase tracking-wider">
|
||||
{{ DIMENSION_LABEL[cat.dimension] }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</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 { ActivityEvent, Stats, Wrapped } from '@/types/api'
|
||||
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: 12 } }),
|
||||
])
|
||||
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)
|
||||
|
||||
// Verbes -> formulation lisible avec actor_label avant.
|
||||
const VERB_LABEL: Record<string, string> = {
|
||||
joined: 'a rejoint',
|
||||
left: 'a quitté',
|
||||
created_project: 'a créé',
|
||||
updated_project: 'a mis à jour',
|
||||
completed_project: 'a terminé',
|
||||
added_step: 'a ajouté une étape à',
|
||||
completed_step: 'a terminé une étape de',
|
||||
posted_discussion: 'a publié dans',
|
||||
added_resource: 'a ajouté une ressource à',
|
||||
}
|
||||
|
||||
const kpiBlocks = computed(() => {
|
||||
if (!stats.value) return []
|
||||
const total = stats.value.projects_total
|
||||
return [
|
||||
{
|
||||
label: 'Projets Actifs',
|
||||
value: stats.value.projects_active,
|
||||
sub: `Sur ${total} projet${total > 1 ? 's' : ''} au total`,
|
||||
iconPath: 'M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z',
|
||||
iconColor: '#F97316',
|
||||
},
|
||||
{
|
||||
label: 'Participants',
|
||||
value: stats.value.participants_total,
|
||||
sub: 'Citoyens engagés',
|
||||
iconPath: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2 M9 7a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M23 21v-2a4 4 0 0 0-3-3.87 M16 3.13a4 4 0 0 1 0 7.75',
|
||||
iconColor: '#EF4444',
|
||||
},
|
||||
{
|
||||
label: 'Projets Réalisés',
|
||||
value: stats.value.projects_completed,
|
||||
sub: 'Impacts concrets',
|
||||
iconPath: 'M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11',
|
||||
iconColor: '#22C55E',
|
||||
},
|
||||
{
|
||||
label: 'Projets Historiques',
|
||||
value: stats.value.projects_archived,
|
||||
sub: 'Mémoire du Donut',
|
||||
iconPath: 'M12 8v4l3 3 M21 12a9 9 0 1 1-9-9',
|
||||
iconColor: '#6B7280',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-10 py-8 max-w-7xl">
|
||||
<header>
|
||||
<h1 class="text-3xl font-semibold text-donut-text">Tableau de bord</h1>
|
||||
<p class="text-donut-muted mt-1">Vue d'ensemble de l'activité citoyenne et de l'intelligence collective.</p>
|
||||
</header>
|
||||
|
||||
<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-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div
|
||||
v-for="k in kpiBlocks"
|
||||
:key="k.label"
|
||||
class="rounded-xl border border-donut-border bg-donut-surface p-5"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="text-sm text-donut-muted">{{ k.label }}</div>
|
||||
<div
|
||||
class="w-8 h-8 rounded-md flex items-center justify-center"
|
||||
:style="{ background: k.iconColor + '1A', color: k.iconColor }"
|
||||
>
|
||||
<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="k.iconPath"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-3xl font-semibold text-donut-text">{{ k.value }}</div>
|
||||
<div class="text-xs text-donut-muted mt-1">{{ k.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2 colonnes : Vision Donut (résumé) + Activité Récente -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<!-- Vision Donut résumé -->
|
||||
<div class="lg:col-span-2 rounded-xl border border-donut-border bg-donut-surface p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-donut-text">Vision Donut</h2>
|
||||
<p class="text-sm text-donut-muted">Plancher social et plafond écologique : nos projets dans l'espace sûr.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-6 items-center">
|
||||
<!-- mini donut -->
|
||||
<div class="flex items-center justify-center">
|
||||
<svg viewBox="-100 -100 200 200" class="w-44 h-44">
|
||||
<circle r="80" fill="#22C55E1A" stroke="#22C55E" stroke-width="0.5" />
|
||||
<circle r="55" fill="#FAFAF6" />
|
||||
<circle r="40" fill="#F9731622" stroke="#F97316" stroke-width="0.5" />
|
||||
<circle r="20" fill="#FAFAF6" />
|
||||
<text text-anchor="middle" font-size="9" fill="#111827" font-weight="600">LE DONUT</text>
|
||||
<text y="11" text-anchor="middle" font-size="6" fill="#6B7280">
|
||||
{{ stats?.projects_total ?? 0 }} projets
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- légende dimensions -->
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wider text-status-active font-semibold">Plancher social</div>
|
||||
<div class="text-sm text-donut-text mt-1">
|
||||
{{ stats?.by_dimension.social ?? 0 }} projets sociaux
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wider text-status-completed font-semibold" style="color:#22C55E">Plafond écologique</div>
|
||||
<div class="text-sm text-donut-text mt-1">
|
||||
{{ stats?.by_dimension.ecologique ?? 0 }} projets environnementaux
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wider text-donut-muted font-semibold">Transversal</div>
|
||||
<div class="text-sm text-donut-text mt-1">
|
||||
{{ stats?.by_dimension.transversal ?? 0 }} projets
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-right">
|
||||
<RouterLink
|
||||
:to="{ name: 'donut' }"
|
||||
class="text-sm text-status-active font-medium hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
Voir la vision Donut complète
|
||||
<span aria-hidden="true">→</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activité Récente -->
|
||||
<div class="rounded-xl border border-donut-border bg-donut-surface p-6">
|
||||
<h2 class="text-lg font-semibold text-donut-text">Activité Récente</h2>
|
||||
<p class="text-sm text-donut-muted">Les dernières dynamiques de la communauté.</p>
|
||||
|
||||
<ul v-if="activity.length" class="mt-4 space-y-3">
|
||||
<li v-for="a in activity" :key="a.id" class="flex items-start gap-3">
|
||||
<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 text-sm">
|
||||
<div class="text-donut-text">
|
||||
<span class="font-medium">{{ a.actor_label }}</span>
|
||||
{{ ' ' }}
|
||||
<span class="text-donut-muted">{{ VERB_LABEL[a.verb] ?? a.verb }}</span>
|
||||
</div>
|
||||
<div class="text-donut-muted text-xs mt-0.5 truncate">{{ a.summary }}</div>
|
||||
<div class="text-donut-muted text-xs mt-0.5">{{ formatRelative(a.occurred_at) }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-sm text-donut-muted mt-4">Aucune activité récente.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Visualisation Donut — SVG custom.
|
||||
*
|
||||
* Modèle Donut Kate Raworth adapté :
|
||||
* - Anneau extérieur (vert pâle) = "Plafond écologique" qu'on ne doit pas dépasser
|
||||
* - Anneau intérieur (orange pâle) = "Plancher social" qu'on ne doit pas franchir vers le bas
|
||||
* - Zone centrale entre les deux = "espace sûr" où vivent les projets citoyens
|
||||
*
|
||||
* 6 secteurs colorés (60° chacun) = les 6 catégories thématiques.
|
||||
* Chaque projet = 1 point coloré (par catégorie) placé dans son secteur,
|
||||
* à un rayon qui varie légèrement (hash stable de l'id) pour éviter les chevauchements.
|
||||
* Survol → tooltip avec titre du projet et lien vers sa fiche.
|
||||
*/
|
||||
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import type { Category, Dimension, Paginated, ProjectListRow, Wrapped } from '@/types/api'
|
||||
import LoadingState from '@/components/LoadingState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const categories = ref<Category[]>([])
|
||||
const projects = ref<ProjectListRow[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const hoveredProject = ref<ProjectListRow | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const [cats, projs] = await Promise.all([
|
||||
api.get<Wrapped<Category>>('/api/categories'),
|
||||
api.get<Paginated<ProjectListRow>>('/api/projects', { query: { per_page: 100 } }),
|
||||
])
|
||||
categories.value = [...cats.data].sort((a, b) => a.sort_order - b.sort_order)
|
||||
projects.value = projs.data
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
|
||||
// --- Géométrie (rayons en unités du viewBox -250..250) ---
|
||||
const R_PROJECT_OUTER = 200
|
||||
const R_PROJECT_INNER = 110
|
||||
const R_FLOOR_OUTER = 95
|
||||
const R_FLOOR_INNER = 70
|
||||
const R_CENTER = 60
|
||||
|
||||
/** deg→rad avec offset -90° pour que 0° soit en haut (12 h). */
|
||||
function rad(deg: number): number {
|
||||
return (deg - 90) * Math.PI / 180
|
||||
}
|
||||
function pt(angleDeg: number, r: number): [number, number] {
|
||||
const a = rad(angleDeg)
|
||||
return [Math.cos(a) * r, Math.sin(a) * r]
|
||||
}
|
||||
function wedgePath(startDeg: number, endDeg: number, rInner: number, rOuter: number): string {
|
||||
const [x1, y1] = pt(startDeg, rOuter)
|
||||
const [x2, y2] = pt(endDeg, rOuter)
|
||||
const [x3, y3] = pt(endDeg, rInner)
|
||||
const [x4, y4] = pt(startDeg, rInner)
|
||||
const largeArc = endDeg - startDeg > 180 ? 1 : 0
|
||||
return [
|
||||
`M ${x1.toFixed(2)} ${y1.toFixed(2)}`,
|
||||
`A ${rOuter} ${rOuter} 0 ${largeArc} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}`,
|
||||
`L ${x3.toFixed(2)} ${y3.toFixed(2)}`,
|
||||
`A ${rInner} ${rInner} 0 ${largeArc} 0 ${x4.toFixed(2)} ${y4.toFixed(2)}`,
|
||||
'Z',
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
interface WedgeView {
|
||||
cat: Category
|
||||
startDeg: number
|
||||
endDeg: number
|
||||
centerDeg: number
|
||||
path: string
|
||||
labelPos: [number, number]
|
||||
}
|
||||
|
||||
const wedges = computed<WedgeView[]>(() => {
|
||||
const n = categories.value.length || 6
|
||||
const step = 360 / n
|
||||
return categories.value.map((cat, i) => {
|
||||
const startDeg = i * step
|
||||
const endDeg = (i + 1) * step
|
||||
const centerDeg = (startDeg + endDeg) / 2
|
||||
return {
|
||||
cat,
|
||||
startDeg,
|
||||
endDeg,
|
||||
centerDeg,
|
||||
path: wedgePath(startDeg, endDeg, R_PROJECT_INNER, R_PROJECT_OUTER),
|
||||
labelPos: pt(centerDeg, (R_PROJECT_INNER + R_PROJECT_OUTER) / 2 + 25),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
interface DotView {
|
||||
project: ProjectListRow
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
color: string
|
||||
}
|
||||
|
||||
/** Hash 32-bit basique sur une chaîne (stabilité de positionnement entre rendus). */
|
||||
function strHash(s: string): number {
|
||||
let h = 0
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0
|
||||
return Math.abs(h)
|
||||
}
|
||||
|
||||
const dots = computed<DotView[]>(() => {
|
||||
const byCat = new Map<number, ProjectListRow[]>()
|
||||
for (const p of projects.value) {
|
||||
const arr = byCat.get(p.category.id) ?? []
|
||||
arr.push(p)
|
||||
byCat.set(p.category.id, arr)
|
||||
}
|
||||
const out: DotView[] = []
|
||||
for (const w of wedges.value) {
|
||||
const ps = byCat.get(w.cat.id) ?? []
|
||||
const span = w.endDeg - w.startDeg
|
||||
ps.forEach((p, idx) => {
|
||||
// Répartit angulairement les projets sur la largeur du secteur (avec marge),
|
||||
// et ajoute un petit jitter de rayon pour ne pas tous les aligner.
|
||||
const t = (idx + 1) / (ps.length + 1)
|
||||
const angleDeg = w.startDeg + span * t
|
||||
const jitter = ((strHash(p.slug) % 100) / 100 - 0.5) * (R_PROJECT_OUTER - R_PROJECT_INNER - 30)
|
||||
const r = (R_PROJECT_INNER + R_PROJECT_OUTER) / 2 + jitter
|
||||
const [x, y] = pt(angleDeg, r)
|
||||
// Diamètre du point en fonction de la progression (un peu plus gros si avancé).
|
||||
const radius = 4 + (p.progress_percent / 100) * 4
|
||||
out.push({ project: p, x, y, r: radius, color: p.category.color_hex })
|
||||
})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
// Légende groupée par dimension
|
||||
const DIMENSION_ORDER: Dimension[] = ['social', 'ecologique', 'transversal']
|
||||
const DIMENSION_META: Record<Dimension, { label: string; color: string }> = {
|
||||
social: { label: 'Plancher social', color: '#F97316' },
|
||||
ecologique: { label: 'Plafond écologique', color: '#22C55E' },
|
||||
transversal: { label: 'Transversal', color: '#6B7280' },
|
||||
}
|
||||
const categoriesByDimension = computed(() => {
|
||||
const map: Record<Dimension, Category[]> = { social: [], ecologique: [], transversal: [] }
|
||||
for (const c of categories.value) map[c.dimension].push(c)
|
||||
return map
|
||||
})
|
||||
|
||||
function gotoProject(p: ProjectListRow) {
|
||||
router.push({ name: 'project-detail', params: { slug: p.slug } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-10 py-8 max-w-7xl">
|
||||
<header class="text-center max-w-3xl mx-auto">
|
||||
<h1 class="text-3xl font-semibold text-donut-text">Le Donut des Projets Citoyens</h1>
|
||||
<p class="text-donut-muted mt-2">
|
||||
Inspiré du modèle de l'économie du Donut de Kate Raworth. L'Infolab des projets citoyens
|
||||
explore les initiatives qui poussent une vie marseillaise dans l'espace sûr : entre
|
||||
plancher social et plafond écologique.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<LoadingState v-if="loading" class="mt-12 justify-center" />
|
||||
<ErrorState v-else-if="error" :message="error" :retry="load" class="mt-12" />
|
||||
|
||||
<div v-else class="mt-10 grid grid-cols-1 lg:grid-cols-3 gap-10 items-start">
|
||||
<!-- Donut SVG (2/3 sur grands écrans) -->
|
||||
<div class="lg:col-span-2 flex flex-col items-center">
|
||||
<svg viewBox="-250 -250 500 500" class="w-full max-w-[560px] aspect-square">
|
||||
<!-- Plafond écologique (anneau externe) -->
|
||||
<circle r="240" fill="#22C55E14" />
|
||||
<circle r="215" fill="#FAFAF6" />
|
||||
<text
|
||||
v-for="i in 1" :key="`ceil-${i}`"
|
||||
x="0" y="-228" text-anchor="middle" font-size="9"
|
||||
fill="#22C55E" font-weight="600" style="letter-spacing:1px"
|
||||
>PLAFOND ÉCOLOGIQUE</text>
|
||||
|
||||
<!-- Plancher social (anneau interne) -->
|
||||
<circle :r="R_FLOOR_OUTER" fill="#F9731614" />
|
||||
<circle :r="R_FLOOR_INNER" fill="#FAFAF6" />
|
||||
|
||||
<!-- 6 secteurs catégories -->
|
||||
<g>
|
||||
<path
|
||||
v-for="w in wedges" :key="w.cat.id"
|
||||
:d="w.path"
|
||||
:fill="w.cat.color_hex + '14'"
|
||||
:stroke="w.cat.color_hex + '40'"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Étiquettes catégories à l'extérieur des secteurs -->
|
||||
<g>
|
||||
<text
|
||||
v-for="w in wedges" :key="`label-${w.cat.id}`"
|
||||
:x="w.labelPos[0]" :y="w.labelPos[1]"
|
||||
text-anchor="middle"
|
||||
font-size="10"
|
||||
:fill="w.cat.color_hex"
|
||||
font-weight="600"
|
||||
>{{ w.cat.name }}</text>
|
||||
</g>
|
||||
|
||||
<!-- Points projets -->
|
||||
<g>
|
||||
<circle
|
||||
v-for="d in dots" :key="d.project.id"
|
||||
:cx="d.x" :cy="d.y" :r="d.r"
|
||||
:fill="d.color"
|
||||
stroke="white" stroke-width="1.5"
|
||||
class="cursor-pointer transition-transform hover:scale-150 origin-center"
|
||||
@mouseenter="hoveredProject = d.project"
|
||||
@mouseleave="hoveredProject = null"
|
||||
@click="gotoProject(d.project)"
|
||||
>
|
||||
<title>{{ d.project.title }}</title>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- Centre -->
|
||||
<circle :r="R_CENTER" fill="#FFFFFF" stroke="#E5E7EB" />
|
||||
<text x="0" y="-4" text-anchor="middle" font-size="14" fill="#111827" font-weight="700"
|
||||
style="letter-spacing:2px">LEDONUT</text>
|
||||
<text x="0" y="14" text-anchor="middle" font-size="9" fill="#6B7280">
|
||||
{{ projects.length }} projet{{ projects.length > 1 ? 's' : '' }}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<!-- Tooltip projet survolé -->
|
||||
<div class="mt-4 h-12 flex items-center justify-center">
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="hoveredProject"
|
||||
class="px-3 py-1.5 rounded-full bg-white border border-donut-border shadow-sm text-sm flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:style="{ background: hoveredProject.category.color_hex }"
|
||||
/>
|
||||
<span class="font-medium">{{ hoveredProject.title }}</span>
|
||||
<span class="text-donut-muted text-xs">— {{ hoveredProject.progress_percent }}%</span>
|
||||
</div>
|
||||
<div v-else class="text-xs text-donut-muted">
|
||||
Survolez un point pour voir le détail d'un projet
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Légende -->
|
||||
<aside class="space-y-6">
|
||||
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
|
||||
<h2 class="text-base font-semibold text-donut-text">Comment lire le donut ?</h2>
|
||||
<p class="text-sm text-donut-muted mt-2 leading-relaxed">
|
||||
Chaque <strong class="text-donut-text">point</strong> est un projet citoyen, placé dans son
|
||||
secteur de catégorie. L'anneau extérieur représente le
|
||||
<span class="font-medium" style="color:#22C55E">plafond écologique</span> et l'anneau
|
||||
intérieur le <span class="font-medium" style="color:#F97316">plancher social</span>.
|
||||
L'objectif : que tous les projets contribuent à rester dans l'espace sûr entre les deux.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="dim in DIMENSION_ORDER"
|
||||
:key="dim"
|
||||
class="rounded-xl border border-donut-border bg-donut-surface p-5"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="w-2 h-2 rounded-full" :style="{ background: DIMENSION_META[dim].color }" />
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider" :style="{ color: DIMENSION_META[dim].color }">
|
||||
{{ DIMENSION_META[dim].label }}
|
||||
</h3>
|
||||
</div>
|
||||
<ul v-if="categoriesByDimension[dim].length" class="space-y-2">
|
||||
<li
|
||||
v-for="c in categoriesByDimension[dim]"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<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="text-donut-text hover:underline"
|
||||
>
|
||||
{{ c.name }}
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-xs text-donut-muted">Aucune catégorie dans cette dimension.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from, .fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Accueil = redirection immédiate vers /donut (la vision Donut est la "home" du Donut Infolab).
|
||||
* On pourrait montrer un vrai landing page plus tard.
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
onMounted(() => router.replace({ name: 'donut' }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-10 py-16 text-center">
|
||||
<div class="text-6xl font-bold text-donut-muted">404</div>
|
||||
<h1 class="text-2xl font-semibold mt-2 text-donut-text">Page introuvable</h1>
|
||||
<p class="text-donut-muted mt-2">L'URL demandée n'existe pas (ou plus).</p>
|
||||
<RouterLink
|
||||
to="/"
|
||||
class="inline-block mt-6 bg-donut-text text-white rounded-lg px-4 py-2 text-sm font-medium hover:bg-black transition-colors"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</RouterLink>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,615 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import type { Discussion, ProjectDetail, Resource, RoadmapStep, Wrapped } from '@/types/api'
|
||||
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import CategoryBadge from '@/components/CategoryBadge.vue'
|
||||
import ProgressBar from '@/components/ProgressBar.vue'
|
||||
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 { formatDate, formatMonthYear, formatRelative } from '@/utils/formatDate'
|
||||
|
||||
const props = defineProps<{ slug: string }>()
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
// État principal
|
||||
const project = ref<ProjectDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Tabs
|
||||
type Tab = 'roadmap' | 'discussions' | 'resources'
|
||||
const activeTab = ref<Tab>('roadmap')
|
||||
|
||||
// Discussions et resources : lazy-loaded au 1er affichage du tab.
|
||||
const discussions = ref<Discussion[] | null>(null)
|
||||
const discussionsLoading = ref(false)
|
||||
const resources = ref<Resource[] | null>(null)
|
||||
const resourcesLoading = ref(false)
|
||||
|
||||
// Inputs admin (ajouter étape / ressource / message)
|
||||
const newStepTitle = ref('')
|
||||
const newResource = ref({ title: '', url: '', kind: 'link' as Resource['kind'] })
|
||||
const newMessage = ref({ author_name: '', body: '' })
|
||||
const adminBusy = ref(false)
|
||||
|
||||
// Modal "Rejoindre"
|
||||
const showJoinModal = ref(false)
|
||||
|
||||
async function loadProject() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
project.value = await api.get<ProjectDetail>(`/api/projects/${encodeURIComponent(props.slug)}`)
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDiscussions() {
|
||||
if (discussions.value !== null) return // déjà chargé
|
||||
discussionsLoading.value = true
|
||||
try {
|
||||
const r = await api.get<Wrapped<Discussion>>(`/api/projects/${encodeURIComponent(props.slug)}/discussions`)
|
||||
discussions.value = r.data
|
||||
} catch {
|
||||
discussions.value = []
|
||||
} finally {
|
||||
discussionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResources() {
|
||||
if (resources.value !== null) return
|
||||
resourcesLoading.value = true
|
||||
try {
|
||||
const r = await api.get<Wrapped<Resource>>(`/api/projects/${encodeURIComponent(props.slug)}/resources`)
|
||||
resources.value = r.data
|
||||
} catch {
|
||||
resources.value = []
|
||||
} finally {
|
||||
resourcesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Charge les onglets à la demande
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'discussions') loadDiscussions()
|
||||
if (tab === 'resources') loadResources()
|
||||
})
|
||||
|
||||
onMounted(loadProject)
|
||||
|
||||
// --- Actions admin ---
|
||||
|
||||
async function toggleStepDone(step: RoadmapStep) {
|
||||
if (!isAuthenticated.value || !project.value) return
|
||||
try {
|
||||
const updated = await api.patch<RoadmapStep>(
|
||||
`/api/projects/${encodeURIComponent(props.slug)}/roadmap/${step.id}`,
|
||||
{ is_done: !step.is_done },
|
||||
)
|
||||
const idx = project.value.roadmap.findIndex(s => s.id === step.id)
|
||||
if (idx >= 0) project.value.roadmap[idx] = updated
|
||||
} catch (e) {
|
||||
console.error('toggleStepDone failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addStep() {
|
||||
const title = newStepTitle.value.trim()
|
||||
if (!title || !isAuthenticated.value || !project.value) return
|
||||
adminBusy.value = true
|
||||
try {
|
||||
const step = await api.post<RoadmapStep>(
|
||||
`/api/projects/${encodeURIComponent(props.slug)}/roadmap`,
|
||||
{ title, is_done: false },
|
||||
)
|
||||
project.value.roadmap.push(step)
|
||||
newStepTitle.value = ''
|
||||
} finally {
|
||||
adminBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteStep(step: RoadmapStep) {
|
||||
if (!isAuthenticated.value || !project.value) return
|
||||
if (!confirm(`Supprimer l'étape « ${step.title} » ?`)) return
|
||||
try {
|
||||
await api.delete(`/api/projects/${encodeURIComponent(props.slug)}/roadmap/${step.id}`)
|
||||
project.value.roadmap = project.value.roadmap.filter(s => s.id !== step.id)
|
||||
} catch (e) {
|
||||
console.error('deleteStep failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function postMessage() {
|
||||
const author = newMessage.value.author_name.trim()
|
||||
const body = newMessage.value.body.trim()
|
||||
if (!author || !body || !isAuthenticated.value) return
|
||||
adminBusy.value = true
|
||||
try {
|
||||
const msg = await api.post<Discussion>(
|
||||
`/api/projects/${encodeURIComponent(props.slug)}/discussions`,
|
||||
{ author_name: author, body },
|
||||
)
|
||||
if (discussions.value) {
|
||||
// Ajoute en tête (la liste est triée DESC date par l'API).
|
||||
discussions.value.unshift({ ...msg, replies: [] })
|
||||
}
|
||||
newMessage.value = { author_name: '', body: '' }
|
||||
if (project.value) project.value.counts.discussions += 1
|
||||
} finally {
|
||||
adminBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addResource() {
|
||||
const { title, url, kind } = newResource.value
|
||||
if (!title.trim() || !url.trim() || !isAuthenticated.value) return
|
||||
adminBusy.value = true
|
||||
try {
|
||||
const res = await api.post<Resource>(
|
||||
`/api/projects/${encodeURIComponent(props.slug)}/resources`,
|
||||
{ title: title.trim(), url: url.trim(), kind },
|
||||
)
|
||||
if (resources.value) resources.value.push(res)
|
||||
newResource.value = { title: '', url: '', kind: 'link' }
|
||||
if (project.value) project.value.counts.resources += 1
|
||||
} finally {
|
||||
adminBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject() {
|
||||
if (!isAuthenticated.value || !project.value) return
|
||||
if (!confirm(`Supprimer définitivement le projet « ${project.value.title} » ?\n(L'historique reste consultable via les archives.)`)) return
|
||||
try {
|
||||
await api.delete(`/api/projects/${encodeURIComponent(props.slug)}`)
|
||||
router.push({ name: 'projects' })
|
||||
} catch (e) {
|
||||
console.error('deleteProject failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
function onJoined(payload: { member: { id: number; slug: string; display_name: string }; role: string }) {
|
||||
// Optimiste : push dans la liste des membres locale.
|
||||
if (!project.value) return
|
||||
project.value.members.push({
|
||||
id: payload.member.id,
|
||||
slug: payload.member.slug,
|
||||
display_name: payload.member.display_name,
|
||||
initials: null,
|
||||
kind: 'person',
|
||||
avatar_url: null,
|
||||
role: payload.role as 'contributor',
|
||||
joined_at: new Date().toISOString(),
|
||||
})
|
||||
project.value.counts.members = project.value.members.length
|
||||
}
|
||||
|
||||
// Présentations dérivées
|
||||
const categoryColor = computed(() => project.value?.category?.color_hex ?? '#111827')
|
||||
const externalDomain = computed(() => {
|
||||
if (!project.value?.external_url) return ''
|
||||
try {
|
||||
return new URL(project.value.external_url).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return project.value.external_url
|
||||
}
|
||||
})
|
||||
const ROLE_LABEL = { admin: 'Admin', contributor: 'Contributor', observer: 'Observer' } as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-10 py-8 max-w-7xl">
|
||||
<!-- Retour -->
|
||||
<RouterLink
|
||||
:to="{ name: '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
|
||||
</RouterLink>
|
||||
|
||||
<LoadingState v-if="loading" class="mt-8" />
|
||||
<ErrorState v-else-if="error" :message="error" :retry="loadProject" class="mt-8" />
|
||||
|
||||
<div v-else-if="project" class="mt-4 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
<!-- ============== COLONNE GAUCHE ============== -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<!-- Badges + bouton supprimer -->
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<StatusBadge :status="project.status" />
|
||||
<CategoryBadge
|
||||
v-if="project.category"
|
||||
:name="project.category.name"
|
||||
:color-hex="project.category.color_hex"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="isAuthenticated"
|
||||
type="button"
|
||||
class="flex items-center gap-1.5 bg-red-500 text-white rounded-lg px-3 py-1.5 text-sm font-medium hover:bg-red-600"
|
||||
@click="deleteProject"
|
||||
>
|
||||
<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="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
|
||||
</svg>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Titre + métadonnées -->
|
||||
<div>
|
||||
<h1 class="text-4xl font-bold text-donut-text">{{ project.title }}</h1>
|
||||
<div class="mt-3 flex items-center gap-4 text-sm text-donut-muted flex-wrap">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<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 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
{{ project.territory }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<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="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/>
|
||||
</svg>
|
||||
Créé en {{ formatMonthYear(project.created_at) }}
|
||||
</span>
|
||||
<a
|
||||
v-if="project.external_url"
|
||||
:href="project.external_url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-1.5 text-status-active hover:underline"
|
||||
>
|
||||
<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="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>
|
||||
</svg>
|
||||
Voir sur {{ externalDomain }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contexte -->
|
||||
<div v-if="project.context_description">
|
||||
<h2 class="text-xl font-semibold text-donut-text">Contexte & Description</h2>
|
||||
<p class="mt-2 text-donut-muted leading-relaxed whitespace-pre-line">
|
||||
{{ project.context_description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Objectifs -->
|
||||
<div v-if="project.objectives">
|
||||
<h2 class="text-xl font-semibold text-donut-text">Objectifs</h2>
|
||||
<p class="mt-2 text-donut-muted leading-relaxed whitespace-pre-line">
|
||||
{{ project.objectives }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TABS -->
|
||||
<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)"
|
||||
:key="tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab"
|
||||
:class="[
|
||||
'flex items-center gap-2 pb-3 text-sm font-medium border-b-2 transition-colors',
|
||||
activeTab === tab
|
||||
? 'border-donut-text text-donut-text'
|
||||
: 'border-transparent text-donut-muted hover:text-donut-text',
|
||||
]"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
<span v-if="tab === 'roadmap'" 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="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
|
||||
</svg>
|
||||
Feuille de route
|
||||
</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"/>
|
||||
</svg>
|
||||
Discussions ({{ project.counts.discussions }})
|
||||
</span>
|
||||
<span v-else 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 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16"/>
|
||||
</svg>
|
||||
Ressources ({{ project.counts.resources }})
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab : Feuille de route -->
|
||||
<div v-if="activeTab === 'roadmap'" class="space-y-3">
|
||||
<p v-if="project.roadmap.length === 0" class="text-donut-muted text-sm">
|
||||
Pas encore d'étape. {{ isAuthenticated ? 'Ajoute-en une ci-dessous.' : '' }}
|
||||
</p>
|
||||
<ul v-else class="space-y-3">
|
||||
<li
|
||||
v-for="step in project.roadmap"
|
||||
:key="step.id"
|
||||
class="flex items-start gap-3 group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="[
|
||||
'mt-0.5 w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0',
|
||||
step.is_done
|
||||
? 'bg-green-500 border-green-500 text-white'
|
||||
: 'border-gray-300 hover:border-green-500',
|
||||
]"
|
||||
:disabled="!isAuthenticated"
|
||||
:title="isAuthenticated ? (step.is_done ? 'Marquer comme à faire' : 'Marquer comme fait') : 'Admin requis'"
|
||||
@click="toggleStepDone(step)"
|
||||
>
|
||||
<svg v-if="step.is_done" viewBox="0 0 24 24" class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12l5 5L20 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex-1">
|
||||
<div
|
||||
:class="[
|
||||
'text-sm font-medium',
|
||||
step.is_done ? 'line-through text-donut-muted' : 'text-donut-text',
|
||||
]"
|
||||
>
|
||||
{{ step.title }}
|
||||
</div>
|
||||
<div v-if="step.step_date" class="text-xs text-donut-muted mt-0.5">
|
||||
{{ formatDate(step.step_date) }}
|
||||
</div>
|
||||
<div v-if="step.description" class="text-xs text-donut-muted mt-0.5 whitespace-pre-line">
|
||||
{{ step.description }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="isAuthenticated"
|
||||
type="button"
|
||||
class="opacity-0 group-hover:opacity-100 text-donut-muted hover:text-red-500"
|
||||
title="Supprimer"
|
||||
@click="deleteStep(step)"
|
||||
>
|
||||
<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="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form
|
||||
v-if="isAuthenticated"
|
||||
class="flex items-center gap-2 pt-2"
|
||||
@submit.prevent="addStep"
|
||||
>
|
||||
<input
|
||||
v-model="newStepTitle"
|
||||
type="text"
|
||||
placeholder="Nouvelle étape…"
|
||||
class="flex-1 bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="adminBusy || !newStepTitle.trim()"
|
||||
class="px-4 py-2 rounded-lg bg-gray-100 text-donut-text text-sm font-medium hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tab : Discussions -->
|
||||
<div v-else-if="activeTab === 'discussions'" class="space-y-4">
|
||||
<LoadingState v-if="discussionsLoading" />
|
||||
<template v-else>
|
||||
<p v-if="(discussions ?? []).length === 0" class="text-donut-muted text-sm">
|
||||
Aucun message pour le moment.
|
||||
</p>
|
||||
<ul v-else class="space-y-4">
|
||||
<li
|
||||
v-for="d in discussions ?? []"
|
||||
:key="d.id"
|
||||
class="rounded-lg border border-donut-border bg-donut-surface p-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<Avatar :name="d.author_name" :size="28" />
|
||||
<div class="text-sm font-medium text-donut-text">{{ d.author_name }}</div>
|
||||
<div class="text-xs text-donut-muted">· {{ formatRelative(d.created_at) }}</div>
|
||||
</div>
|
||||
<p class="text-sm text-donut-text whitespace-pre-line leading-relaxed">{{ d.body }}</p>
|
||||
<ul v-if="d.replies && d.replies.length" class="mt-3 ml-6 pl-4 border-l border-donut-border space-y-3">
|
||||
<li v-for="r in d.replies" :key="r.id">
|
||||
<div class="flex items-center gap-2 mb-0.5">
|
||||
<Avatar :name="r.author_name" :size="22" />
|
||||
<div class="text-xs font-medium text-donut-text">{{ r.author_name }}</div>
|
||||
<div class="text-xs text-donut-muted">· {{ formatRelative(r.created_at) }}</div>
|
||||
</div>
|
||||
<p class="text-sm text-donut-muted whitespace-pre-line">{{ r.body }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form
|
||||
v-if="isAuthenticated"
|
||||
class="space-y-2 pt-4 border-t border-donut-border"
|
||||
@submit.prevent="postMessage"
|
||||
>
|
||||
<div class="text-sm font-medium text-donut-text">Nouveau message</div>
|
||||
<input
|
||||
v-model="newMessage.author_name"
|
||||
type="text"
|
||||
placeholder="Ton nom"
|
||||
class="w-full bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
|
||||
/>
|
||||
<textarea
|
||||
v-model="newMessage.body"
|
||||
rows="3"
|
||||
placeholder="Ton message…"
|
||||
class="w-full bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text resize-y"
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="adminBusy || !newMessage.author_name.trim() || !newMessage.body.trim()"
|
||||
class="px-4 py-2 rounded-lg bg-donut-text text-white text-sm font-medium hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
Publier
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-else class="text-xs text-donut-muted">Connecte-toi en admin pour publier un message.</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Tab : Ressources -->
|
||||
<div v-else class="space-y-3">
|
||||
<LoadingState v-if="resourcesLoading" />
|
||||
<template v-else>
|
||||
<p v-if="(resources ?? []).length === 0" class="text-donut-muted text-sm">
|
||||
Aucune ressource pour le moment.
|
||||
</p>
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="r in resources ?? []"
|
||||
:key="r.id"
|
||||
class="flex items-start gap-3 rounded-lg border border-donut-border bg-donut-surface p-3"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-md bg-gray-100 flex items-center justify-center shrink-0 text-donut-muted">
|
||||
<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="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<a
|
||||
:href="r.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-sm font-medium text-donut-text hover:underline"
|
||||
>
|
||||
{{ r.title }}
|
||||
</a>
|
||||
<div class="text-xs text-donut-muted truncate">{{ r.url }}</div>
|
||||
<div v-if="r.description" class="text-xs text-donut-muted mt-1">{{ r.description }}</div>
|
||||
</div>
|
||||
<span class="text-[10px] uppercase tracking-wide text-donut-muted">{{ r.kind }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<form
|
||||
v-if="isAuthenticated"
|
||||
class="space-y-2 pt-4 border-t border-donut-border"
|
||||
@submit.prevent="addResource"
|
||||
>
|
||||
<div class="text-sm font-medium text-donut-text">Nouvelle ressource</div>
|
||||
<input
|
||||
v-model="newResource.title"
|
||||
type="text"
|
||||
placeholder="Titre"
|
||||
class="w-full bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="newResource.url"
|
||||
type="url"
|
||||
placeholder="https://…"
|
||||
class="flex-1 bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
|
||||
/>
|
||||
<select
|
||||
v-model="newResource.kind"
|
||||
class="bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
|
||||
>
|
||||
<option value="link">Lien</option>
|
||||
<option value="document">Document</option>
|
||||
<option value="dataset">Dataset</option>
|
||||
<option value="video">Vidéo</option>
|
||||
<option value="other">Autre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="adminBusy || !newResource.title.trim() || !newResource.url.trim()"
|
||||
class="px-4 py-2 rounded-lg bg-donut-text text-white text-sm font-medium hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-else class="text-xs text-donut-muted">Connecte-toi en admin pour ajouter une ressource.</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== COLONNE DROITE ============== -->
|
||||
<aside class="space-y-4">
|
||||
<!-- Card Progression + Rejoindre -->
|
||||
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
|
||||
<div class="text-sm font-medium text-donut-text mb-1">Progression</div>
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<ProgressBar :value="project.progress_percent" :color="categoryColor" class="flex-1" />
|
||||
<span class="text-sm font-semibold tabular-nums">{{ project.progress_percent }}%</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full bg-donut-text text-white rounded-lg px-4 py-2.5 text-sm font-medium hover:bg-black transition-colors"
|
||||
@click="showJoinModal = true"
|
||||
>
|
||||
Rejoindre le projet
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Card Équipe -->
|
||||
<div class="rounded-xl border border-donut-border bg-donut-surface p-5">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<svg viewBox="0 0 24 24" class="w-4 h-4 text-donut-muted" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-donut-text">Équipe ({{ project.members.length }})</span>
|
||||
</div>
|
||||
<p v-if="project.members.length === 0" class="text-sm text-donut-muted">
|
||||
Pas encore de membre. Sois le premier !
|
||||
</p>
|
||||
<ul v-else class="space-y-3">
|
||||
<li
|
||||
v-for="m in project.members"
|
||||
:key="m.id"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<Avatar :name="m.display_name" :initials="m.initials" :avatar-url="m.avatar_url" :size="34" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium text-donut-text truncate">{{ m.display_name }}</div>
|
||||
<div class="text-xs text-donut-muted">{{ ROLE_LABEL[m.role] }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Modal Rejoindre -->
|
||||
<JoinProjectModal
|
||||
v-if="showJoinModal && project"
|
||||
:project-slug="project.slug"
|
||||
@close="showJoinModal = false"
|
||||
@joined="onJoined"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,224 @@
|
||||
<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 LoadingState from '@/components/LoadingState.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const categories = ref<Category[]>([])
|
||||
const loadingCategories = ref(true)
|
||||
|
||||
const form = ref({
|
||||
title: '',
|
||||
category_id: '' as string | number,
|
||||
territory: 'Marseille',
|
||||
short_description: '',
|
||||
context_description: '',
|
||||
objectives: '',
|
||||
cover_image_url: '',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const fieldErrors = ref<Record<string, string>>({})
|
||||
const generalError = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await api.get<Wrapped<Category>>('/api/categories')
|
||||
categories.value = r.data
|
||||
} finally {
|
||||
loadingCategories.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => isAuthenticated.value && form.value.title.trim() && form.value.category_id)
|
||||
|
||||
async function submit() {
|
||||
fieldErrors.value = {}
|
||||
generalError.value = null
|
||||
if (!isAuthenticated.value) {
|
||||
generalError.value = "Tu dois être connecté en admin pour créer un projet."
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
title: form.value.title.trim(),
|
||||
category_id: Number(form.value.category_id),
|
||||
}
|
||||
if (form.value.territory.trim()) body.territory = form.value.territory.trim()
|
||||
if (form.value.short_description.trim()) body.short_description = form.value.short_description.trim()
|
||||
if (form.value.context_description.trim()) body.context_description = form.value.context_description.trim()
|
||||
if (form.value.objectives.trim()) body.objectives = form.value.objectives.trim()
|
||||
if (form.value.cover_image_url.trim()) body.cover_image_url = form.value.cover_image_url.trim()
|
||||
|
||||
const created = await api.post<ProjectDetail>('/api/projects', body)
|
||||
router.push({ name: 'project-detail', 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 = 'Token admin invalide ou expiré.'
|
||||
} 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-3xl">
|
||||
<RouterLink
|
||||
:to="{ name: '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 aux projets
|
||||
</RouterLink>
|
||||
|
||||
<header class="mt-4">
|
||||
<h1 class="text-3xl font-semibold text-donut-text">Nouveau Projet</h1>
|
||||
<p class="text-donut-muted mt-1">Proposez une nouvelle initiative citoyenne pour la communauté.</p>
|
||||
</header>
|
||||
|
||||
<!-- Avertissement si pas connecté admin -->
|
||||
<div
|
||||
v-if="!isAuthenticated"
|
||||
class="mt-6 rounded-lg border border-amber-200 bg-amber-50 text-amber-800 px-4 py-3 text-sm"
|
||||
>
|
||||
La création de projets est réservée à l'admin en V1.
|
||||
<RouterLink :to="{ name: 'admin-login' }" class="underline font-medium">Se connecter</RouterLink>
|
||||
pour publier.
|
||||
</div>
|
||||
|
||||
<form
|
||||
@submit.prevent="submit"
|
||||
class="mt-6 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">Détails du projet</h2>
|
||||
<p class="text-sm text-donut-muted mt-0.5">Décrivez clairement votre projet pour encourager la participation.</p>
|
||||
</header>
|
||||
|
||||
<!-- Titre -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Titre du projet *</label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
placeholder="Ex: Cartographie des îlots de chaleur"
|
||||
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>
|
||||
|
||||
<!-- Catégorie + Territoire -->
|
||||
<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 thématique *</label>
|
||||
<LoadingState v-if="loadingCategories" />
|
||||
<select
|
||||
v-else
|
||||
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 value="" disabled>Sélectionner une catégorie</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.id">{{ c.name }}</option>
|
||||
</select>
|
||||
<p v-if="fieldErrors.category_id" class="text-xs text-red-600 mt-1">
|
||||
{{ fieldErrors.category_id === 'not_found' ? 'Catégorie introuvable.' : 'Catégorie requise.' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Territoire / Quartier</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>
|
||||
|
||||
<!-- Description courte -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Description courte</label>
|
||||
<textarea
|
||||
v-model="form.short_description"
|
||||
rows="2"
|
||||
placeholder="Un résumé en une ou deux phrases (utilisé sur les cartes)."
|
||||
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>
|
||||
|
||||
<!-- Contexte -->
|
||||
<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"
|
||||
placeholder="Présentez le contexte et les enjeux…"
|
||||
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>
|
||||
|
||||
<!-- Objectifs -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Objectifs et impacts attendus</label>
|
||||
<textarea
|
||||
v-model="form.objectives"
|
||||
rows="3"
|
||||
placeholder="Quels sont les résultats concrets visés ?"
|
||||
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>
|
||||
|
||||
<!-- Image cover -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-donut-text mb-1">Image de couverture (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"
|
||||
:class="fieldErrors.cover_image_url ? 'border-red-400' : ''"
|
||||
/>
|
||||
<p class="text-xs text-donut-muted mt-1">Optionnel — une image d'illustration pour votre projet.</p>
|
||||
</div>
|
||||
|
||||
<!-- Erreur générale -->
|
||||
<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>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center justify-end gap-2 pt-2 border-t border-donut-border">
|
||||
<RouterLink
|
||||
:to="{ name: '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="!canSubmit || submitting"
|
||||
class="px-4 py-2 rounded-lg bg-donut-text text-white text-sm font-medium hover:bg-black disabled:opacity-50"
|
||||
>
|
||||
{{ submitting ? 'Publication…' : 'Publier le projet' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { api, ApiError } from '@/api/client'
|
||||
import type { Category, Paginated, ProjectListRow, ProjectStatus, Wrapped } from '@/types/api'
|
||||
import ProjectCard from '@/components/ProjectCard.vue'
|
||||
import LoadingState from '@/components/LoadingState.vue'
|
||||
import ErrorState from '@/components/ErrorState.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// État
|
||||
const categories = ref<Category[]>([])
|
||||
const projects = ref<ProjectListRow[]>([])
|
||||
const totalProjects = ref(0)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Filtres lus depuis la query string -> source de vérité = l'URL.
|
||||
const filterCategory = computed(() => (route.query.category as string) || '')
|
||||
const filterStatus = computed(() => (route.query.status as string) || '')
|
||||
const filterQ = computed(() => (route.query.q as string) || '')
|
||||
const searchInput = ref(filterQ.value)
|
||||
|
||||
function updateQuery(patch: Record<string, string | null>) {
|
||||
const next: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
|
||||
if (v && typeof v === 'string') next[k] = v
|
||||
}
|
||||
router.replace({ name: 'projects', query: next })
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
const r = await api.get<Wrapped<Category>>('/api/categories')
|
||||
categories.value = r.data
|
||||
} catch {
|
||||
/* silencieux : on garde les filtres sans label de catégorie. */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const r = await api.get<Paginated<ProjectListRow>>('/api/projects', {
|
||||
query: {
|
||||
category: filterCategory.value || undefined,
|
||||
status: filterStatus.value || undefined,
|
||||
q: filterQ.value || undefined,
|
||||
per_page: 50,
|
||||
},
|
||||
})
|
||||
projects.value = r.data
|
||||
totalProjects.value = r.meta.total
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? (e.payload.message ?? e.code) : 'Erreur réseau'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadCategories(), loadProjects()])
|
||||
})
|
||||
|
||||
// Re-fetch quand un filtre change dans l'URL (back/forward, RouterLink externe).
|
||||
watch(() => [filterCategory.value, filterStatus.value, filterQ.value], loadProjects)
|
||||
|
||||
// Recherche : on debounce 250ms.
|
||||
let searchTimer: number | undefined
|
||||
function onSearchInput() {
|
||||
window.clearTimeout(searchTimer)
|
||||
searchTimer = window.setTimeout(() => {
|
||||
updateQuery({ q: searchInput.value || null })
|
||||
}, 250)
|
||||
}
|
||||
|
||||
const STATUSES: { value: ProjectStatus | ''; label: string }[] = [
|
||||
{ value: '', label: 'Tous les statuts' },
|
||||
{ value: 'active', label: 'Actifs' },
|
||||
{ value: 'completed', label: 'Réalisés' },
|
||||
{ value: 'archived', label: 'Archivés' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="px-10 py-8 max-w-7xl">
|
||||
<header class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-3xl font-semibold text-donut-text">Projets Actifs</h1>
|
||||
<p class="text-donut-muted mt-1">Découvrez et rejoignez les initiatives de la communauté.</p>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
<!-- Filtres -->
|
||||
<div class="mt-6 flex items-center gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-2 bg-donut-surface border border-donut-border rounded-lg px-3 py-2 flex-1 min-w-[240px] max-w-md">
|
||||
<svg viewBox="0 0 24 24" class="w-4 h-4 text-donut-muted" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchInput"
|
||||
@input="onSearchInput"
|
||||
type="search"
|
||||
placeholder="Rechercher un projet…"
|
||||
class="flex-1 bg-transparent text-sm outline-none placeholder:text-donut-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
:value="filterCategory"
|
||||
@change="updateQuery({ category: ($event.target as HTMLSelectElement).value || null })"
|
||||
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 les catégories</option>
|
||||
<option v-for="c in categories" :key="c.id" :value="c.slug">{{ c.name }}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
:value="filterStatus"
|
||||
@change="updateQuery({ status: ($event.target as HTMLSelectElement).value || null })"
|
||||
class="bg-donut-surface border border-donut-border rounded-lg px-3 py-2 text-sm outline-none focus:border-donut-text"
|
||||
>
|
||||
<option v-for="s in STATUSES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<div class="mt-6">
|
||||
<LoadingState v-if="loading" />
|
||||
<ErrorState v-else-if="error" :message="error" :retry="loadProjects" />
|
||||
<div v-else-if="projects.length === 0" class="text-donut-muted text-center py-16">
|
||||
Aucun projet ne correspond aux critères.
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="text-xs text-donut-muted mb-4">
|
||||
{{ totalProjects }} projet{{ totalProjects > 1 ? 's' : '' }} trouvé{{ totalProjects > 1 ? 's' : '' }}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<ProjectCard v-for="p in projects" :key="p.id" :project="p" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user