- Fix accents manquants dans 7 pages UI (décisions, boîte à outils, etc.) - Titres accueil enrichis : Décisions structurantes, Documents de référence, Mandats et nominations, Protocoles et fonctionnement - Retrait Embarquement Forgeron du seed (n'est pas une Decision) - 2 protocoles opérationnels dans Protocoles : Embarquement Forgeron (lié à l'Acte d'engagement) + Soumission Runtime Upgrade (lié à la Décision Runtime Upgrade) avec timeline et liens croisés signalétiques - Décision Runtime Upgrade : badge on-chain + lien protocole + contexte - Document [slug] : lien protocole dans la section Qualification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
611 lines
16 KiB
Vue
611 lines
16 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' },
|
|
qualification: { label: 'Qualification', icon: 'i-lucide-graduation-cap' },
|
|
aspirant: { label: 'Aspirant forgeron', icon: 'i-lucide-user-plus' },
|
|
certificateur: { label: 'Certificateur forgeron', icon: 'i-lucide-stamp' },
|
|
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: 'Réglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
|
|
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
|
|
}
|
|
|
|
const SECTION_ORDER = ['introduction', 'fondamental', 'technique', 'qualification', 'aspirant', 'certificateur', '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 'Règlement'
|
|
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) {
|
|
// Expand the section if collapsed
|
|
if (collapsedSections.value[tag]) {
|
|
collapsedSections.value[tag] = false
|
|
}
|
|
nextTick(() => {
|
|
const el = document.getElementById(`section-${tag}`)
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
|
activeSection.value = tag
|
|
}
|
|
})
|
|
}
|
|
|
|
// ─── Collapsible sections ────────────────────────────────────
|
|
// First 2 sections open by default, rest collapsed
|
|
|
|
const collapsedSections = ref<Record<string, boolean>>({})
|
|
|
|
watch(sections, (newSections) => {
|
|
if (newSections.length > 0 && Object.keys(collapsedSections.value).length === 0) {
|
|
const map: Record<string, boolean> = {}
|
|
newSections.forEach((s, i) => {
|
|
map[s.tag] = i >= 2 // collapsed if index >= 2
|
|
})
|
|
collapsedSections.value = map
|
|
}
|
|
}, { immediate: true })
|
|
|
|
function toggleSection(tag: string) {
|
|
collapsedSections.value[tag] = !collapsedSections.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">Créé le</p>
|
|
<p class="doc-page__meta-value">{{ formatDate(documents.current.created_at) }}</p>
|
|
</div>
|
|
<div>
|
|
<p class="doc-page__meta-label">Mis à 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 (clickable toggle) -->
|
|
<button
|
|
class="doc-page__section-header"
|
|
@click="toggleSection(section.tag)"
|
|
>
|
|
<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>
|
|
<div class="flex items-center gap-2">
|
|
<InertiaSlider :preset="section.inertiaPreset" compact mini />
|
|
<UIcon
|
|
name="i-lucide-chevron-down"
|
|
class="doc-page__section-chevron"
|
|
:class="{ 'doc-page__section-chevron--open': !collapsedSections[section.tag] }"
|
|
/>
|
|
</div>
|
|
</button>
|
|
|
|
<!-- Protocol link for qualification section -->
|
|
<NuxtLink
|
|
v-if="section.tag === 'qualification' && !collapsedSections[section.tag]"
|
|
to="/protocols"
|
|
class="doc-page__protocol-link"
|
|
>
|
|
<UIcon name="i-lucide-git-branch" class="text-sm" />
|
|
<div>
|
|
<span class="doc-page__protocol-link-label">Protocole lié</span>
|
|
<span class="doc-page__protocol-link-name">Embarquement Forgeron</span>
|
|
</div>
|
|
<UIcon name="i-lucide-arrow-right" class="text-sm doc-page__protocol-link-arrow" />
|
|
</NuxtLink>
|
|
|
|
<!-- Items (collapsible) -->
|
|
<Transition name="section-collapse">
|
|
<div v-show="!collapsedSections[section.tag]" 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>
|
|
</Transition>
|
|
</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);
|
|
width: 100%;
|
|
background: none;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
transition: opacity 0.15s;
|
|
}
|
|
|
|
.doc-page__section-header:hover {
|
|
opacity: 0.85;
|
|
}
|
|
|
|
.doc-page__section-chevron {
|
|
font-size: 1rem;
|
|
color: var(--mood-text-muted);
|
|
transform: rotate(-90deg);
|
|
transition: transform 0.25s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.doc-page__section-chevron--open {
|
|
transform: rotate(0deg);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
/* Protocol link */
|
|
.doc-page__protocol-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 8%, var(--mood-surface));
|
|
border: 1px solid color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
|
|
border-radius: 14px;
|
|
text-decoration: none;
|
|
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
|
color: var(--mood-tertiary, var(--mood-accent));
|
|
}
|
|
|
|
.doc-page__protocol-link:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px var(--mood-shadow);
|
|
}
|
|
|
|
.doc-page__protocol-link-label {
|
|
display: block;
|
|
font-size: 0.625rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.06em;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
|
|
.doc-page__protocol-link-name {
|
|
display: block;
|
|
font-size: 0.875rem;
|
|
font-weight: 700;
|
|
color: var(--mood-text);
|
|
}
|
|
|
|
.doc-page__protocol-link-arrow {
|
|
margin-left: auto;
|
|
opacity: 0.3;
|
|
transition: opacity 0.12s;
|
|
}
|
|
|
|
.doc-page__protocol-link:hover .doc-page__protocol-link-arrow {
|
|
opacity: 1;
|
|
}
|
|
|
|
/* Section collapse transition */
|
|
.section-collapse-enter-active,
|
|
.section-collapse-leave-active {
|
|
transition: all 0.3s ease;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.section-collapse-enter-from,
|
|
.section-collapse-leave-to {
|
|
opacity: 0;
|
|
max-height: 0;
|
|
}
|
|
|
|
.section-collapse-enter-to,
|
|
.section-collapse-leave-from {
|
|
opacity: 1;
|
|
}
|
|
</style>
|