Sprint 5 : integration et production -- securite, performance, API publique, documentation
Backend: rate limiter, security headers, blockchain cache service avec RPC, public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection pooling, structured logging, health check DB. Frontend: API retry/timeout, WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger + drawer, error boundary, offline banner, loading skeletons, dashboard enrichi. Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+), guide deploiement, politique securite. 123 tests, 155 fichiers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
const documents = useDocumentsStore()
|
||||
const decisions = useDecisionsStore()
|
||||
const mandates = useMandatesStore()
|
||||
const votes = useVotesStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
@@ -9,12 +12,15 @@ onMounted(async () => {
|
||||
await Promise.all([
|
||||
documents.fetchAll(),
|
||||
decisions.fetchAll(),
|
||||
mandates.fetchAll(),
|
||||
votes.fetchSessions(),
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/** Summary stats for the dashboard cards. */
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: 'Documents actifs',
|
||||
@@ -32,8 +38,64 @@ const stats = computed(() => [
|
||||
color: 'success' as const,
|
||||
to: '/decisions',
|
||||
},
|
||||
{
|
||||
label: 'Votes ouverts',
|
||||
value: openVoteSessions.value.length,
|
||||
total: votes.sessions.length,
|
||||
icon: 'i-lucide-vote',
|
||||
color: 'warning' as const,
|
||||
to: '/decisions',
|
||||
},
|
||||
{
|
||||
label: 'Mandats actifs',
|
||||
value: mandates.activeMandates.length,
|
||||
total: mandates.list.length,
|
||||
icon: 'i-lucide-user-check',
|
||||
color: 'info' as const,
|
||||
to: '/mandates',
|
||||
},
|
||||
])
|
||||
|
||||
/** Open vote sessions. */
|
||||
const openVoteSessions = computed(() => {
|
||||
return votes.sessions.filter(s => s.status === 'open')
|
||||
})
|
||||
|
||||
/** Last 5 decisions sorted by most recent. */
|
||||
const recentDecisions = computed(() => {
|
||||
return [...decisions.list]
|
||||
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
/** Last 5 vote sessions sorted by most recent. */
|
||||
const recentVotes = computed(() => {
|
||||
return [...votes.sessions]
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
/** Format a date string to a localized relative or absolute string. */
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffHours = diffMs / (1000 * 60 * 60)
|
||||
|
||||
if (diffHours < 1) {
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60))
|
||||
return diffMinutes <= 1 ? 'Il y a un instant' : `Il y a ${diffMinutes} min`
|
||||
}
|
||||
if (diffHours < 24) {
|
||||
return `Il y a ${Math.floor(diffHours)}h`
|
||||
}
|
||||
if (diffHours < 48) {
|
||||
return 'Hier'
|
||||
}
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
/** Section cards for the "Domaines" grid. */
|
||||
const sections = [
|
||||
{
|
||||
title: 'Documents de reference',
|
||||
@@ -77,57 +139,205 @@ const sections = [
|
||||
<div class="space-y-8">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Glibredecision
|
||||
</h1>
|
||||
<p class="mt-2 text-lg text-gray-600 dark:text-gray-400">
|
||||
<p class="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-400">
|
||||
Decisions collectives pour la communaute Duniter/G1
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions (authenticated users only) -->
|
||||
<div v-if="auth.isAuthenticated" class="flex flex-wrap gap-2 sm:gap-3">
|
||||
<UButton
|
||||
to="/decisions"
|
||||
icon="i-lucide-plus"
|
||||
label="Nouvelle decision"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="sm"
|
||||
/>
|
||||
<UButton
|
||||
to="/documents"
|
||||
icon="i-lucide-book-open"
|
||||
label="Voir les documents"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
/>
|
||||
<UButton
|
||||
to="/mandates"
|
||||
icon="i-lucide-user-check"
|
||||
label="Mandats"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
<template v-if="loading">
|
||||
<UCard v-for="i in 2" :key="i">
|
||||
<div class="space-y-3">
|
||||
<USkeleton class="h-4 w-32" />
|
||||
<USkeleton class="h-8 w-16" />
|
||||
<USkeleton class="h-3 w-24" />
|
||||
</div>
|
||||
</UCard>
|
||||
<LoadingSkeleton
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
:lines="2"
|
||||
card
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink v-for="stat in stats" :key="stat.label" :to="stat.to">
|
||||
<UCard class="hover:shadow-md transition-shadow">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ stat.label }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
<UCard class="hover:shadow-md transition-shadow h-full">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ stat.label }}
|
||||
</p>
|
||||
<p class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
{{ stat.value }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{{ stat.total }} au total
|
||||
</p>
|
||||
</div>
|
||||
<UIcon :name="stat.icon" class="text-3xl text-gray-400" />
|
||||
<UIcon
|
||||
:name="stat.icon"
|
||||
class="text-2xl sm:text-3xl text-gray-400 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Section cards -->
|
||||
<!-- Recent activity: decisions + votes side-by-side -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<!-- Recent decisions -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-scale" class="text-lg text-primary" />
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Decisions recentes
|
||||
</h3>
|
||||
</div>
|
||||
<UButton
|
||||
to="/decisions"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
color="neutral"
|
||||
label="Tout voir"
|
||||
trailing-icon="i-lucide-chevron-right"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="space-y-3">
|
||||
<LoadingSkeleton :lines="5" />
|
||||
</div>
|
||||
<div v-else-if="recentDecisions.length === 0" class="text-center py-6">
|
||||
<UIcon name="i-lucide-inbox" class="text-3xl text-gray-300 dark:text-gray-600 mx-auto" />
|
||||
<p class="text-sm text-gray-500 mt-2">Aucune decision pour le moment</p>
|
||||
</div>
|
||||
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<NuxtLink
|
||||
v-for="decision in recentDecisions"
|
||||
:key="decision.id"
|
||||
:to="`/decisions/${decision.id}`"
|
||||
class="flex items-center justify-between py-3 first:pt-0 last:pb-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 -mx-2 px-2 rounded transition-colors"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ decision.title }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
{{ formatDate(decision.updated_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge :status="decision.status" type="decision" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Recent votes -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-vote" class="text-lg text-warning" />
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Sessions de vote recentes
|
||||
</h3>
|
||||
</div>
|
||||
<UButton
|
||||
to="/decisions"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
color="neutral"
|
||||
label="Tout voir"
|
||||
trailing-icon="i-lucide-chevron-right"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loading" class="space-y-3">
|
||||
<LoadingSkeleton :lines="5" />
|
||||
</div>
|
||||
<div v-else-if="recentVotes.length === 0" class="text-center py-6">
|
||||
<UIcon name="i-lucide-inbox" class="text-3xl text-gray-300 dark:text-gray-600 mx-auto" />
|
||||
<p class="text-sm text-gray-500 mt-2">Aucune session de vote pour le moment</p>
|
||||
</div>
|
||||
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<div
|
||||
v-for="session in recentVotes"
|
||||
:key="session.id"
|
||||
class="py-3 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<StatusBadge :status="session.status" type="vote" />
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ session.votes_total }} vote{{ session.votes_total !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1.5">
|
||||
<!-- Mini progress bar for vote results -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-success-500 rounded-full transition-all"
|
||||
:style="{ width: session.votes_total > 0 ? `${(session.votes_for / session.votes_total) * 100}%` : '0%' }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 flex-shrink-0">
|
||||
{{ session.votes_for }}/{{ session.votes_total }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{{ formatDate(session.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Section cards (Domaines) -->
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<h2 class="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Domaines
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
<NuxtLink v-for="section in sections" :key="section.title" :to="section.to">
|
||||
<UCard class="h-full hover:shadow-md transition-shadow">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon :name="section.icon" class="text-2xl text-primary" />
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<UIcon :name="section.icon" class="text-2xl text-primary flex-shrink-0" />
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -145,7 +355,7 @@ const sections = [
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-calculator" class="text-xl text-primary" />
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Formule de seuil WoT
|
||||
</h3>
|
||||
</div>
|
||||
@@ -153,10 +363,10 @@ const sections = [
|
||||
Le seuil d'adoption s'adapte dynamiquement a la participation :
|
||||
faible participation = quasi-unanimite requise ; forte participation = majorite simple suffisante.
|
||||
</p>
|
||||
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono">
|
||||
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-xs sm:text-sm font-mono overflow-x-auto">
|
||||
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
|
||||
</code>
|
||||
<div class="flex flex-wrap gap-4 text-xs text-gray-500">
|
||||
<div class="flex flex-wrap gap-3 sm:gap-4 text-xs text-gray-500">
|
||||
<span>C = constante de base</span>
|
||||
<span>B = exposant de base</span>
|
||||
<span>W = taille WoT</span>
|
||||
|
||||
Reference in New Issue
Block a user