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:
@@ -30,7 +30,20 @@ const navigationItems = [
|
||||
},
|
||||
]
|
||||
|
||||
/** Mobile drawer state. */
|
||||
const mobileMenuOpen = ref(false)
|
||||
|
||||
/** Close mobile menu on route change. */
|
||||
watch(() => route.path, () => {
|
||||
mobileMenuOpen.value = false
|
||||
})
|
||||
|
||||
/** WebSocket connection and notifications. */
|
||||
const ws = useWebSocket()
|
||||
const { setupWsNotifications } = useNotifications()
|
||||
|
||||
onMounted(async () => {
|
||||
// Hydrate auth from localStorage
|
||||
auth.hydrateFromStorage()
|
||||
if (auth.token) {
|
||||
try {
|
||||
@@ -39,30 +52,62 @@ onMounted(async () => {
|
||||
auth.logout()
|
||||
}
|
||||
}
|
||||
|
||||
// Connect WebSocket and setup notifications
|
||||
ws.connect()
|
||||
setupWsNotifications(ws)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ws.disconnect()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<!-- Offline detection banner -->
|
||||
<OfflineBanner />
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||||
<header class="sticky top-0 z-30 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<NuxtLink to="/" class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-vote" class="text-primary text-2xl" />
|
||||
<span class="text-xl font-bold text-gray-900 dark:text-white">Glibredecision</span>
|
||||
</NuxtLink>
|
||||
<!-- Left: Hamburger (mobile) + Logo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Hamburger menu button for mobile -->
|
||||
<UButton
|
||||
class="md:hidden"
|
||||
icon="i-lucide-menu"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
aria-label="Ouvrir le menu"
|
||||
@click="mobileMenuOpen = true"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<NuxtLink to="/" class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-vote" class="text-primary text-2xl" />
|
||||
<span class="text-xl font-bold text-gray-900 dark:text-white">Glibredecision</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Right: Auth controls -->
|
||||
<div class="flex items-center gap-2 sm:gap-4">
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<UBadge
|
||||
:color="auth.identity?.is_smith ? 'success' : 'neutral'"
|
||||
variant="subtle"
|
||||
class="hidden sm:inline-flex"
|
||||
>
|
||||
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 12) + '...' }}
|
||||
</UBadge>
|
||||
<UBadge v-if="auth.identity?.is_techcomm" color="info" variant="subtle">
|
||||
<UBadge
|
||||
v-if="auth.identity?.is_techcomm"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
class="hidden sm:inline-flex"
|
||||
>
|
||||
Comite Tech
|
||||
</UBadge>
|
||||
<UButton
|
||||
@@ -70,6 +115,7 @@ onMounted(async () => {
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
aria-label="Se deconnecter"
|
||||
@click="auth.logout()"
|
||||
/>
|
||||
</template>
|
||||
@@ -87,11 +133,68 @@ onMounted(async () => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Mobile navigation drawer (USlideover) -->
|
||||
<USlideover
|
||||
v-model:open="mobileMenuOpen"
|
||||
side="left"
|
||||
title="Navigation"
|
||||
:ui="{ width: 'max-w-xs' }"
|
||||
>
|
||||
<template #body>
|
||||
<nav class="py-2">
|
||||
<NuxtLink
|
||||
v-for="item in navigationItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-lg transition-colors"
|
||||
:class="
|
||||
route.path.startsWith(item.to)
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 text-primary'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
|
||||
"
|
||||
@click="mobileMenuOpen = false"
|
||||
>
|
||||
<UIcon :name="item.icon" class="text-lg" />
|
||||
<span>{{ item.label }}</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile user info -->
|
||||
<div
|
||||
v-if="auth.isAuthenticated"
|
||||
class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<UIcon name="i-lucide-user" class="text-lg" />
|
||||
<span>{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 16) + '...' }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<UBadge
|
||||
v-if="auth.identity?.is_smith"
|
||||
color="success"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Forgeron
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-if="auth.identity?.is_techcomm"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Comite Tech
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
|
||||
<!-- Main content with sidebar -->
|
||||
<div class="flex flex-1">
|
||||
<!-- Sidebar navigation -->
|
||||
<aside class="w-64 border-r border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50 hidden md:block">
|
||||
<nav class="p-4">
|
||||
<!-- Desktop sidebar navigation -->
|
||||
<aside class="w-64 border-r border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50 hidden md:block flex-shrink-0">
|
||||
<nav class="p-4 sticky top-16">
|
||||
<UNavigationMenu
|
||||
:items="navigationItems"
|
||||
orientation="vertical"
|
||||
@@ -100,24 +203,40 @@ onMounted(async () => {
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile navigation -->
|
||||
<div class="md:hidden border-b border-gray-200 dark:border-gray-800 w-full absolute top-16 bg-white dark:bg-gray-900 z-10">
|
||||
<UNavigationMenu
|
||||
:items="navigationItems"
|
||||
class="px-4 py-2 overflow-x-auto"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Page content -->
|
||||
<main class="flex-1 p-4 sm:p-6 lg:p-8 md:mt-0 mt-12">
|
||||
<NuxtPage />
|
||||
<!-- Page content with error boundary -->
|
||||
<main class="flex-1 min-w-0 p-4 sm:p-6 lg:p-8">
|
||||
<ErrorBoundary>
|
||||
<NuxtPage />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- WebSocket error banner -->
|
||||
<Transition name="slide-up">
|
||||
<div
|
||||
v-if="ws.error.value"
|
||||
class="fixed bottom-0 left-0 right-0 z-40 bg-error-100 dark:bg-error-900/50 border-t border-error-300 dark:border-error-700 px-4 py-3 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-error-800 dark:text-error-200">
|
||||
<UIcon name="i-lucide-plug-zap" class="text-lg flex-shrink-0" />
|
||||
<span>{{ ws.error.value }}</span>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="soft"
|
||||
color="error"
|
||||
label="Reconnecter"
|
||||
icon="i-lucide-refresh-cw"
|
||||
@click="ws.disconnect(); ws.connect()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="flex items-center justify-between text-sm text-gray-500">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-gray-500">
|
||||
<span>Glibredecision v0.1.0 - Decisions collectives pour Duniter/G1</span>
|
||||
<span>Licence libre</span>
|
||||
</div>
|
||||
@@ -126,3 +245,22 @@ onMounted(async () => {
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-up-enter-to,
|
||||
.slide-up-leave-from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
70
frontend/app/components/common/ErrorBoundary.vue
Normal file
70
frontend/app/components/common/ErrorBoundary.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Error boundary component.
|
||||
*
|
||||
* Wraps slot content with NuxtErrorBoundary and displays a user-friendly
|
||||
* error message in French when child components crash.
|
||||
* Logs error details to console and emits error event for monitoring.
|
||||
*/
|
||||
|
||||
const emit = defineEmits<{
|
||||
error: [error: any]
|
||||
}>()
|
||||
|
||||
const hasError = ref(false)
|
||||
const errorDetails = ref<string | null>(null)
|
||||
|
||||
function handleError(error: any) {
|
||||
hasError.value = true
|
||||
errorDetails.value = error?.message || error?.toString() || 'Erreur inconnue'
|
||||
|
||||
// Log to console for debugging
|
||||
console.error('[ErrorBoundary] Erreur capturee:', error)
|
||||
|
||||
// Emit for external monitoring
|
||||
emit('error', error)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
hasError.value = false
|
||||
errorDetails.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtErrorBoundary @error="handleError">
|
||||
<template v-if="!hasError">
|
||||
<slot />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div class="max-w-md space-y-4">
|
||||
<UIcon
|
||||
name="i-lucide-alert-triangle"
|
||||
class="text-5xl text-warning mx-auto"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Une erreur est survenue
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Un probleme inattendu s'est produit lors du chargement de ce contenu.
|
||||
Vous pouvez essayer de recharger cette section.
|
||||
</p>
|
||||
<p
|
||||
v-if="errorDetails"
|
||||
class="text-xs text-gray-400 dark:text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded"
|
||||
>
|
||||
{{ errorDetails }}
|
||||
</p>
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
label="Reessayer"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
@click="retry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NuxtErrorBoundary>
|
||||
</template>
|
||||
81
frontend/app/components/common/LoadingSkeleton.vue
Normal file
81
frontend/app/components/common/LoadingSkeleton.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Reusable skeleton loader component.
|
||||
*
|
||||
* Provides multiple skeleton variants for loading states:
|
||||
* - Card: card layout with title and content lines
|
||||
* - List: multiple rows with optional avatar
|
||||
* - Detail: detailed view with mixed content
|
||||
*
|
||||
* Uses Nuxt UI USkeleton components.
|
||||
*/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Number of skeleton lines to display (default: 3). */
|
||||
lines?: number
|
||||
/** Show an avatar circle placeholder. */
|
||||
avatar?: boolean
|
||||
/** Render as a card skeleton with border and padding. */
|
||||
card?: boolean
|
||||
}>(),
|
||||
{
|
||||
lines: 3,
|
||||
avatar: false,
|
||||
card: false,
|
||||
},
|
||||
)
|
||||
|
||||
/** Generate varying line widths for a natural appearance. */
|
||||
const lineWidths = computed(() => {
|
||||
const widths = ['w-full', 'w-3/4', 'w-5/6', 'w-2/3', 'w-4/5']
|
||||
return Array.from({ length: props.lines }, (_, i) => widths[i % widths.length])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Card variant -->
|
||||
<UCard v-if="card" class="animate-pulse">
|
||||
<div class="space-y-4">
|
||||
<!-- Optional avatar row -->
|
||||
<div v-if="avatar" class="flex items-center gap-3">
|
||||
<USkeleton class="h-10 w-10 rounded-full" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<USkeleton class="h-4 w-1/3" />
|
||||
<USkeleton class="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title line -->
|
||||
<USkeleton class="h-5 w-2/3" />
|
||||
|
||||
<!-- Content lines -->
|
||||
<div class="space-y-2">
|
||||
<USkeleton
|
||||
v-for="(width, i) in lineWidths"
|
||||
:key="i"
|
||||
class="h-3"
|
||||
:class="width"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- List / default variant -->
|
||||
<div v-else class="space-y-3 animate-pulse">
|
||||
<div
|
||||
v-for="(width, i) in lineWidths"
|
||||
:key="i"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<!-- Optional avatar per line -->
|
||||
<USkeleton v-if="avatar" class="h-8 w-8 rounded-full flex-shrink-0" />
|
||||
|
||||
<!-- Line content -->
|
||||
<div class="flex-1 space-y-1">
|
||||
<USkeleton class="h-3" :class="width" />
|
||||
<USkeleton v-if="i === 0" class="h-2 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
95
frontend/app/components/common/OfflineBanner.vue
Normal file
95
frontend/app/components/common/OfflineBanner.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Offline detection banner.
|
||||
*
|
||||
* Uses navigator.onLine and online/offline events to detect
|
||||
* network connectivity changes. Shows a warning banner when
|
||||
* offline and a brief success message when back online.
|
||||
*/
|
||||
|
||||
const isOnline = ref(true)
|
||||
const showReconnected = ref(false)
|
||||
|
||||
let reconnectedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleOffline() {
|
||||
isOnline.value = false
|
||||
showReconnected.value = false
|
||||
if (reconnectedTimer) {
|
||||
clearTimeout(reconnectedTimer)
|
||||
reconnectedTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnline() {
|
||||
isOnline.value = true
|
||||
showReconnected.value = true
|
||||
|
||||
// Show "reconnected" message briefly then hide
|
||||
reconnectedTimer = setTimeout(() => {
|
||||
showReconnected.value = false
|
||||
reconnectedTimer = null
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isOnline.value = navigator.onLine
|
||||
window.addEventListener('offline', handleOffline)
|
||||
window.addEventListener('online', handleOnline)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
if (reconnectedTimer) {
|
||||
clearTimeout(reconnectedTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="slide-down">
|
||||
<div
|
||||
v-if="!isOnline"
|
||||
class="bg-warning-100 dark:bg-warning-900/50 border-b border-warning-300 dark:border-warning-700 px-4 py-2 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-warning-800 dark:text-warning-200">
|
||||
<UIcon name="i-lucide-wifi-off" class="text-lg flex-shrink-0" />
|
||||
<span>Vous etes hors ligne. Certaines fonctionnalites sont indisponibles.</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-down">
|
||||
<div
|
||||
v-if="showReconnected && isOnline"
|
||||
class="bg-success-100 dark:bg-success-900/50 border-b border-success-300 dark:border-success-700 px-4 py-2 text-center"
|
||||
role="status"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-success-800 dark:text-success-200">
|
||||
<UIcon name="i-lucide-wifi" class="text-lg flex-shrink-0" />
|
||||
<span>Connexion retablie</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-down-enter-to,
|
||||
.slide-down-leave-from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,73 @@
|
||||
*
|
||||
* Uses the runtime config `apiBase` and automatically injects the Bearer token
|
||||
* from the auth store when available.
|
||||
*
|
||||
* Production-grade features:
|
||||
* - Error interceptor with user-friendly French messages
|
||||
* - Retry logic with exponential backoff for 5xx errors
|
||||
* - Request timeout (30s default)
|
||||
* - AbortController support for cancellation
|
||||
*/
|
||||
|
||||
/** Map of HTTP status codes to user-friendly French error messages. */
|
||||
const HTTP_ERROR_MESSAGES: Record<number, string> = {
|
||||
401: 'Session expirée, veuillez vous reconnecter',
|
||||
403: 'Accès non autorisé',
|
||||
404: 'Ressource introuvable',
|
||||
409: 'Conflit avec une ressource existante',
|
||||
422: 'Données invalides',
|
||||
429: 'Trop de requêtes, veuillez patienter',
|
||||
500: 'Erreur serveur, veuillez réessayer',
|
||||
502: 'Service temporairement indisponible',
|
||||
503: 'Service en maintenance, veuillez réessayer',
|
||||
504: 'Délai de réponse dépassé',
|
||||
}
|
||||
|
||||
/** Default request timeout in milliseconds. */
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Maximum number of retry attempts for 5xx errors. */
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
/** Base delay for exponential backoff in milliseconds. */
|
||||
const BASE_BACKOFF_MS = 1_000
|
||||
|
||||
export interface ApiOptions extends Record<string, any> {
|
||||
/** Custom timeout in milliseconds (default: 30000). */
|
||||
timeout?: number
|
||||
/** External AbortController for request cancellation. */
|
||||
signal?: AbortSignal
|
||||
/** Disable automatic retry for this request. */
|
||||
noRetry?: boolean
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
status: number
|
||||
message: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-friendly error message from an HTTP status code.
|
||||
*/
|
||||
function resolveErrorMessage(status: number, fallback?: string): string {
|
||||
return HTTP_ERROR_MESSAGES[status] || fallback || 'Une erreur inattendue est survenue'
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the specified duration in milliseconds.
|
||||
*/
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an HTTP status code is retryable (5xx server errors).
|
||||
*/
|
||||
function isRetryable(status: number): boolean {
|
||||
return status >= 500 && status < 600
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
const config = useRuntimeConfig()
|
||||
const auth = useAuthStore()
|
||||
@@ -12,20 +78,119 @@ export function useApi() {
|
||||
* Perform a typed fetch against the backend API.
|
||||
*
|
||||
* @param path - API path relative to apiBase, e.g. "/documents"
|
||||
* @param options - $fetch options (method, body, query, headers, etc.)
|
||||
* @param options - $fetch options (method, body, query, headers, timeout, signal, noRetry)
|
||||
* @returns Typed response
|
||||
* @throws ApiError with status, message, and optional detail
|
||||
*/
|
||||
async function $api<T>(path: string, options: Record<string, any> = {}): Promise<T> {
|
||||
async function $api<T>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
const {
|
||||
timeout = DEFAULT_TIMEOUT_MS,
|
||||
signal: externalSignal,
|
||||
noRetry = false,
|
||||
...fetchOptions
|
||||
} = options
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (auth.token) {
|
||||
headers.Authorization = `Bearer ${auth.token}`
|
||||
}
|
||||
|
||||
return await $fetch<T>(`${config.public.apiBase}${path}`, {
|
||||
...options,
|
||||
headers: { ...headers, ...options.headers },
|
||||
})
|
||||
const maxAttempts = noRetry ? 1 : MAX_RETRIES
|
||||
let lastError: any = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
// Create an AbortController for timeout management
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
|
||||
// If an external signal is provided, forward its abort
|
||||
let externalAbortHandler: (() => void) | null = null
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
clearTimeout(timeoutId)
|
||||
throw _createApiError(0, 'Requête annulée')
|
||||
}
|
||||
externalAbortHandler = () => controller.abort()
|
||||
externalSignal.addEventListener('abort', externalAbortHandler, { once: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await $fetch<T>(`${config.public.apiBase}${path}`, {
|
||||
...fetchOptions,
|
||||
headers: { ...headers, ...fetchOptions.headers },
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (err: any) {
|
||||
lastError = err
|
||||
|
||||
const status = err?.response?.status || err?.status || 0
|
||||
|
||||
// Handle 401: auto-logout for expired sessions
|
||||
if (status === 401 && auth.token) {
|
||||
auth.token = null
|
||||
auth.identity = null
|
||||
auth._clearToken()
|
||||
navigateTo('/login')
|
||||
}
|
||||
|
||||
// Handle abort (timeout or external cancellation)
|
||||
if (err?.name === 'AbortError' || controller.signal.aborted) {
|
||||
if (externalSignal?.aborted) {
|
||||
throw _createApiError(0, 'Requête annulée')
|
||||
}
|
||||
throw _createApiError(0, 'Délai de réponse dépassé')
|
||||
}
|
||||
|
||||
// Retry only for 5xx errors and if we have remaining attempts
|
||||
if (isRetryable(status) && attempt < maxAttempts) {
|
||||
const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1)
|
||||
await delay(backoffMs)
|
||||
continue
|
||||
}
|
||||
|
||||
// Build and throw a structured error
|
||||
const detail = err?.data?.detail || err?.message || undefined
|
||||
const message = resolveErrorMessage(status, detail)
|
||||
throw _createApiError(status, message, detail)
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
if (externalAbortHandler && externalSignal) {
|
||||
externalSignal.removeEventListener('abort', externalAbortHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: should not reach here, but handle gracefully
|
||||
const fallbackStatus = lastError?.response?.status || lastError?.status || 0
|
||||
const fallbackDetail = lastError?.data?.detail || lastError?.message || undefined
|
||||
throw _createApiError(fallbackStatus, resolveErrorMessage(fallbackStatus, fallbackDetail), fallbackDetail)
|
||||
}
|
||||
|
||||
return { $api }
|
||||
/**
|
||||
* Create a structured ApiError object.
|
||||
*/
|
||||
function _createApiError(status: number, message: string, detail?: string): ApiError {
|
||||
const error: ApiError = { status, message }
|
||||
if (detail) error.detail = detail
|
||||
return error
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an AbortController for manual request cancellation.
|
||||
* Useful for component unmount cleanup.
|
||||
*
|
||||
* @example
|
||||
* const { controller, signal } = createAbortController()
|
||||
* await $api('/documents', { signal })
|
||||
* // On unmount:
|
||||
* controller.abort()
|
||||
*/
|
||||
function createAbortController() {
|
||||
const controller = new AbortController()
|
||||
return { controller, signal: controller.signal }
|
||||
}
|
||||
|
||||
return { $api, createAbortController }
|
||||
}
|
||||
|
||||
180
frontend/app/composables/useNotifications.ts
Normal file
180
frontend/app/composables/useNotifications.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Composable for toast notifications using Nuxt UI.
|
||||
*
|
||||
* Provides typed notification helpers with French messages and
|
||||
* integration with WebSocket events for real-time notifications.
|
||||
*/
|
||||
|
||||
export type NotificationType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface NotifyOptions {
|
||||
/** Toast title. */
|
||||
title: string
|
||||
/** Toast description (optional). */
|
||||
description?: string
|
||||
/** Notification type. */
|
||||
type?: NotificationType
|
||||
/** Auto-close duration in milliseconds (default: 5000). */
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/** Map notification types to Nuxt UI toast color props. */
|
||||
const TYPE_COLORS: Record<NotificationType, string> = {
|
||||
success: 'success',
|
||||
error: 'error',
|
||||
warning: 'warning',
|
||||
info: 'info',
|
||||
}
|
||||
|
||||
/** Map notification types to Lucide icon names. */
|
||||
const TYPE_ICONS: Record<NotificationType, string> = {
|
||||
success: 'i-lucide-check-circle',
|
||||
error: 'i-lucide-alert-circle',
|
||||
warning: 'i-lucide-alert-triangle',
|
||||
info: 'i-lucide-info',
|
||||
}
|
||||
|
||||
/** Default duration for toasts (ms). */
|
||||
const DEFAULT_DURATION = 5_000
|
||||
|
||||
export function useNotifications() {
|
||||
const toast = useToast()
|
||||
|
||||
/**
|
||||
* Show a toast notification.
|
||||
*
|
||||
* @param options - Notification options (title, description, type, duration)
|
||||
*/
|
||||
function notify(options: NotifyOptions): void
|
||||
function notify(title: string, description?: string, type?: NotificationType): void
|
||||
function notify(
|
||||
titleOrOptions: string | NotifyOptions,
|
||||
description?: string,
|
||||
type?: NotificationType,
|
||||
): void {
|
||||
let opts: NotifyOptions
|
||||
|
||||
if (typeof titleOrOptions === 'string') {
|
||||
opts = {
|
||||
title: titleOrOptions,
|
||||
description,
|
||||
type: type || 'info',
|
||||
}
|
||||
} else {
|
||||
opts = titleOrOptions
|
||||
}
|
||||
|
||||
const notifType = opts.type || 'info'
|
||||
|
||||
toast.add({
|
||||
title: opts.title,
|
||||
description: opts.description,
|
||||
icon: TYPE_ICONS[notifType],
|
||||
color: TYPE_COLORS[notifType] as any,
|
||||
duration: opts.duration ?? DEFAULT_DURATION,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a success toast.
|
||||
*/
|
||||
function notifySuccess(message: string, description?: string): void {
|
||||
notify({
|
||||
title: message,
|
||||
description,
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an error toast.
|
||||
*/
|
||||
function notifyError(message: string, description?: string): void {
|
||||
notify({
|
||||
title: message,
|
||||
description,
|
||||
type: 'error',
|
||||
duration: 8_000,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a warning toast.
|
||||
*/
|
||||
function notifyWarning(message: string, description?: string): void {
|
||||
notify({
|
||||
title: message,
|
||||
description,
|
||||
type: 'warning',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show an info toast.
|
||||
*/
|
||||
function notifyInfo(message: string, description?: string): void {
|
||||
notify({
|
||||
title: message,
|
||||
description,
|
||||
type: 'info',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup WebSocket event listeners that auto-show notifications.
|
||||
* Call this once in app.vue or a layout component.
|
||||
*/
|
||||
function setupWsNotifications(wsComposable: ReturnType<typeof useWebSocket>): void {
|
||||
wsComposable.onVoteSubmitted((data) => {
|
||||
notifyInfo(
|
||||
'Nouveau vote enregistre',
|
||||
data?.session_title || 'Un vote a ete soumis dans une session active.',
|
||||
)
|
||||
})
|
||||
|
||||
wsComposable.onDecisionAdvanced((data) => {
|
||||
notifySuccess(
|
||||
'Decision avancee',
|
||||
data?.title
|
||||
? `La decision "${data.title}" est passee a l'etape suivante.`
|
||||
: 'Une decision a progresse dans son processus.',
|
||||
)
|
||||
})
|
||||
|
||||
wsComposable.onMandateUpdated((data) => {
|
||||
notifyInfo(
|
||||
'Mandat mis a jour',
|
||||
data?.title
|
||||
? `Le mandat "${data.title}" a ete modifie.`
|
||||
: 'Un mandat a ete mis a jour.',
|
||||
)
|
||||
})
|
||||
|
||||
wsComposable.onDocumentChanged((data) => {
|
||||
notifyInfo(
|
||||
'Document modifie',
|
||||
data?.title
|
||||
? `Le document "${data.title}" a ete modifie.`
|
||||
: 'Un document de reference a ete modifie.',
|
||||
)
|
||||
})
|
||||
|
||||
wsComposable.onSanctuaryArchived((data) => {
|
||||
notifySuccess(
|
||||
'Document archive au sanctuaire',
|
||||
data?.title
|
||||
? `"${data.title}" a ete ancre sur IPFS.`
|
||||
: 'Un document a ete archive de maniere immuable.',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
notify,
|
||||
notifySuccess,
|
||||
notifyError,
|
||||
notifyWarning,
|
||||
notifyInfo,
|
||||
setupWsNotifications,
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,73 @@
|
||||
/**
|
||||
* Composable for WebSocket connectivity to receive live vote updates.
|
||||
* Composable for WebSocket connectivity to receive live updates.
|
||||
*
|
||||
* Connects to the backend WS endpoint and allows subscribing to
|
||||
* individual vote session channels for real-time tally updates.
|
||||
*
|
||||
* Production-grade features:
|
||||
* - Bearer token authentication via query param
|
||||
* - Heartbeat (ping every 25s, pong expected within 10s)
|
||||
* - Exponential backoff on reconnect (1s, 2s, 4s, 8s, max 30s)
|
||||
* - Max reconnect attempts: 10
|
||||
* - Typed event handlers for domain events
|
||||
* - Message queue during disconnection with replay on reconnect
|
||||
*/
|
||||
|
||||
/** WebSocket event types from the backend. */
|
||||
export type WsEventType =
|
||||
| 'vote_submitted'
|
||||
| 'decision_advanced'
|
||||
| 'mandate_updated'
|
||||
| 'document_changed'
|
||||
| 'sanctuary_archived'
|
||||
| 'pong'
|
||||
| 'error'
|
||||
|
||||
/** Typed WebSocket message from the backend. */
|
||||
export interface WsMessage {
|
||||
event: WsEventType
|
||||
data: any
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
/** Event handler callback type. */
|
||||
type WsEventHandler = (data: any) => void
|
||||
|
||||
/** Maximum reconnect attempts before giving up. */
|
||||
const MAX_RECONNECT_ATTEMPTS = 10
|
||||
|
||||
/** Base delay for reconnection backoff (ms). */
|
||||
const RECONNECT_BASE_MS = 1_000
|
||||
|
||||
/** Maximum delay between reconnection attempts (ms). */
|
||||
const RECONNECT_MAX_MS = 30_000
|
||||
|
||||
/** Interval between heartbeat pings (ms). */
|
||||
const HEARTBEAT_INTERVAL_MS = 25_000
|
||||
|
||||
/** Maximum time to wait for a pong response (ms). */
|
||||
const PONG_TIMEOUT_MS = 10_000
|
||||
|
||||
export function useWebSocket() {
|
||||
const config = useRuntimeConfig()
|
||||
const auth = useAuthStore()
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
let pongTimeoutTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectAttempts = 0
|
||||
let intentionalClose = false
|
||||
|
||||
const connected = ref(false)
|
||||
const lastMessage = ref<any>(null)
|
||||
const lastMessage = ref<WsMessage | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
/** Message queue: messages sent while disconnected are replayed on reconnect. */
|
||||
const messageQueue: string[] = []
|
||||
|
||||
/** Typed event handlers registry. */
|
||||
const eventHandlers: Map<WsEventType, Set<WsEventHandler>> = new Map()
|
||||
|
||||
/**
|
||||
* Open a WebSocket connection to the backend live endpoint.
|
||||
@@ -19,23 +77,40 @@ export function useWebSocket() {
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = config.public.apiBase
|
||||
// Reset state
|
||||
error.value = null
|
||||
intentionalClose = false
|
||||
|
||||
// Build WS URL with authentication token
|
||||
let wsUrl = config.public.apiBase
|
||||
.replace(/^http/, 'ws')
|
||||
.replace(/\/api\/v1$/, '/api/v1/ws/live')
|
||||
|
||||
if (auth.token) {
|
||||
wsUrl += `?token=${encodeURIComponent(auth.token)}`
|
||||
}
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
connected.value = true
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
reconnectAttempts = 0
|
||||
error.value = null
|
||||
|
||||
// Start heartbeat
|
||||
_startHeartbeat()
|
||||
|
||||
// Replay queued messages
|
||||
_flushMessageQueue()
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
connected.value = false
|
||||
reconnect()
|
||||
_stopHeartbeat()
|
||||
|
||||
if (!intentionalClose) {
|
||||
_scheduleReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
@@ -44,9 +119,20 @@ export function useWebSocket() {
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
lastMessage.value = JSON.parse(event.data)
|
||||
const message: WsMessage = JSON.parse(event.data)
|
||||
lastMessage.value = message
|
||||
|
||||
// Handle pong for heartbeat
|
||||
if (message.event === 'pong') {
|
||||
_onPongReceived()
|
||||
return
|
||||
}
|
||||
|
||||
// Dispatch to typed event handlers
|
||||
_dispatchEvent(message)
|
||||
} catch {
|
||||
lastMessage.value = event.data
|
||||
// Non-JSON message, store as-is
|
||||
lastMessage.value = { event: 'error', data: event.data }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -55,46 +141,223 @@ export function useWebSocket() {
|
||||
* Subscribe to real-time updates for a vote session.
|
||||
*/
|
||||
function subscribe(sessionId: string) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ action: 'subscribe', session_id: sessionId }))
|
||||
}
|
||||
_send(JSON.stringify({ action: 'subscribe', session_id: sessionId }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a vote session's updates.
|
||||
*/
|
||||
function unsubscribe(sessionId: string) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ action: 'unsubscribe', session_id: sessionId }))
|
||||
}
|
||||
_send(JSON.stringify({ action: 'unsubscribe', session_id: sessionId }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close the WebSocket connection.
|
||||
*/
|
||||
function disconnect() {
|
||||
intentionalClose = true
|
||||
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
_stopHeartbeat()
|
||||
|
||||
if (ws) {
|
||||
ws.onclose = null
|
||||
ws.onerror = null
|
||||
ws.onmessage = null
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
connected.value = false
|
||||
reconnectAttempts = 0
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// ---- Typed event handler registration ----
|
||||
|
||||
/**
|
||||
* Register a handler for when a vote is submitted.
|
||||
*/
|
||||
function onVoteSubmitted(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('vote_submitted', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnection attempt after a delay.
|
||||
* Register a handler for when a decision advances to the next step.
|
||||
*/
|
||||
function reconnect() {
|
||||
function onDecisionAdvanced(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('decision_advanced', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for when a mandate is updated.
|
||||
*/
|
||||
function onMandateUpdated(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('mandate_updated', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for when a document is changed.
|
||||
*/
|
||||
function onDocumentChanged(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('document_changed', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a handler for when a document is archived to the sanctuary.
|
||||
*/
|
||||
function onSanctuaryArchived(handler: WsEventHandler): () => void {
|
||||
return _addEventHandler('sanctuary_archived', handler)
|
||||
}
|
||||
|
||||
// ---- Internal helpers ----
|
||||
|
||||
/**
|
||||
* Send a message via WebSocket, or queue it if disconnected.
|
||||
*/
|
||||
function _send(message: string) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(message)
|
||||
} else {
|
||||
messageQueue.push(message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay all queued messages after reconnection.
|
||||
*/
|
||||
function _flushMessageQueue() {
|
||||
while (messageQueue.length > 0) {
|
||||
const message = messageQueue.shift()!
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(message)
|
||||
} else {
|
||||
// Put it back if connection dropped again
|
||||
messageQueue.unshift(message)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an event handler and return an unsubscribe function.
|
||||
*/
|
||||
function _addEventHandler(event: WsEventType, handler: WsEventHandler): () => void {
|
||||
if (!eventHandlers.has(event)) {
|
||||
eventHandlers.set(event, new Set())
|
||||
}
|
||||
eventHandlers.get(event)!.add(handler)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
eventHandlers.get(event)?.delete(handler)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a received message to all registered handlers for its event type.
|
||||
*/
|
||||
function _dispatchEvent(message: WsMessage) {
|
||||
const handlers = eventHandlers.get(message.event)
|
||||
if (handlers) {
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(message.data)
|
||||
} catch (err) {
|
||||
console.error(`[WS] Erreur dans le handler pour "${message.event}":`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the heartbeat: send ping every 25s and expect pong within 10s.
|
||||
*/
|
||||
function _startHeartbeat() {
|
||||
_stopHeartbeat()
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ action: 'ping' }))
|
||||
|
||||
// Start pong timeout
|
||||
pongTimeoutTimer = setTimeout(() => {
|
||||
console.warn('[WS] Pong non recu dans le delai imparti, reconnexion...')
|
||||
// Force close and reconnect
|
||||
if (ws) {
|
||||
ws.close()
|
||||
}
|
||||
}, PONG_TIMEOUT_MS)
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat timers.
|
||||
*/
|
||||
function _stopHeartbeat() {
|
||||
if (heartbeatTimer) {
|
||||
clearInterval(heartbeatTimer)
|
||||
heartbeatTimer = null
|
||||
}
|
||||
if (pongTimeoutTimer) {
|
||||
clearTimeout(pongTimeoutTimer)
|
||||
pongTimeoutTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pong response: cancel the pong timeout.
|
||||
*/
|
||||
function _onPongReceived() {
|
||||
if (pongTimeoutTimer) {
|
||||
clearTimeout(pongTimeoutTimer)
|
||||
pongTimeoutTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnection attempt with exponential backoff.
|
||||
*/
|
||||
function _scheduleReconnect() {
|
||||
if (reconnectTimer) return
|
||||
|
||||
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
||||
error.value = 'Connexion au serveur perdue. Veuillez rafraichir la page.'
|
||||
console.error(`[WS] Nombre maximum de tentatives de reconnexion atteint (${MAX_RECONNECT_ATTEMPTS})`)
|
||||
return
|
||||
}
|
||||
|
||||
const backoffMs = Math.min(
|
||||
RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts),
|
||||
RECONNECT_MAX_MS,
|
||||
)
|
||||
|
||||
reconnectAttempts++
|
||||
console.info(`[WS] Tentative de reconnexion ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} dans ${backoffMs}ms`)
|
||||
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
connect()
|
||||
}, 3000)
|
||||
}, backoffMs)
|
||||
}
|
||||
|
||||
return { connected, lastMessage, connect, subscribe, unsubscribe, disconnect }
|
||||
return {
|
||||
connected,
|
||||
lastMessage,
|
||||
error,
|
||||
connect,
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
disconnect,
|
||||
onVoteSubmitted,
|
||||
onDecisionAdvanced,
|
||||
onMandateUpdated,
|
||||
onDocumentChanged,
|
||||
onSanctuaryArchived,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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