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:
Yvv
2026-02-28 12:46:11 +01:00
commit 25437f24e3
100 changed files with 10236 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 &lt; 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>

View 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>