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>
267 lines
7.8 KiB
Vue
267 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
const auth = useAuthStore()
|
|
const route = useRoute()
|
|
|
|
const navigationItems = [
|
|
{
|
|
label: 'Documents de reference',
|
|
icon: 'i-lucide-book-open',
|
|
to: '/documents',
|
|
},
|
|
{
|
|
label: 'Decisions',
|
|
icon: 'i-lucide-scale',
|
|
to: '/decisions',
|
|
},
|
|
{
|
|
label: 'Mandats',
|
|
icon: 'i-lucide-user-check',
|
|
to: '/mandates',
|
|
},
|
|
{
|
|
label: 'Protocoles',
|
|
icon: 'i-lucide-settings',
|
|
to: '/protocols',
|
|
},
|
|
{
|
|
label: 'Sanctuaire',
|
|
icon: 'i-lucide-archive',
|
|
to: '/sanctuary',
|
|
},
|
|
]
|
|
|
|
/** 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 {
|
|
await auth.fetchMe()
|
|
} catch {
|
|
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="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">
|
|
<!-- 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"
|
|
/>
|
|
|
|
<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"
|
|
class="hidden sm:inline-flex"
|
|
>
|
|
Comite Tech
|
|
</UBadge>
|
|
<UButton
|
|
icon="i-lucide-log-out"
|
|
variant="ghost"
|
|
color="neutral"
|
|
size="sm"
|
|
aria-label="Se deconnecter"
|
|
@click="auth.logout()"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<UButton
|
|
to="/login"
|
|
icon="i-lucide-log-in"
|
|
label="Se connecter"
|
|
variant="soft"
|
|
color="primary"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</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">
|
|
<!-- 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"
|
|
class="w-full"
|
|
/>
|
|
</nav>
|
|
</aside>
|
|
|
|
<!-- 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 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>
|
|
</div>
|
|
</footer>
|
|
</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>
|