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:
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>
|
||||
Reference in New Issue
Block a user