Sprint 1 : scaffolding complet de Glibredecision
Plateforme de decisions collectives pour Duniter/G1. Backend FastAPI async + PostgreSQL (14 tables, 8 routers, 6 services, moteur de vote avec formule d'inertie WoT/Smith/TechComm). Frontend Nuxt 4 + Nuxt UI v3 + Pinia (9 pages, 5 stores). Infrastructure Docker + Woodpecker CI + Traefik. Documentation technique et utilisateur (15 fichiers). Seed : Licence G1, Engagement Forgeron v2.0.0, 4 protocoles de vote. 30 tests unitaires (formules, mode params, vote nuance) -- tous verts. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
128
frontend/app/app.vue
Normal file
128
frontend/app/app.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<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',
|
||||
},
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
auth.hydrateFromStorage()
|
||||
if (auth.token) {
|
||||
try {
|
||||
await auth.fetchMe()
|
||||
} catch {
|
||||
auth.logout()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<template v-if="auth.isAuthenticated">
|
||||
<UBadge
|
||||
:color="auth.identity?.is_smith ? 'success' : 'neutral'"
|
||||
variant="subtle"
|
||||
>
|
||||
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 12) + '...' }}
|
||||
</UBadge>
|
||||
<UBadge v-if="auth.identity?.is_techcomm" color="info" variant="subtle">
|
||||
Comite Tech
|
||||
</UBadge>
|
||||
<UButton
|
||||
icon="i-lucide-log-out"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
@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>
|
||||
|
||||
<!-- 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">
|
||||
<UNavigationMenu
|
||||
:items="navigationItems"
|
||||
orientation="vertical"
|
||||
class="w-full"
|
||||
/>
|
||||
</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 />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<span>Glibredecision v0.1.0 - Decisions collectives pour Duniter/G1</span>
|
||||
<span>Licence libre</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</UApp>
|
||||
</template>
|
||||
31
frontend/app/composables/useApi.ts
Normal file
31
frontend/app/composables/useApi.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Composable for making authenticated API calls to the Glibredecision backend.
|
||||
*
|
||||
* Uses the runtime config `apiBase` and automatically injects the Bearer token
|
||||
* from the auth store when available.
|
||||
*/
|
||||
export function useApi() {
|
||||
const config = useRuntimeConfig()
|
||||
const auth = useAuthStore()
|
||||
|
||||
/**
|
||||
* 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.)
|
||||
* @returns Typed response
|
||||
*/
|
||||
async function $api<T>(path: string, options: Record<string, any> = {}): Promise<T> {
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
return { $api }
|
||||
}
|
||||
271
frontend/app/pages/decisions/[id].vue
Normal file
271
frontend/app/pages/decisions/[id].vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const decisions = useDecisionsStore()
|
||||
|
||||
const decisionId = computed(() => route.params.id as string)
|
||||
|
||||
onMounted(async () => {
|
||||
await decisions.fetchById(decisionId.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
decisions.clearCurrent()
|
||||
})
|
||||
|
||||
watch(decisionId, async (newId) => {
|
||||
if (newId) {
|
||||
await decisions.fetchById(newId)
|
||||
}
|
||||
})
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'in_progress': return 'success'
|
||||
case 'draft': return 'warning'
|
||||
case 'completed': return 'info'
|
||||
case 'closed': return 'neutral'
|
||||
case 'pending': return 'warning'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'Actif'
|
||||
case 'in_progress': return 'En cours'
|
||||
case 'draft': return 'Brouillon'
|
||||
case 'completed': return 'Termine'
|
||||
case 'closed': return 'Ferme'
|
||||
case 'pending': return 'En attente'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = (decisionType: string) => {
|
||||
switch (decisionType) {
|
||||
case 'runtime_upgrade': return 'Runtime upgrade'
|
||||
case 'document_change': return 'Modification de document'
|
||||
case 'mandate_vote': return 'Vote de mandat'
|
||||
case 'custom': return 'Personnalise'
|
||||
default: return decisionType
|
||||
}
|
||||
}
|
||||
|
||||
const stepTypeLabel = (stepType: string) => {
|
||||
switch (stepType) {
|
||||
case 'qualification': return 'Qualification'
|
||||
case 'review': return 'Revue'
|
||||
case 'vote': return 'Vote'
|
||||
case 'execution': return 'Execution'
|
||||
case 'reporting': return 'Compte rendu'
|
||||
default: return stepType
|
||||
}
|
||||
}
|
||||
|
||||
const stepTypeIcon = (stepType: string) => {
|
||||
switch (stepType) {
|
||||
case 'qualification': return 'i-lucide-check-square'
|
||||
case 'review': return 'i-lucide-eye'
|
||||
case 'vote': return 'i-lucide-vote'
|
||||
case 'execution': return 'i-lucide-play'
|
||||
case 'reporting': return 'i-lucide-file-text'
|
||||
default: return 'i-lucide-circle'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const sortedSteps = computed(() => {
|
||||
if (!decisions.current) return []
|
||||
return [...decisions.current.steps].sort((a, b) => a.step_order - b.step_order)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Back link -->
|
||||
<div>
|
||||
<UButton
|
||||
to="/decisions"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-arrow-left"
|
||||
label="Retour aux decisions"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="decisions.loading">
|
||||
<div class="space-y-4">
|
||||
<USkeleton class="h-8 w-96" />
|
||||
<USkeleton class="h-4 w-64" />
|
||||
<div class="space-y-3 mt-8">
|
||||
<USkeleton v-for="i in 4" :key="i" class="h-20 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="decisions.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ decisions.error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Decision detail -->
|
||||
<template v-else-if="decisions.current">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ decisions.current.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<UBadge variant="subtle" color="primary">
|
||||
{{ typeLabel(decisions.current.decision_type) }}
|
||||
</UBadge>
|
||||
<UBadge :color="statusColor(decisions.current.status)" variant="subtle">
|
||||
{{ statusLabel(decisions.current.status) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description & Context -->
|
||||
<UCard v-if="decisions.current.description || decisions.current.context">
|
||||
<div class="space-y-4">
|
||||
<div v-if="decisions.current.description">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Description</h3>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{{ decisions.current.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="decisions.current.context">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-1">Contexte</h3>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{{ decisions.current.context }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Metadata -->
|
||||
<UCard>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-gray-500">Cree le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(decisions.current.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Mis a jour le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(decisions.current.updated_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Nombre d'etapes</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ decisions.current.steps.length }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Steps timeline -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Etapes du processus
|
||||
</h2>
|
||||
|
||||
<div v-if="sortedSteps.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-lucide-list-checks" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucune etape definie pour cette decision</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative">
|
||||
<!-- Timeline line -->
|
||||
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700" />
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="(step, index) in sortedSteps"
|
||||
:key="step.id"
|
||||
class="relative pl-12"
|
||||
>
|
||||
<!-- Timeline dot -->
|
||||
<div
|
||||
class="absolute left-2 w-5 h-5 rounded-full border-2 flex items-center justify-center"
|
||||
:class="{
|
||||
'bg-green-500 border-green-500': step.status === 'completed',
|
||||
'bg-primary border-primary': step.status === 'active' || step.status === 'in_progress',
|
||||
'bg-yellow-400 border-yellow-400': step.status === 'pending',
|
||||
'bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-600': step.status === 'draft',
|
||||
}"
|
||||
>
|
||||
<UIcon
|
||||
v-if="step.status === 'completed'"
|
||||
name="i-lucide-check"
|
||||
class="text-white text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UCard>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon :name="stepTypeIcon(step.step_type)" class="text-gray-500" />
|
||||
<span class="text-sm font-mono text-gray-400">Etape {{ step.step_order }}</span>
|
||||
<UBadge variant="subtle" color="neutral" size="xs">
|
||||
{{ stepTypeLabel(step.step_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<UBadge :color="statusColor(step.status)" variant="subtle" size="xs">
|
||||
{{ statusLabel(step.status) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
|
||||
{{ step.title }}
|
||||
</h3>
|
||||
|
||||
<p v-if="step.description" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="step.outcome" class="flex items-center gap-2 mt-2">
|
||||
<UIcon name="i-lucide-flag" class="text-gray-400" />
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Resultat : {{ step.outcome }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="step.vote_session_id" class="mt-2">
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
icon="i-lucide-vote"
|
||||
label="Voir la session de vote"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
190
frontend/app/pages/decisions/index.vue
Normal file
190
frontend/app/pages/decisions/index.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
const decisions = useDecisionsStore()
|
||||
|
||||
const filterType = ref<string | undefined>(undefined)
|
||||
const filterStatus = ref<string | undefined>(undefined)
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Tous les types', value: undefined },
|
||||
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
|
||||
{ label: 'Modification de document', value: 'document_change' },
|
||||
{ label: 'Vote de mandat', value: 'mandate_vote' },
|
||||
{ label: 'Personnalise', value: 'custom' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Tous les statuts', value: undefined },
|
||||
{ label: 'Brouillon', value: 'draft' },
|
||||
{ label: 'En cours', value: 'in_progress' },
|
||||
{ label: 'Actif', value: 'active' },
|
||||
{ label: 'Termine', value: 'completed' },
|
||||
{ label: 'Ferme', value: 'closed' },
|
||||
]
|
||||
|
||||
async function loadDecisions() {
|
||||
await decisions.fetchAll({
|
||||
decision_type: filterType.value,
|
||||
status: filterStatus.value,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDecisions()
|
||||
})
|
||||
|
||||
watch([filterType, filterStatus], () => {
|
||||
loadDecisions()
|
||||
})
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
case 'in_progress': return 'success'
|
||||
case 'draft': return 'warning'
|
||||
case 'completed': return 'info'
|
||||
case 'closed': return 'neutral'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'Actif'
|
||||
case 'in_progress': return 'En cours'
|
||||
case 'draft': return 'Brouillon'
|
||||
case 'completed': return 'Termine'
|
||||
case 'closed': return 'Ferme'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = (decisionType: string) => {
|
||||
switch (decisionType) {
|
||||
case 'runtime_upgrade': return 'Runtime upgrade'
|
||||
case 'document_change': return 'Modif. document'
|
||||
case 'mandate_vote': return 'Vote de mandat'
|
||||
case 'custom': return 'Personnalise'
|
||||
default: return decisionType
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Decisions
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Processus de decision collectifs de la communaute
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<USelect
|
||||
v-model="filterType"
|
||||
:items="typeOptions"
|
||||
placeholder="Type de decision"
|
||||
class="w-56"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
placeholder="Statut"
|
||||
class="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="decisions.loading">
|
||||
<div class="space-y-3">
|
||||
<USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="decisions.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ decisions.error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template v-else-if="decisions.list.length === 0">
|
||||
<UCard>
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-scale" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucune decision pour le moment</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Decisions table -->
|
||||
<template v-else>
|
||||
<UCard>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Titre</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Type</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Statut</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Etapes</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="decision in decisions.list"
|
||||
:key="decision.id"
|
||||
class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
|
||||
@click="navigateTo(`/decisions/${decision.id}`)"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-scale" class="text-gray-400" />
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ decision.title }}</span>
|
||||
<p v-if="decision.description" class="text-xs text-gray-500 mt-0.5 line-clamp-1">
|
||||
{{ decision.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(decision.decision_type) }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<UBadge :color="statusColor(decision.status)" variant="subtle" size="xs">
|
||||
{{ statusLabel(decision.status) }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{{ decision.steps.length }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">
|
||||
{{ formatDate(decision.created_at) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
230
frontend/app/pages/documents/[slug].vue
Normal file
230
frontend/app/pages/documents/[slug].vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const documents = useDocumentsStore()
|
||||
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
|
||||
onMounted(async () => {
|
||||
await documents.fetchBySlug(slug.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
documents.clearCurrent()
|
||||
})
|
||||
|
||||
watch(slug, async (newSlug) => {
|
||||
if (newSlug) {
|
||||
await documents.fetchBySlug(newSlug)
|
||||
}
|
||||
})
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success'
|
||||
case 'draft': return 'warning'
|
||||
case 'archived': return 'neutral'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'Actif'
|
||||
case 'draft': return 'Brouillon'
|
||||
case 'archived': return 'Archive'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = (docType: string) => {
|
||||
switch (docType) {
|
||||
case 'licence': return 'Licence'
|
||||
case 'engagement': return 'Engagement'
|
||||
case 'reglement': return 'Reglement'
|
||||
case 'constitution': return 'Constitution'
|
||||
default: return docType
|
||||
}
|
||||
}
|
||||
|
||||
const itemTypeLabel = (itemType: string) => {
|
||||
switch (itemType) {
|
||||
case 'clause': return 'Clause'
|
||||
case 'rule': return 'Regle'
|
||||
case 'verification': return 'Verification'
|
||||
case 'preamble': return 'Preambule'
|
||||
case 'section': return 'Section'
|
||||
default: return itemType
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Back link -->
|
||||
<div>
|
||||
<UButton
|
||||
to="/documents"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-arrow-left"
|
||||
label="Retour aux documents"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="documents.loading">
|
||||
<div class="space-y-4">
|
||||
<USkeleton class="h-8 w-96" />
|
||||
<USkeleton class="h-4 w-64" />
|
||||
<div class="space-y-3 mt-8">
|
||||
<USkeleton v-for="i in 5" :key="i" class="h-24 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="documents.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ documents.error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Document detail -->
|
||||
<template v-else-if="documents.current">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ documents.current.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<UBadge variant="subtle" color="primary">
|
||||
{{ typeLabel(documents.current.doc_type) }}
|
||||
</UBadge>
|
||||
<UBadge :color="statusColor(documents.current.status)" variant="subtle">
|
||||
{{ statusLabel(documents.current.status) }}
|
||||
</UBadge>
|
||||
<span class="text-sm text-gray-500 font-mono">
|
||||
v{{ documents.current.version }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="documents.current.description" class="mt-4 text-gray-600 dark:text-gray-400">
|
||||
{{ documents.current.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<UCard>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<p class="text-gray-500">Cree le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(documents.current.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Mis a jour le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(documents.current.updated_at) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Nombre d'items</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ documents.current.items_count }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Ancrage IPFS</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
<template v-if="documents.current.ipfs_cid">
|
||||
<span class="font-mono text-xs">{{ documents.current.ipfs_cid.slice(0, 16) }}...</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-gray-400">Non ancre</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Document items -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Contenu du document ({{ documents.items.length }} items)
|
||||
</h2>
|
||||
|
||||
<div v-if="documents.items.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-lucide-file-plus" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun item dans ce document</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<UCard
|
||||
v-for="item in documents.items"
|
||||
:key="item.id"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Item header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-mono font-bold text-primary">
|
||||
{{ item.position }}
|
||||
</span>
|
||||
<UBadge variant="subtle" color="neutral" size="xs">
|
||||
{{ itemTypeLabel(item.item_type) }}
|
||||
</UBadge>
|
||||
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge
|
||||
v-if="item.voting_protocol_id"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Sous vote
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Pas de vote
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item text -->
|
||||
<div class="pl-8">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap leading-relaxed">
|
||||
{{ item.current_text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
191
frontend/app/pages/documents/index.vue
Normal file
191
frontend/app/pages/documents/index.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
const documents = useDocumentsStore()
|
||||
|
||||
const filterType = ref<string | undefined>(undefined)
|
||||
const filterStatus = ref<string | undefined>(undefined)
|
||||
|
||||
const docTypeOptions = [
|
||||
{ label: 'Tous les types', value: undefined },
|
||||
{ label: 'Licence', value: 'licence' },
|
||||
{ label: 'Engagement', value: 'engagement' },
|
||||
{ label: 'Reglement', value: 'reglement' },
|
||||
{ label: 'Constitution', value: 'constitution' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Tous les statuts', value: undefined },
|
||||
{ label: 'Brouillon', value: 'draft' },
|
||||
{ label: 'Actif', value: 'active' },
|
||||
{ label: 'Archive', value: 'archived' },
|
||||
]
|
||||
|
||||
const columns = [
|
||||
{ key: 'title', label: 'Titre' },
|
||||
{ key: 'doc_type', label: 'Type' },
|
||||
{ key: 'version', label: 'Version' },
|
||||
{ key: 'status', label: 'Statut' },
|
||||
{ key: 'items_count', label: 'Items' },
|
||||
{ key: 'updated_at', label: 'Mis a jour' },
|
||||
]
|
||||
|
||||
async function loadDocuments() {
|
||||
await documents.fetchAll({
|
||||
doc_type: filterType.value,
|
||||
status: filterStatus.value,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDocuments()
|
||||
})
|
||||
|
||||
watch([filterType, filterStatus], () => {
|
||||
loadDocuments()
|
||||
})
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success'
|
||||
case 'draft': return 'warning'
|
||||
case 'archived': return 'neutral'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'Actif'
|
||||
case 'draft': return 'Brouillon'
|
||||
case 'archived': return 'Archive'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = (docType: string) => {
|
||||
switch (docType) {
|
||||
case 'licence': return 'Licence'
|
||||
case 'engagement': return 'Engagement'
|
||||
case 'reglement': return 'Reglement'
|
||||
case 'constitution': return 'Constitution'
|
||||
default: return docType
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Documents de reference
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Documents fondateurs de la communaute Duniter/G1 sous vote permanent
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<USelect
|
||||
v-model="filterType"
|
||||
:items="docTypeOptions"
|
||||
placeholder="Type de document"
|
||||
class="w-48"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
placeholder="Statut"
|
||||
class="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="documents.loading">
|
||||
<div class="space-y-3">
|
||||
<USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="documents.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ documents.error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template v-else-if="documents.list.length === 0">
|
||||
<UCard>
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-book-open" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun document de reference pour le moment</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Documents table -->
|
||||
<template v-else>
|
||||
<UCard>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th v-for="col in columns" :key="col.key" class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ col.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="doc in documents.list"
|
||||
:key="doc.id"
|
||||
class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
|
||||
@click="navigateTo(`/documents/${doc.slug}`)"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-file-text" class="text-gray-400" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ doc.title }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<UBadge variant="subtle" color="primary">
|
||||
{{ typeLabel(doc.doc_type) }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400 font-mono text-xs">
|
||||
v{{ doc.version }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<UBadge :color="statusColor(doc.status)" variant="subtle">
|
||||
{{ statusLabel(doc.status) }}
|
||||
</UBadge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
|
||||
{{ doc.items_count }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">
|
||||
{{ formatDate(doc.updated_at) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
170
frontend/app/pages/index.vue
Normal file
170
frontend/app/pages/index.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<script setup lang="ts">
|
||||
const documents = useDocumentsStore()
|
||||
const decisions = useDecisionsStore()
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
documents.fetchAll(),
|
||||
decisions.fetchAll(),
|
||||
])
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: 'Documents actifs',
|
||||
value: documents.activeDocuments.length,
|
||||
total: documents.list.length,
|
||||
icon: 'i-lucide-book-open',
|
||||
color: 'primary' as const,
|
||||
to: '/documents',
|
||||
},
|
||||
{
|
||||
label: 'Decisions en cours',
|
||||
value: decisions.activeDecisions.length,
|
||||
total: decisions.list.length,
|
||||
icon: 'i-lucide-scale',
|
||||
color: 'success' as const,
|
||||
to: '/decisions',
|
||||
},
|
||||
])
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Documents de reference',
|
||||
description: 'Licence G1, engagements forgerons, reglement du comite technique et autres documents fondateurs sous vote permanent.',
|
||||
icon: 'i-lucide-book-open',
|
||||
to: '/documents',
|
||||
color: 'primary' as const,
|
||||
},
|
||||
{
|
||||
title: 'Decisions',
|
||||
description: 'Processus de decision collectifs: runtime upgrades, modifications de documents, votes de mandats.',
|
||||
icon: 'i-lucide-scale',
|
||||
to: '/decisions',
|
||||
color: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: 'Mandats',
|
||||
description: 'Gestion des mandats du comite technique, des forgerons et autres roles de gouvernance.',
|
||||
icon: 'i-lucide-user-check',
|
||||
to: '/mandates',
|
||||
color: 'warning' as const,
|
||||
},
|
||||
{
|
||||
title: 'Protocoles de vote',
|
||||
description: 'Configuration des formules de seuil WoT, criteres Smith et TechComm, parametres de vote nuance.',
|
||||
icon: 'i-lucide-settings',
|
||||
to: '/protocols',
|
||||
color: 'info' as const,
|
||||
},
|
||||
{
|
||||
title: 'Sanctuaire',
|
||||
description: 'Archive immuable: documents ancres sur IPFS avec preuve on-chain via system.remark.',
|
||||
icon: 'i-lucide-archive',
|
||||
to: '/sanctuary',
|
||||
color: 'error' as const,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<h1 class="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">
|
||||
Decisions collectives pour la communaute Duniter/G1
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 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>
|
||||
</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">
|
||||
{{ 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" />
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Section cards -->
|
||||
<div>
|
||||
<h2 class="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">
|
||||
<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">
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ section.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula explainer -->
|
||||
<UCard>
|
||||
<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">
|
||||
Formule de seuil WoT
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
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">
|
||||
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">
|
||||
<span>C = constante de base</span>
|
||||
<span>B = exposant de base</span>
|
||||
<span>W = taille WoT</span>
|
||||
<span>T = votes totaux</span>
|
||||
<span>M = majorite</span>
|
||||
<span>G = gradient</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
177
frontend/app/pages/login.vue
Normal file
177
frontend/app/pages/login.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const address = ref('')
|
||||
const step = ref<'input' | 'challenge' | 'signing' | 'success'>('input')
|
||||
const errorMessage = ref('')
|
||||
|
||||
async function handleLogin() {
|
||||
if (!address.value.trim()) {
|
||||
errorMessage.value = 'Veuillez entrer votre adresse Duniter'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
step.value = 'challenge'
|
||||
|
||||
try {
|
||||
step.value = 'signing'
|
||||
await auth.login(address.value.trim())
|
||||
step.value = 'success'
|
||||
|
||||
// Redirect to home after a brief moment
|
||||
setTimeout(() => {
|
||||
router.push('/')
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
errorMessage.value = err?.data?.detail || err?.message || 'Erreur lors de la connexion'
|
||||
step.value = 'input'
|
||||
}
|
||||
}
|
||||
|
||||
const steps = computed(() => [
|
||||
{
|
||||
title: 'Adresse Duniter',
|
||||
description: 'Entrez votre adresse SS58 Duniter V2',
|
||||
icon: 'i-lucide-user',
|
||||
active: step.value === 'input',
|
||||
complete: step.value !== 'input',
|
||||
},
|
||||
{
|
||||
title: 'Challenge cryptographique',
|
||||
description: 'Un challenge aleatoire est genere par le serveur',
|
||||
icon: 'i-lucide-shield',
|
||||
active: step.value === 'challenge',
|
||||
complete: step.value === 'signing' || step.value === 'success',
|
||||
},
|
||||
{
|
||||
title: 'Signature Ed25519',
|
||||
description: 'Signez le challenge avec votre cle privee',
|
||||
icon: 'i-lucide-key',
|
||||
active: step.value === 'signing',
|
||||
complete: step.value === 'success',
|
||||
},
|
||||
{
|
||||
title: 'Connexion',
|
||||
description: 'Votre identite est verifiee et la session creee',
|
||||
icon: 'i-lucide-check-circle',
|
||||
active: step.value === 'success',
|
||||
complete: false,
|
||||
},
|
||||
])
|
||||
|
||||
// Redirect if already authenticated
|
||||
onMounted(() => {
|
||||
if (auth.isAuthenticated) {
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-lg mx-auto space-y-8 py-8">
|
||||
<div class="text-center">
|
||||
<UIcon name="i-lucide-vote" class="text-5xl text-primary mb-4" />
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Connexion a Glibredecision
|
||||
</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
Authentification via votre identite Duniter V2
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<UCard>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Adresse Duniter (SS58)
|
||||
</label>
|
||||
<UInput
|
||||
v-model="address"
|
||||
placeholder="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"
|
||||
size="lg"
|
||||
icon="i-lucide-wallet"
|
||||
:disabled="auth.loading || step !== 'input'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div v-if="errorMessage || auth.error" class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-red-500" />
|
||||
<p class="text-sm text-red-700 dark:text-red-400">
|
||||
{{ errorMessage || auth.error }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success message -->
|
||||
<div v-if="step === 'success'" class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-check-circle" class="text-green-500" />
|
||||
<p class="text-sm text-green-700 dark:text-green-400">
|
||||
Connexion reussie ! Redirection en cours...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
:label="auth.loading ? 'Connexion en cours...' : 'Se connecter avec Duniter'"
|
||||
icon="i-lucide-log-in"
|
||||
size="lg"
|
||||
block
|
||||
:loading="auth.loading"
|
||||
:disabled="!address.trim() || step === 'success'"
|
||||
@click="handleLogin"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Challenge flow steps -->
|
||||
<UCard>
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide">
|
||||
Processus d'authentification
|
||||
</h3>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="(s, index) in steps"
|
||||
:key="index"
|
||||
class="flex items-start gap-3"
|
||||
:class="{ 'opacity-40': !s.active && !s.complete }"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
:class="{
|
||||
'bg-primary text-white': s.active,
|
||||
'bg-green-500 text-white': s.complete,
|
||||
'bg-gray-200 dark:bg-gray-700 text-gray-500': !s.active && !s.complete,
|
||||
}"
|
||||
>
|
||||
<UIcon v-if="s.complete" name="i-lucide-check" class="text-sm" />
|
||||
<span v-else class="text-xs font-bold">{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ s.title }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ s.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Info note -->
|
||||
<div class="text-center">
|
||||
<p class="text-xs text-gray-400">
|
||||
L'authentification utilise la cryptographie Ed25519 de Duniter V2.
|
||||
Aucun mot de passe n'est transmis au serveur.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
222
frontend/app/pages/mandates/index.vue
Normal file
222
frontend/app/pages/mandates/index.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
const { $api } = useApi()
|
||||
|
||||
interface MandateStep {
|
||||
id: string
|
||||
mandate_id: string
|
||||
step_order: number
|
||||
step_type: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
status: string
|
||||
vote_session_id: string | null
|
||||
outcome: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface Mandate {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
mandate_type: string
|
||||
status: string
|
||||
mandatee_id: string | null
|
||||
decision_id: string | null
|
||||
starts_at: string | null
|
||||
ends_at: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
steps: MandateStep[]
|
||||
}
|
||||
|
||||
const mandates = ref<Mandate[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const filterType = ref<string | undefined>(undefined)
|
||||
const filterStatus = ref<string | undefined>(undefined)
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Tous les types', value: undefined },
|
||||
{ label: 'Comite technique', value: 'techcomm' },
|
||||
{ label: 'Forgeron', value: 'smith' },
|
||||
{ label: 'Personnalise', value: 'custom' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Tous les statuts', value: undefined },
|
||||
{ label: 'Brouillon', value: 'draft' },
|
||||
{ label: 'Actif', value: 'active' },
|
||||
{ label: 'Expire', value: 'expired' },
|
||||
{ label: 'Revoque', value: 'revoked' },
|
||||
]
|
||||
|
||||
async function loadMandates() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const query: Record<string, string> = {}
|
||||
if (filterType.value) query.mandate_type = filterType.value
|
||||
if (filterStatus.value) query.status = filterStatus.value
|
||||
|
||||
mandates.value = await $api<Mandate[]>('/mandates/', { query })
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.detail || err?.message || 'Erreur lors du chargement des mandats'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMandates()
|
||||
})
|
||||
|
||||
watch([filterType, filterStatus], () => {
|
||||
loadMandates()
|
||||
})
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'success'
|
||||
case 'draft': return 'warning'
|
||||
case 'expired': return 'neutral'
|
||||
case 'revoked': return 'error'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'Actif'
|
||||
case 'draft': return 'Brouillon'
|
||||
case 'expired': return 'Expire'
|
||||
case 'revoked': return 'Revoque'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = (mandateType: string) => {
|
||||
switch (mandateType) {
|
||||
case 'techcomm': return 'Comite technique'
|
||||
case 'smith': return 'Forgeron'
|
||||
case 'custom': return 'Personnalise'
|
||||
default: return mandateType
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Mandats
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Mandats de gouvernance : comite technique, forgerons et roles specifiques
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<USelect
|
||||
v-model="filterType"
|
||||
:items="typeOptions"
|
||||
placeholder="Type de mandat"
|
||||
class="w-56"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
placeholder="Statut"
|
||||
class="w-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="loading">
|
||||
<div class="space-y-3">
|
||||
<USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template v-else-if="mandates.length === 0">
|
||||
<UCard>
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-user-check" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun mandat pour le moment</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Mandates list -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<UCard
|
||||
v-for="mandate in mandates"
|
||||
:key="mandate.id"
|
||||
class="hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-user-check" class="text-gray-400" />
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ mandate.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<UBadge :color="statusColor(mandate.status)" variant="subtle" size="xs">
|
||||
{{ statusLabel(mandate.status) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{{ mandate.description }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(mandate.mandate_type) }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ mandate.steps.length }} etape(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs text-gray-500">
|
||||
<div>
|
||||
<span class="block font-medium">Debut</span>
|
||||
{{ formatDate(mandate.starts_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-medium">Fin</span>
|
||||
{{ formatDate(mandate.ends_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
257
frontend/app/pages/protocols/index.vue
Normal file
257
frontend/app/pages/protocols/index.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
const protocols = useProtocolsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
protocols.fetchProtocols(),
|
||||
protocols.fetchFormulas(),
|
||||
])
|
||||
})
|
||||
|
||||
const voteTypeLabel = (voteType: string) => {
|
||||
switch (voteType) {
|
||||
case 'binary': return 'Binaire'
|
||||
case 'nuanced': return 'Nuance'
|
||||
default: return voteType
|
||||
}
|
||||
}
|
||||
|
||||
const voteTypeColor = (voteType: string) => {
|
||||
switch (voteType) {
|
||||
case 'binary': return 'primary'
|
||||
case 'nuanced': return 'info'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function formatModeParamsDisplay(modeParams: string | null): string {
|
||||
if (!modeParams) return '-'
|
||||
try {
|
||||
return formatModeParams(modeParams)
|
||||
} catch {
|
||||
return modeParams
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Protocoles de vote
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Configuration des protocoles de vote et formules de seuil WoT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="protocols.loading">
|
||||
<div class="space-y-3">
|
||||
<USkeleton v-for="i in 4" :key="i" class="h-32 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="protocols.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ protocols.error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Voting Protocols -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Protocoles ({{ protocols.protocols.length }})
|
||||
</h2>
|
||||
|
||||
<div v-if="protocols.protocols.length === 0">
|
||||
<UCard>
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-settings" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun protocole de vote configure</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<UCard
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Protocol header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ protocol.name }}
|
||||
</h3>
|
||||
<p v-if="protocol.description" class="text-sm text-gray-500 mt-0.5">
|
||||
{{ protocol.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :color="voteTypeColor(protocol.vote_type)" variant="subtle" size="xs">
|
||||
{{ voteTypeLabel(protocol.vote_type) }}
|
||||
</UBadge>
|
||||
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle" size="xs">
|
||||
Meta-gouverne
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mode params -->
|
||||
<div v-if="protocol.mode_params" class="p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-mono font-bold text-primary">{{ protocol.mode_params }}</span>
|
||||
</div>
|
||||
<p class="text-gray-500">{{ formatModeParamsDisplay(protocol.mode_params) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Formula config summary -->
|
||||
<div class="border-t border-gray-100 dark:border-gray-800 pt-3">
|
||||
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||
Formule : {{ protocol.formula_config.name }}
|
||||
</h4>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span class="text-gray-400 block">Duree</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.duration_days }}j
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400 block">Majorite</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.majority_pct }}%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400 block">Base</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.base_exponent }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400 block">Gradient</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.gradient_exponent }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="protocol.formula_config.smith_exponent !== null">
|
||||
<span class="text-gray-400 block">Smith</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.smith_exponent }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="protocol.formula_config.techcomm_exponent !== null">
|
||||
<span class="text-gray-400 block">TechComm</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.techcomm_exponent }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula Configurations -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Configurations de formule ({{ protocols.formulas.length }})
|
||||
</h2>
|
||||
|
||||
<div v-if="protocols.formulas.length === 0">
|
||||
<UCard>
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-calculator" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucune configuration de formule</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<UCard>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Nom</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Duree</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Majorite</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">B</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">G</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">C</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Smith</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">TechComm</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="formula in protocols.formulas"
|
||||
:key="formula.id"
|
||||
class="border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
|
||||
{{ formula.name }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">{{ formula.duration_days }}j</td>
|
||||
<td class="px-4 py-3 text-gray-600">{{ formula.majority_pct }}%</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.base_exponent }}</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.gradient_exponent }}</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.constant_base }}</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">
|
||||
{{ formula.smith_exponent ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">
|
||||
{{ formula.techcomm_exponent ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">
|
||||
{{ formatDate(formula.created_at) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula explainer -->
|
||||
<UCard>
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Reference : Formule de seuil WoT
|
||||
</h3>
|
||||
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono">
|
||||
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
|
||||
</code>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 text-xs text-gray-500">
|
||||
<div><strong>C</strong> = constante de base (plancher fixe)</div>
|
||||
<div><strong>B</strong> = exposant de base (B^W tend vers 0 si B < 1)</div>
|
||||
<div><strong>W</strong> = taille du corpus WoT</div>
|
||||
<div><strong>T</strong> = nombre total de votes</div>
|
||||
<div><strong>M</strong> = ratio de majorite (M = majorite_pct / 100)</div>
|
||||
<div><strong>G</strong> = exposant du gradient d'inertie</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
268
frontend/app/pages/sanctuary/index.vue
Normal file
268
frontend/app/pages/sanctuary/index.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
const { $api } = useApi()
|
||||
|
||||
interface SanctuaryEntry {
|
||||
id: string
|
||||
entry_type: string
|
||||
reference_id: string
|
||||
title: string | null
|
||||
content_hash: string
|
||||
ipfs_cid: string | null
|
||||
chain_tx_hash: string | null
|
||||
chain_block: number | null
|
||||
metadata_json: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const entries = ref<SanctuaryEntry[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const filterType = ref<string | undefined>(undefined)
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Tous les types', value: undefined },
|
||||
{ label: 'Document', value: 'document' },
|
||||
{ label: 'Decision', value: 'decision' },
|
||||
{ label: 'Resultat de vote', value: 'vote_result' },
|
||||
]
|
||||
|
||||
async function loadEntries() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const query: Record<string, string> = {}
|
||||
if (filterType.value) query.entry_type = filterType.value
|
||||
|
||||
entries.value = await $api<SanctuaryEntry[]>('/sanctuary/', { query })
|
||||
} catch (err: any) {
|
||||
error.value = err?.data?.detail || err?.message || 'Erreur lors du chargement des entrees'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
watch(filterType, () => {
|
||||
loadEntries()
|
||||
})
|
||||
|
||||
const typeLabel = (entryType: string) => {
|
||||
switch (entryType) {
|
||||
case 'document': return 'Document'
|
||||
case 'decision': return 'Decision'
|
||||
case 'vote_result': return 'Resultat de vote'
|
||||
default: return entryType
|
||||
}
|
||||
}
|
||||
|
||||
const typeColor = (entryType: string) => {
|
||||
switch (entryType) {
|
||||
case 'document': return 'primary'
|
||||
case 'decision': return 'success'
|
||||
case 'vote_result': return 'info'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function truncateHash(hash: string | null, length: number = 16): string {
|
||||
if (!hash) return '-'
|
||||
if (hash.length <= length * 2) return hash
|
||||
return hash.slice(0, length) + '...' + hash.slice(-8)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Sanctuaire
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Archive immuable : documents et decisions ancres sur IPFS avec preuve on-chain via system.remark
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<USelect
|
||||
v-model="filterType"
|
||||
:items="typeOptions"
|
||||
placeholder="Type d'entree"
|
||||
class="w-56"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="loading">
|
||||
<div class="space-y-3">
|
||||
<USkeleton v-for="i in 4" :key="i" class="h-24 w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template v-else-if="entries.length === 0">
|
||||
<UCard>
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-archive" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucune entree dans le sanctuaire pour le moment</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Les documents et decisions adoptes seront automatiquement archives ici
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Entries list -->
|
||||
<template v-else>
|
||||
<div class="space-y-4">
|
||||
<UCard
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Entry header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ entry.title || 'Entree sans titre' }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatDate(entry.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UBadge :color="typeColor(entry.entry_type)" variant="subtle" size="xs">
|
||||
{{ typeLabel(entry.entry_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Hashes and anchors -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Content hash -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
||||
{{ truncateHash(entry.content_hash) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- IPFS CID -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
|
||||
</div>
|
||||
<template v-if="entry.ipfs_cid">
|
||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
||||
{{ truncateHash(entry.ipfs_cid) }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-xs text-gray-400 italic">En attente d'epinglage</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Chain anchor -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
|
||||
</div>
|
||||
<template v-if="entry.chain_tx_hash">
|
||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
||||
{{ truncateHash(entry.chain_tx_hash) }}
|
||||
</p>
|
||||
<p v-if="entry.chain_block" class="text-xs text-gray-400 mt-0.5">
|
||||
Bloc #{{ entry.chain_block.toLocaleString('fr-FR') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="text-xs text-gray-400 italic">En attente d'ancrage</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification status -->
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon
|
||||
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
||||
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
|
||||
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon
|
||||
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
||||
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
|
||||
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Info card -->
|
||||
<UCard>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-info" class="text-primary" />
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Processus d'archivage
|
||||
</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-xs text-gray-500">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">1</span>
|
||||
<span>Le contenu est hache en SHA-256 pour garantir son integrite</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">2</span>
|
||||
<span>Le document est epingle sur IPFS (Kubo) pour le stockage distribue</span>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold">3</span>
|
||||
<span>Le hash est ancre on-chain via system.remark sur Duniter V2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
180
frontend/app/stores/auth.ts
Normal file
180
frontend/app/stores/auth.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Auth store: manages Duniter Ed25519 challenge-response authentication.
|
||||
*
|
||||
* Persists the session token in localStorage for SPA rehydration.
|
||||
* The identity object mirrors the backend IdentityOut schema.
|
||||
*/
|
||||
|
||||
export interface DuniterIdentity {
|
||||
id: string
|
||||
address: string
|
||||
display_name: string | null
|
||||
wot_status: string
|
||||
is_smith: boolean
|
||||
is_techcomm: boolean
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
identity: DuniterIdentity | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
token: null,
|
||||
identity: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state): boolean => !!state.token && !!state.identity,
|
||||
isSmith: (state): boolean => state.identity?.is_smith ?? false,
|
||||
isTechComm: (state): boolean => state.identity?.is_techcomm ?? false,
|
||||
displayName: (state): string => {
|
||||
if (!state.identity) return ''
|
||||
return state.identity.display_name || state.identity.address.slice(0, 12) + '...'
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Initiate the challenge-response login flow.
|
||||
*
|
||||
* Steps:
|
||||
* 1. POST /auth/challenge with the Duniter SS58 address
|
||||
* 2. Client signs the challenge with Ed25519 private key
|
||||
* 3. POST /auth/verify with address + signature + challenge
|
||||
* 4. Store the returned token and identity
|
||||
*/
|
||||
async login(address: string, signFn?: (challenge: string) => Promise<string>) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
|
||||
// Step 1: Request challenge
|
||||
const challengeRes = await $api<{ challenge: string; expires_at: string }>(
|
||||
'/auth/challenge',
|
||||
{
|
||||
method: 'POST',
|
||||
body: { address },
|
||||
},
|
||||
)
|
||||
|
||||
// Step 2: Sign the challenge
|
||||
// In production, signFn would use the Duniter keypair to produce an Ed25519 signature.
|
||||
// For development, we use a placeholder signature.
|
||||
let signature: string
|
||||
if (signFn) {
|
||||
signature = await signFn(challengeRes.challenge)
|
||||
} else {
|
||||
// Development placeholder -- backend currently accepts any signature
|
||||
signature = 'dev_signature_placeholder'
|
||||
}
|
||||
|
||||
// Step 3: Verify and get token
|
||||
const verifyRes = await $api<{ token: string; identity: DuniterIdentity }>(
|
||||
'/auth/verify',
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
address,
|
||||
signature,
|
||||
challenge: challengeRes.challenge,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// Step 4: Store credentials
|
||||
this.token = verifyRes.token
|
||||
this.identity = verifyRes.identity
|
||||
this._persistToken()
|
||||
|
||||
return verifyRes
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur de connexion'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the currently authenticated identity from the backend.
|
||||
* Used on app init to validate a persisted token.
|
||||
*/
|
||||
async fetchMe() {
|
||||
if (!this.token) return
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const identity = await $api<DuniterIdentity>('/auth/me')
|
||||
this.identity = identity
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Session invalide'
|
||||
this.token = null
|
||||
this.identity = null
|
||||
this._clearToken()
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Log out: invalidate session on server and clear local state.
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
if (this.token) {
|
||||
const { $api } = useApi()
|
||||
await $api('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during logout -- clear local state regardless
|
||||
} finally {
|
||||
this.token = null
|
||||
this.identity = null
|
||||
this.error = null
|
||||
this._clearToken()
|
||||
navigateTo('/login')
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hydrate the token from localStorage on app init.
|
||||
*/
|
||||
hydrateFromStorage() {
|
||||
if (import.meta.client) {
|
||||
const stored = localStorage.getItem('glibredecision_token')
|
||||
if (stored) {
|
||||
this.token = stored
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/** @internal Persist token to localStorage */
|
||||
_persistToken() {
|
||||
if (import.meta.client && this.token) {
|
||||
localStorage.setItem('glibredecision_token', this.token)
|
||||
}
|
||||
},
|
||||
|
||||
/** @internal Clear token from localStorage */
|
||||
_clearToken() {
|
||||
if (import.meta.client) {
|
||||
localStorage.removeItem('glibredecision_token')
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Note: hydration from localStorage happens in app.vue onMounted
|
||||
// via auth.hydrateFromStorage() before calling auth.fetchMe().
|
||||
138
frontend/app/stores/decisions.ts
Normal file
138
frontend/app/stores/decisions.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Decisions store: decision processes and their steps.
|
||||
*
|
||||
* Maps to the backend /api/v1/decisions endpoints.
|
||||
*/
|
||||
|
||||
export interface DecisionStep {
|
||||
id: string
|
||||
decision_id: string
|
||||
step_order: number
|
||||
step_type: string
|
||||
title: string | null
|
||||
description: string | null
|
||||
status: string
|
||||
vote_session_id: string | null
|
||||
outcome: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Decision {
|
||||
id: string
|
||||
title: string
|
||||
description: string | null
|
||||
context: string | null
|
||||
decision_type: string
|
||||
status: string
|
||||
voting_protocol_id: string | null
|
||||
created_by_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
steps: DecisionStep[]
|
||||
}
|
||||
|
||||
export interface DecisionCreate {
|
||||
title: string
|
||||
description?: string | null
|
||||
context?: string | null
|
||||
decision_type: string
|
||||
voting_protocol_id?: string | null
|
||||
}
|
||||
|
||||
interface DecisionsState {
|
||||
list: Decision[]
|
||||
current: Decision | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useDecisionsStore = defineStore('decisions', {
|
||||
state: (): DecisionsState => ({
|
||||
list: [],
|
||||
current: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
byStatus: (state) => {
|
||||
return (status: string) => state.list.filter(d => d.status === status)
|
||||
},
|
||||
activeDecisions: (state): Decision[] => {
|
||||
return state.list.filter(d => d.status === 'active' || d.status === 'in_progress')
|
||||
},
|
||||
completedDecisions: (state): Decision[] => {
|
||||
return state.list.filter(d => d.status === 'completed' || d.status === 'closed')
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Fetch all decisions with optional filters.
|
||||
*/
|
||||
async fetchAll(params?: { decision_type?: string; status?: string }) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const query: Record<string, string> = {}
|
||||
if (params?.decision_type) query.decision_type = params.decision_type
|
||||
if (params?.status) query.status = params.status
|
||||
|
||||
this.list = await $api<Decision[]>('/decisions/', { query })
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des decisions'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single decision by ID with all its steps.
|
||||
*/
|
||||
async fetchById(id: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
this.current = await $api<Decision>(`/decisions/${id}`)
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Decision introuvable'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new decision.
|
||||
*/
|
||||
async create(payload: DecisionCreate) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const decision = await $api<Decision>('/decisions/', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
this.list.unshift(decision)
|
||||
return decision
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation de la decision'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current decision.
|
||||
*/
|
||||
clearCurrent() {
|
||||
this.current = null
|
||||
},
|
||||
},
|
||||
})
|
||||
149
frontend/app/stores/documents.ts
Normal file
149
frontend/app/stores/documents.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Documents store: reference documents, their items, and item versions.
|
||||
*
|
||||
* Maps to the backend /api/v1/documents endpoints.
|
||||
*/
|
||||
|
||||
export interface DocumentItem {
|
||||
id: string
|
||||
document_id: string
|
||||
position: string
|
||||
item_type: string
|
||||
title: string | null
|
||||
current_text: string
|
||||
voting_protocol_id: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
doc_type: string
|
||||
version: string
|
||||
status: string
|
||||
description: string | null
|
||||
ipfs_cid: string | null
|
||||
chain_anchor: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
items_count: number
|
||||
}
|
||||
|
||||
export interface DocumentCreate {
|
||||
slug: string
|
||||
title: string
|
||||
doc_type: string
|
||||
description?: string | null
|
||||
version?: string
|
||||
}
|
||||
|
||||
interface DocumentsState {
|
||||
list: Document[]
|
||||
current: Document | null
|
||||
items: DocumentItem[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useDocumentsStore = defineStore('documents', {
|
||||
state: (): DocumentsState => ({
|
||||
list: [],
|
||||
current: null,
|
||||
items: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
byType: (state) => {
|
||||
return (docType: string) => state.list.filter(d => d.doc_type === docType)
|
||||
},
|
||||
activeDocuments: (state): Document[] => {
|
||||
return state.list.filter(d => d.status === 'active')
|
||||
},
|
||||
draftDocuments: (state): Document[] => {
|
||||
return state.list.filter(d => d.status === 'draft')
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Fetch all documents with optional filters.
|
||||
*/
|
||||
async fetchAll(params?: { doc_type?: string; status?: string }) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const query: Record<string, string> = {}
|
||||
if (params?.doc_type) query.doc_type = params.doc_type
|
||||
if (params?.status) query.status = params.status
|
||||
|
||||
this.list = await $api<Document[]>('/documents/', { query })
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des documents'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a single document by slug and its items.
|
||||
*/
|
||||
async fetchBySlug(slug: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
|
||||
const [doc, items] = await Promise.all([
|
||||
$api<Document>(`/documents/${slug}`),
|
||||
$api<DocumentItem[]>(`/documents/${slug}/items`),
|
||||
])
|
||||
|
||||
this.current = doc
|
||||
this.items = items
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Document introuvable'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new reference document.
|
||||
*/
|
||||
async createDocument(payload: DocumentCreate) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const doc = await $api<Document>('/documents/', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
this.list.unshift(doc)
|
||||
return doc
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors de la creation du document'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current document and items.
|
||||
*/
|
||||
clearCurrent() {
|
||||
this.current = null
|
||||
this.items = []
|
||||
},
|
||||
},
|
||||
})
|
||||
100
frontend/app/stores/protocols.ts
Normal file
100
frontend/app/stores/protocols.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Protocols store: voting protocols and formula configurations.
|
||||
*
|
||||
* Maps to the backend /api/v1/protocols endpoints.
|
||||
*/
|
||||
|
||||
export interface FormulaConfig {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
duration_days: number
|
||||
majority_pct: number
|
||||
base_exponent: number
|
||||
gradient_exponent: number
|
||||
constant_base: number
|
||||
smith_exponent: number | null
|
||||
techcomm_exponent: number | null
|
||||
nuanced_min_participants: number | null
|
||||
nuanced_threshold_pct: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface VotingProtocol {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
vote_type: string
|
||||
formula_config_id: string
|
||||
mode_params: string | null
|
||||
is_meta_governed: boolean
|
||||
created_at: string
|
||||
formula_config: FormulaConfig
|
||||
}
|
||||
|
||||
interface ProtocolsState {
|
||||
protocols: VotingProtocol[]
|
||||
formulas: FormulaConfig[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useProtocolsStore = defineStore('protocols', {
|
||||
state: (): ProtocolsState => ({
|
||||
protocols: [],
|
||||
formulas: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
binaryProtocols: (state): VotingProtocol[] => {
|
||||
return state.protocols.filter(p => p.vote_type === 'binary')
|
||||
},
|
||||
nuancedProtocols: (state): VotingProtocol[] => {
|
||||
return state.protocols.filter(p => p.vote_type === 'nuanced')
|
||||
},
|
||||
metaGovernedProtocols: (state): VotingProtocol[] => {
|
||||
return state.protocols.filter(p => p.is_meta_governed)
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Fetch all voting protocols with their formula configurations.
|
||||
*/
|
||||
async fetchProtocols(params?: { vote_type?: string }) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const query: Record<string, string> = {}
|
||||
if (params?.vote_type) query.vote_type = params.vote_type
|
||||
|
||||
this.protocols = await $api<VotingProtocol[]>('/protocols/', { query })
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des protocoles'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all formula configurations.
|
||||
*/
|
||||
async fetchFormulas() {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
this.formulas = await $api<FormulaConfig[]>('/protocols/formulas')
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du chargement des formules'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
178
frontend/app/stores/votes.ts
Normal file
178
frontend/app/stores/votes.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Votes store: vote sessions, individual votes, and result computation.
|
||||
*
|
||||
* Maps to the backend /api/v1/votes endpoints.
|
||||
*/
|
||||
|
||||
export interface Vote {
|
||||
id: string
|
||||
session_id: string
|
||||
voter_id: string
|
||||
vote_value: string
|
||||
nuanced_level: number | null
|
||||
comment: string | null
|
||||
signature: string
|
||||
signed_payload: string
|
||||
voter_wot_status: string
|
||||
voter_is_smith: boolean
|
||||
voter_is_techcomm: boolean
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface VoteSession {
|
||||
id: string
|
||||
decision_id: string | null
|
||||
item_version_id: string | null
|
||||
voting_protocol_id: string
|
||||
wot_size: number
|
||||
smith_size: number
|
||||
techcomm_size: number
|
||||
starts_at: string
|
||||
ends_at: string
|
||||
status: string
|
||||
votes_for: number
|
||||
votes_against: number
|
||||
votes_total: number
|
||||
smith_votes_for: number
|
||||
techcomm_votes_for: number
|
||||
threshold_required: number
|
||||
result: string | null
|
||||
chain_recorded: boolean
|
||||
chain_tx_hash: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface VoteResult {
|
||||
session_id: string
|
||||
status: string
|
||||
votes_for: number
|
||||
votes_against: number
|
||||
votes_total: number
|
||||
wot_size: number
|
||||
smith_size: number
|
||||
techcomm_size: number
|
||||
smith_votes_for: number
|
||||
techcomm_votes_for: number
|
||||
threshold_required: number
|
||||
result: string
|
||||
smith_threshold: number | null
|
||||
smith_pass: boolean
|
||||
techcomm_threshold: number | null
|
||||
techcomm_pass: boolean
|
||||
}
|
||||
|
||||
export interface VoteCreate {
|
||||
session_id: string
|
||||
vote_value: string
|
||||
nuanced_level?: number | null
|
||||
comment?: string | null
|
||||
signature: string
|
||||
signed_payload: string
|
||||
}
|
||||
|
||||
interface VotesState {
|
||||
currentSession: VoteSession | null
|
||||
votes: Vote[]
|
||||
result: VoteResult | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export const useVotesStore = defineStore('votes', {
|
||||
state: (): VotesState => ({
|
||||
currentSession: null,
|
||||
votes: [],
|
||||
result: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isSessionOpen: (state): boolean => {
|
||||
if (!state.currentSession) return false
|
||||
return state.currentSession.status === 'open' && new Date(state.currentSession.ends_at) > new Date()
|
||||
},
|
||||
participationRate: (state): number => {
|
||||
if (!state.currentSession || state.currentSession.wot_size === 0) return 0
|
||||
return (state.currentSession.votes_total / state.currentSession.wot_size) * 100
|
||||
},
|
||||
forPercentage: (state): number => {
|
||||
if (!state.currentSession || state.currentSession.votes_total === 0) return 0
|
||||
return (state.currentSession.votes_for / state.currentSession.votes_total) * 100
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
/**
|
||||
* Fetch a vote session by ID with its votes and result.
|
||||
*/
|
||||
async fetchSession(sessionId: string) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
|
||||
const [session, votes, result] = await Promise.all([
|
||||
$api<VoteSession>(`/votes/sessions/${sessionId}`),
|
||||
$api<Vote[]>(`/votes/sessions/${sessionId}/votes`),
|
||||
$api<VoteResult>(`/votes/sessions/${sessionId}/result`),
|
||||
])
|
||||
|
||||
this.currentSession = session
|
||||
this.votes = votes
|
||||
this.result = result
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Session de vote introuvable'
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit a vote to the current session.
|
||||
*/
|
||||
async submitVote(payload: VoteCreate) {
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const { $api } = useApi()
|
||||
const vote = await $api<Vote>(`/votes/sessions/${payload.session_id}/vote`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
|
||||
// Update local state
|
||||
this.votes.push(vote)
|
||||
|
||||
// Refresh session tallies and result
|
||||
if (this.currentSession) {
|
||||
const [session, result] = await Promise.all([
|
||||
$api<VoteSession>(`/votes/sessions/${payload.session_id}`),
|
||||
$api<VoteResult>(`/votes/sessions/${payload.session_id}/result`),
|
||||
])
|
||||
this.currentSession = session
|
||||
this.result = result
|
||||
}
|
||||
|
||||
return vote
|
||||
} catch (err: any) {
|
||||
this.error = err?.data?.detail || err?.message || 'Erreur lors du vote'
|
||||
throw err
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current session state.
|
||||
*/
|
||||
clearSession() {
|
||||
this.currentSession = null
|
||||
this.votes = []
|
||||
this.result = null
|
||||
},
|
||||
},
|
||||
})
|
||||
164
frontend/app/utils/mode-params.ts
Normal file
164
frontend/app/utils/mode-params.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* TypeScript mirror of the Python mode_params parser.
|
||||
*
|
||||
* A mode-params string encodes voting formula parameters in a compact format.
|
||||
* Example: "D30M50B.1G.2T.1"
|
||||
*
|
||||
* Supported codes:
|
||||
* D = duration_days (int)
|
||||
* M = majority_pct (int, 0-100)
|
||||
* B = base_exponent (float)
|
||||
* G = gradient_exponent (float)
|
||||
* C = constant_base (float)
|
||||
* S = smith_exponent (float)
|
||||
* T = techcomm_exponent (float)
|
||||
* N = ratio_multiplier (float)
|
||||
* R = is_ratio_mode (bool, 0 or 1)
|
||||
*
|
||||
* Values may start with a dot for decimals < 1, e.g. "B.1" means base_exponent=0.1.
|
||||
*/
|
||||
|
||||
export interface ModeParams {
|
||||
duration_days: number
|
||||
majority_pct: number
|
||||
base_exponent: number
|
||||
gradient_exponent: number
|
||||
constant_base: number
|
||||
smith_exponent: number | null
|
||||
techcomm_exponent: number | null
|
||||
ratio_multiplier: number | null
|
||||
is_ratio_mode: boolean
|
||||
}
|
||||
|
||||
type CodeType = 'int' | 'float' | 'bool'
|
||||
|
||||
const CODES: Record<string, { key: keyof ModeParams; type: CodeType }> = {
|
||||
D: { key: 'duration_days', type: 'int' },
|
||||
M: { key: 'majority_pct', type: 'int' },
|
||||
B: { key: 'base_exponent', type: 'float' },
|
||||
G: { key: 'gradient_exponent', type: 'float' },
|
||||
C: { key: 'constant_base', type: 'float' },
|
||||
S: { key: 'smith_exponent', type: 'float' },
|
||||
T: { key: 'techcomm_exponent', type: 'float' },
|
||||
N: { key: 'ratio_multiplier', type: 'float' },
|
||||
R: { key: 'is_ratio_mode', type: 'bool' },
|
||||
}
|
||||
|
||||
const PARAM_RE = /([A-Z])(\d*\.?\d+)/g
|
||||
|
||||
function getDefaults(): ModeParams {
|
||||
return {
|
||||
duration_days: 30,
|
||||
majority_pct: 50,
|
||||
base_exponent: 0.1,
|
||||
gradient_exponent: 0.2,
|
||||
constant_base: 0.0,
|
||||
smith_exponent: null,
|
||||
techcomm_exponent: null,
|
||||
ratio_multiplier: null,
|
||||
is_ratio_mode: false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a mode-params string into a structured ModeParams object.
|
||||
*
|
||||
* @param paramsStr - Compact parameter string, e.g. "D30M50B.1G.2T.1"
|
||||
* @returns Parsed parameters with defaults for codes not found
|
||||
* @throws Error if an unrecognised code letter is found
|
||||
*/
|
||||
export function parseModeParams(paramsStr: string): ModeParams {
|
||||
const result = getDefaults()
|
||||
|
||||
if (!paramsStr || !paramsStr.trim()) {
|
||||
return result
|
||||
}
|
||||
|
||||
let match: RegExpExecArray | null
|
||||
PARAM_RE.lastIndex = 0
|
||||
|
||||
while ((match = PARAM_RE.exec(paramsStr)) !== null) {
|
||||
const code = match[1]
|
||||
const rawValue = match[2]
|
||||
|
||||
if (!(code in CODES)) {
|
||||
throw new Error(`Code de parametre inconnu : '${code}'`)
|
||||
}
|
||||
|
||||
const { key, type } = CODES[code]
|
||||
|
||||
if (type === 'int') {
|
||||
;(result as any)[key] = Math.floor(parseFloat(rawValue))
|
||||
} else if (type === 'float') {
|
||||
;(result as any)[key] = parseFloat(rawValue)
|
||||
} else if (type === 'bool') {
|
||||
;(result as any)[key] = parseFloat(rawValue) !== 0
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a ModeParams object into a compact mode-params string.
|
||||
*
|
||||
* Only includes parameters that differ from defaults.
|
||||
*
|
||||
* @param params - Parameters to encode
|
||||
* @returns Compact string, e.g. "D30M50B.1G.2"
|
||||
*/
|
||||
export function encodeModeParams(params: Partial<ModeParams>): string {
|
||||
const defaults = getDefaults()
|
||||
const parts: string[] = []
|
||||
|
||||
const codeEntries = Object.entries(CODES) as [string, { key: keyof ModeParams; type: CodeType }][]
|
||||
|
||||
for (const [code, { key, type }] of codeEntries) {
|
||||
const value = params[key]
|
||||
if (value === undefined || value === null) continue
|
||||
if (value === defaults[key]) continue
|
||||
|
||||
if (type === 'int') {
|
||||
parts.push(`${code}${value}`)
|
||||
} else if (type === 'float') {
|
||||
const numVal = value as number
|
||||
if (numVal < 1 && numVal > 0) {
|
||||
parts.push(`${code}${numVal.toString().replace(/^0/, '')}`)
|
||||
} else {
|
||||
parts.push(`${code}${numVal}`)
|
||||
}
|
||||
} else if (type === 'bool') {
|
||||
parts.push(`${code}${value ? 1 : 0}`)
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a mode-params string for human display.
|
||||
*
|
||||
* @param paramsStr - Compact parameter string
|
||||
* @returns Human-readable description in French
|
||||
*/
|
||||
export function formatModeParams(paramsStr: string): string {
|
||||
const params = parseModeParams(paramsStr)
|
||||
const parts: string[] = []
|
||||
|
||||
parts.push(`Duree: ${params.duration_days} jours`)
|
||||
parts.push(`Majorite: ${params.majority_pct}%`)
|
||||
parts.push(`Base: ${params.base_exponent}`)
|
||||
parts.push(`Gradient: ${params.gradient_exponent}`)
|
||||
|
||||
if (params.constant_base > 0) {
|
||||
parts.push(`Constante: ${params.constant_base}`)
|
||||
}
|
||||
if (params.smith_exponent !== null) {
|
||||
parts.push(`Smith: ${params.smith_exponent}`)
|
||||
}
|
||||
if (params.techcomm_exponent !== null) {
|
||||
parts.push(`TechComm: ${params.techcomm_exponent}`)
|
||||
}
|
||||
|
||||
return parts.join(' | ')
|
||||
}
|
||||
84
frontend/app/utils/threshold.ts
Normal file
84
frontend/app/utils/threshold.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* TypeScript mirror of the Python WoT threshold formula.
|
||||
*
|
||||
* Core formula:
|
||||
* Result = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T - C)
|
||||
*
|
||||
* Where:
|
||||
* C = constant_base
|
||||
* B = base_exponent
|
||||
* W = wot_size (corpus of eligible voters)
|
||||
* T = total_votes (for + against)
|
||||
* M = majority_ratio (majority_pct / 100)
|
||||
* G = gradient_exponent
|
||||
*
|
||||
* Inertia behaviour:
|
||||
* - Low participation (T << W) -> near-unanimity required
|
||||
* - High participation (T -> W) -> simple majority M suffices
|
||||
*
|
||||
* Reference test case:
|
||||
* wot_size=7224, votes_for=97, votes_against=23 (total=120)
|
||||
* params M50 B.1 G.2 => threshold=94, adopted (97 >= 94)
|
||||
*/
|
||||
export function wotThreshold(
|
||||
wotSize: number,
|
||||
totalVotes: number,
|
||||
majorityPct: number = 50,
|
||||
baseExponent: number = 0.1,
|
||||
gradientExponent: number = 0.2,
|
||||
constantBase: number = 0.0,
|
||||
): number {
|
||||
if (wotSize <= 0) {
|
||||
throw new Error('wotSize doit etre strictement positif')
|
||||
}
|
||||
if (totalVotes < 0) {
|
||||
throw new Error('totalVotes ne peut pas etre negatif')
|
||||
}
|
||||
if (majorityPct < 0 || majorityPct > 100) {
|
||||
throw new Error('majorityPct doit etre entre 0 et 100')
|
||||
}
|
||||
|
||||
const M = majorityPct / 100
|
||||
const T = totalVotes
|
||||
const W = wotSize
|
||||
const C = constantBase
|
||||
const B = baseExponent
|
||||
const G = gradientExponent
|
||||
|
||||
// Guard: if no votes, threshold is at least ceil(C + B^W)
|
||||
if (T === 0) {
|
||||
return Math.ceil(C + Math.pow(B, W))
|
||||
}
|
||||
|
||||
// Core formula
|
||||
const participationRatio = T / W
|
||||
const inertiaFactor = 1.0 - Math.pow(participationRatio, G)
|
||||
const requiredRatio = M + (1.0 - M) * inertiaFactor
|
||||
const result = C + Math.pow(B, W) + requiredRatio * Math.max(0, T - C)
|
||||
|
||||
return Math.ceil(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the Smith criterion threshold.
|
||||
*
|
||||
* @param smithWotSize - Number of Smith members
|
||||
* @param smithExponent - Exponent S for the Smith criterion
|
||||
* @returns Minimum number of Smith votes required
|
||||
*/
|
||||
export function smithThreshold(smithWotSize: number, smithExponent: number): number {
|
||||
if (smithWotSize <= 0) return 0
|
||||
return Math.ceil(Math.pow(smithWotSize, smithExponent))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the TechComm criterion threshold.
|
||||
*
|
||||
* @param techcommSize - Number of TechComm members
|
||||
* @param techcommExponent - Exponent T for the TechComm criterion
|
||||
* @returns Minimum number of TechComm votes required
|
||||
*/
|
||||
export function techcommThreshold(techcommSize: number, techcommExponent: number): number {
|
||||
if (techcommSize <= 0) return 0
|
||||
return Math.ceil(Math.pow(techcommSize, techcommExponent))
|
||||
}
|
||||
28
frontend/nuxt.config.ts
Normal file
28
frontend/nuxt.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
ssr: false,
|
||||
devtools: { enabled: true },
|
||||
devServer: { port: 3002 },
|
||||
components: [{ path: '~/components', pathPrefix: false }],
|
||||
modules: [
|
||||
'@nuxt/ui',
|
||||
'@pinia/nuxt',
|
||||
'@unocss/nuxt',
|
||||
'@vueuse/nuxt',
|
||||
],
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'fr' },
|
||||
meta: [
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||
{ name: 'description', content: 'Plateforme de decisions collectives pour la communaute Duniter/G1' },
|
||||
],
|
||||
title: 'Glibredecision',
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
apiBase: 'http://localhost:8002/api/v1',
|
||||
},
|
||||
},
|
||||
})
|
||||
29
frontend/package.json
Normal file
29
frontend/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "glibredecision",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/content": "^3.11.2",
|
||||
"@nuxt/ui": "^3.1.0",
|
||||
"@pinia/nuxt": "^0.9.0",
|
||||
"@unocss/nuxt": "^66.6.0",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"nuxt": "^4.3.1",
|
||||
"pinia": "^3.0.2",
|
||||
"vue": "^3.5.28",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/lucide": "^1.2.91",
|
||||
"typescript": "^5.9.3",
|
||||
"unocss": "^66.6.0"
|
||||
}
|
||||
}
|
||||
3
frontend/tsconfig.json
Normal file
3
frontend/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||
Reference in New Issue
Block a user