Files
decision/frontend/app/pages/documents/[slug].vue
Yvv 62808b974d Composants engagement: GenesisBlock, InertiaSlider, MiniVoteBoard, EngagementCard, DocumentTuto
Backend: genesis_json sur Document, section_tag/inertia_preset/is_permanent_vote sur DocumentItem
Frontend: 5 nouveaux composants pour vue detail document enrichie
- GenesisBlock: sources, outils, synthese forum, contributeurs (depliable)
- InertiaSlider: visualisation inertie 4 niveaux avec params formule G/M
- MiniVoteBoard: tableau vote compact (barre seuil, pour/contre, participation)
- EngagementCard: carte item enrichie integrant vote + inertie + actions
- DocumentTuto: modal pedagogique vote permanent/inertie/seuils
Seed et page [slug] enrichis pour exploiter les nouveaux champs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 07:59:05 +01:00

473 lines
12 KiB
Vue

<script setup lang="ts">
/**
* Document detail page — full structured view with:
* - Genesis block (source files, repos, forum synthesis, formula trigger)
* - Sectioned items grouped by section_tag
* - Mini vote boards per item
* - Inertia sliders per section
* - Permanent vote signage
* - Tuto overlay
*/
import type { DocumentItem } from '~/stores/documents'
const route = useRoute()
const documents = useDocumentsStore()
const auth = useAuthStore()
const slug = computed(() => route.params.slug as string)
const archiving = ref(false)
onMounted(async () => {
await documents.fetchBySlug(slug.value)
})
onUnmounted(() => {
documents.clearCurrent()
})
watch(slug, async (newSlug) => {
if (newSlug) {
await documents.fetchBySlug(newSlug)
}
})
// ─── Section grouping ──────────────────────────────────────────
interface Section {
tag: string
label: string
icon: string
inertiaPreset: string
items: DocumentItem[]
}
const SECTION_META: Record<string, { label: string; icon: string }> = {
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
inertie: { label: 'Reglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
}
const SECTION_ORDER = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']
const sections = computed((): Section[] => {
const grouped: Record<string, DocumentItem[]> = {}
const ungrouped: DocumentItem[] = []
for (const item of documents.items) {
const tag = item.section_tag
if (tag) {
if (!grouped[tag]) grouped[tag] = []
grouped[tag].push(item)
} else {
ungrouped.push(item)
}
}
const result: Section[] = []
for (const tag of SECTION_ORDER) {
if (grouped[tag]) {
const meta = SECTION_META[tag] || { label: tag, icon: 'i-lucide-file-text' }
const firstItem = grouped[tag][0]
result.push({
tag,
label: meta.label,
icon: meta.icon,
inertiaPreset: firstItem?.inertia_preset || 'standard',
items: grouped[tag],
})
}
}
// Ungrouped items
if (ungrouped.length > 0) {
result.push({
tag: '_other',
label: 'Autres',
icon: 'i-lucide-file-text',
inertiaPreset: 'standard',
items: ungrouped,
})
}
return result
})
const totalItems = computed(() => documents.items.length)
// ─── Helpers ───────────────────────────────────────────────────
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: 'long',
year: 'numeric',
})
}
function handlePropose(item: DocumentItem) {
navigateTo(`/documents/${slug.value}/items/${item.id}`)
}
async function archiveToSanctuary() {
archiving.value = true
try {
await documents.archiveDocument(slug.value)
} catch {
// Error handled in store
} finally {
archiving.value = false
}
}
// ─── Active section (scroll spy) ──────────────────────────────
const activeSection = ref<string | null>(null)
function scrollToSection(tag: string) {
const el = document.getElementById(`section-${tag}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeSection.value = tag
}
}
</script>
<template>
<div class="doc-page">
<!-- Back link -->
<div class="doc-page__nav">
<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 class="doc-page__header">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="doc-page__title">
{{ documents.current.title }}
</h1>
<div class="flex items-center gap-3 mt-2 flex-wrap">
<UBadge variant="subtle" color="primary">
{{ typeLabel(documents.current.doc_type) }}
</UBadge>
<StatusBadge :status="documents.current.status" type="document" :clickable="false" />
<span class="text-sm font-mono" style="color: var(--mood-text-muted)">
v{{ documents.current.version }}
</span>
<span class="text-sm" style="color: var(--mood-text-muted)">
{{ totalItems }} items
</span>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<DocumentTuto />
<UButton
v-if="auth.isAuthenticated && documents.current.status === 'active'"
label="Archiver"
icon="i-lucide-archive"
color="primary"
variant="soft"
size="sm"
:loading="archiving"
@click="archiveToSanctuary"
/>
</div>
</div>
<!-- Description -->
<p v-if="documents.current.description" class="doc-page__desc">
{{ documents.current.description }}
</p>
</div>
<!-- METADATA -->
<div class="doc-page__meta">
<div class="doc-page__meta-grid">
<div>
<p class="doc-page__meta-label">Cree le</p>
<p class="doc-page__meta-value">{{ formatDate(documents.current.created_at) }}</p>
</div>
<div>
<p class="doc-page__meta-label">Mis a jour le</p>
<p class="doc-page__meta-value">{{ formatDate(documents.current.updated_at) }}</p>
</div>
<div>
<p class="doc-page__meta-label">Ancrage IPFS</p>
<div class="mt-1">
<IPFSLink :cid="documents.current.ipfs_cid" />
</div>
</div>
<div v-if="documents.current.chain_anchor">
<p class="doc-page__meta-label">Ancrage on-chain</p>
<ChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
</div>
</div>
</div>
<!-- GENESIS BLOCK -->
<GenesisBlock
v-if="documents.current.genesis_json"
:genesis-json="documents.current.genesis_json"
/>
<!-- SECTION NAVIGATOR -->
<div v-if="sections.length > 1" class="doc-page__section-nav">
<button
v-for="section in sections"
:key="section.tag"
class="doc-page__section-pill"
:class="{ 'doc-page__section-pill--active': activeSection === section.tag }"
@click="scrollToSection(section.tag)"
>
<UIcon :name="section.icon" class="text-xs" />
{{ section.label }}
<span class="doc-page__section-count">{{ section.items.length }}</span>
</button>
</div>
<!-- SECTIONS WITH ITEMS -->
<div class="doc-page__sections">
<div
v-for="section in sections"
:key="section.tag"
:id="`section-${section.tag}`"
class="doc-page__section"
>
<!-- Section header -->
<div class="doc-page__section-header">
<div class="flex items-center gap-2">
<UIcon :name="section.icon" style="color: var(--mood-accent)" />
<h2 class="doc-page__section-title">
{{ section.label }}
</h2>
<UBadge variant="subtle" color="neutral" size="xs">
{{ section.items.length }}
</UBadge>
</div>
<InertiaSlider :preset="section.inertiaPreset" compact class="max-w-48" />
</div>
<!-- Items -->
<div class="doc-page__section-items">
<EngagementCard
v-for="item in section.items"
:key="item.id"
:item="item"
:document-slug="slug"
:show-actions="auth.isAuthenticated"
@propose="handlePropose"
/>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.doc-page {
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: 56rem;
margin: 0 auto;
padding-bottom: 4rem;
}
.doc-page__nav {
margin-bottom: -0.5rem;
}
/* Header */
.doc-page__header {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.doc-page__title {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.02em;
line-height: 1.2;
}
@media (min-width: 640px) {
.doc-page__title {
font-size: 1.875rem;
}
}
.doc-page__desc {
font-size: 0.875rem;
color: var(--mood-text-muted);
line-height: 1.6;
margin-top: 0.25rem;
}
/* Metadata */
.doc-page__meta {
padding: 1rem 1.25rem;
background: var(--mood-surface);
border-radius: 14px;
}
.doc-page__meta-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
@media (min-width: 640px) {
.doc-page__meta-grid {
grid-template-columns: repeat(4, 1fr);
}
}
.doc-page__meta-label {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.doc-page__meta-value {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
}
/* Section navigator */
.doc-page__section-nav {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.doc-page__section-nav::-webkit-scrollbar {
display: none;
}
.doc-page__section-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
background: var(--mood-surface);
color: var(--mood-text-muted);
cursor: pointer;
transition: all 0.15s;
border: none;
}
.doc-page__section-pill:hover {
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
color: var(--mood-text);
}
.doc-page__section-pill--active {
background: var(--mood-accent);
color: white;
}
.doc-page__section-count {
font-size: 0.625rem;
font-weight: 800;
opacity: 0.7;
}
/* Sections */
.doc-page__sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
.doc-page__section {
display: flex;
flex-direction: column;
gap: 0.75rem;
scroll-margin-top: 4rem;
}
.doc-page__section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0;
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
}
.doc-page__section-title {
font-size: 1rem;
font-weight: 800;
color: var(--mood-text);
letter-spacing: -0.01em;
}
@media (min-width: 640px) {
.doc-page__section-title {
font-size: 1.125rem;
}
}
.doc-page__section-items {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>