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>
This commit is contained in:
127
frontend/app/components/documents/DocumentTuto.vue
Normal file
127
frontend/app/components/documents/DocumentTuto.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DocumentTuto — Quick tutorial overlay explaining how the document works.
|
||||
* Shows how permanent voting, inertia, counter-proposals, and thresholds work.
|
||||
*/
|
||||
const open = ref(false)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: 'i-lucide-infinity',
|
||||
title: 'Vote permanent',
|
||||
text: 'Chaque engagement est sous vote permanent. A tout moment, vous pouvez proposer une alternative ou voter pour/contre le texte en vigueur.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-sliders-horizontal',
|
||||
title: 'Inertie variable',
|
||||
text: 'Les engagements fondamentaux ont une inertie standard (difficulte de remplacement moderee). Les annexes sont plus faciles a modifier. La formule et ses reglages sont tres proteges.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
title: 'Seuil adaptatif',
|
||||
text: 'La formule WoT adapte le seuil a la participation : peu de votants = quasi-unanimite requise ; beaucoup de votants = majorite simple suffit.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-pen-line',
|
||||
title: 'Contre-propositions',
|
||||
text: 'Cliquez sur "Proposer une alternative" pour soumettre un texte de remplacement. Il sera soumis au vote et devra atteindre le seuil d\'adoption pour remplacer le texte en vigueur.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-git-branch',
|
||||
title: 'Depot automatique',
|
||||
text: 'Quand une alternative est adoptee, le document officiel est mis a jour, ancre sur IPFS et on-chain, puis deploye dans les applications (Cesium, Gecko).',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UButton
|
||||
label="Comment ca marche ?"
|
||||
icon="i-lucide-circle-help"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
@click="open = true"
|
||||
/>
|
||||
|
||||
<UModal v-model:open="open" :ui="{ content: 'max-w-lg' }">
|
||||
<template #content>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-bold" style="color: var(--mood-text)">
|
||||
Comment ca marche ?
|
||||
</h2>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
@click="open = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="idx"
|
||||
class="tuto-step"
|
||||
>
|
||||
<div class="tuto-step__icon">
|
||||
<UIcon :name="step.icon" class="text-base" />
|
||||
</div>
|
||||
<div class="tuto-step__content">
|
||||
<h4 class="text-sm font-bold" style="color: var(--mood-text)">
|
||||
{{ step.title }}
|
||||
</h4>
|
||||
<p class="text-xs leading-relaxed" style="color: var(--mood-text-muted)">
|
||||
{{ step.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 pt-4 border-t" style="border-color: color-mix(in srgb, var(--mood-text) 8%, transparent)">
|
||||
<p class="text-xs text-center" style="color: var(--mood-text-muted)">
|
||||
Reference : formule g1vote —
|
||||
<a
|
||||
href="https://g1vote-view-237903.pages.duniter.org/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="color: var(--mood-accent)"
|
||||
>
|
||||
g1vote-view
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tuto-step {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tuto-step__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tuto-step__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
252
frontend/app/components/documents/EngagementCard.vue
Normal file
252
frontend/app/components/documents/EngagementCard.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EngagementCard — Enhanced item card with inline mini vote board,
|
||||
* inertia indicator, and action buttons.
|
||||
*
|
||||
* Replaces the basic ItemCard for the document detail view.
|
||||
*/
|
||||
import type { DocumentItem } from '~/stores/documents'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
item: DocumentItem
|
||||
documentSlug: string
|
||||
showActions?: boolean
|
||||
showVoteBoard?: boolean
|
||||
}>(), {
|
||||
showActions: false,
|
||||
showVoteBoard: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
propose: [item: DocumentItem]
|
||||
}>()
|
||||
|
||||
const isSection = computed(() => props.item.item_type === 'section')
|
||||
const isPreamble = computed(() => props.item.item_type === 'preamble')
|
||||
|
||||
const itemTypeIcon = computed(() => {
|
||||
switch (props.item.item_type) {
|
||||
case 'clause': return 'i-lucide-shield-check'
|
||||
case 'rule': return 'i-lucide-scale'
|
||||
case 'verification': return 'i-lucide-check-circle'
|
||||
case 'preamble': return 'i-lucide-scroll-text'
|
||||
case 'section': return 'i-lucide-layout-list'
|
||||
default: return 'i-lucide-file-text'
|
||||
}
|
||||
})
|
||||
|
||||
const itemTypeLabel = computed(() => {
|
||||
switch (props.item.item_type) {
|
||||
case 'clause': return 'Engagement'
|
||||
case 'rule': return 'Regle'
|
||||
case 'verification': return 'Verification'
|
||||
case 'preamble': return 'Preambule'
|
||||
case 'section': return 'Titre'
|
||||
default: return props.item.item_type
|
||||
}
|
||||
})
|
||||
|
||||
function navigateToItem() {
|
||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Section header (visual separator, not a card) -->
|
||||
<div v-if="isSection" class="engagement-section">
|
||||
<div class="engagement-section__line" />
|
||||
<div class="engagement-section__content">
|
||||
<h3 class="engagement-section__title">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<p class="engagement-section__text">
|
||||
{{ item.current_text }}
|
||||
</p>
|
||||
<InertiaSlider
|
||||
:preset="item.inertia_preset"
|
||||
compact
|
||||
class="mt-2 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular item card -->
|
||||
<div
|
||||
v-else
|
||||
class="engagement-card"
|
||||
:class="{
|
||||
'engagement-card--preamble': isPreamble,
|
||||
}"
|
||||
>
|
||||
<!-- Card header -->
|
||||
<div class="engagement-card__header" @click="navigateToItem">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<div class="engagement-card__position">
|
||||
{{ item.position }}
|
||||
</div>
|
||||
<UIcon :name="itemTypeIcon" class="text-sm shrink-0" style="color: var(--mood-accent)" />
|
||||
<span v-if="item.title" class="engagement-card__title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<UBadge variant="subtle" color="neutral" size="xs" class="shrink-0">
|
||||
{{ itemTypeLabel }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item text -->
|
||||
<div class="engagement-card__body" @click="navigateToItem">
|
||||
<MarkdownRenderer :content="item.current_text" />
|
||||
</div>
|
||||
|
||||
<!-- Mini vote board -->
|
||||
<div v-if="showVoteBoard" class="engagement-card__vote">
|
||||
<MiniVoteBoard
|
||||
:votes-for="10"
|
||||
:votes-against="1"
|
||||
:wot-size="7224"
|
||||
:is-permanent="item.is_permanent_vote"
|
||||
:inertia-preset="item.inertia_preset"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Inertia indicator -->
|
||||
<div class="engagement-card__inertia">
|
||||
<InertiaSlider
|
||||
:preset="item.inertia_preset"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="showActions" class="engagement-card__actions">
|
||||
<UButton
|
||||
label="Proposer une alternative"
|
||||
icon="i-lucide-pen-line"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click.stop="emit('propose', item)"
|
||||
/>
|
||||
<UButton
|
||||
label="Voter"
|
||||
icon="i-lucide-vote"
|
||||
variant="soft"
|
||||
color="success"
|
||||
size="xs"
|
||||
@click.stop="navigateToItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Section separator */
|
||||
.engagement-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.engagement-section__line {
|
||||
width: 4px;
|
||||
background: var(--mood-accent);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engagement-section__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.engagement-section__title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.engagement-section__text {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.engagement-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.engagement-card:hover {
|
||||
box-shadow: 0 2px 12px color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.engagement-card--preamble {
|
||||
border-left: 4px solid color-mix(in srgb, var(--mood-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.engagement-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.engagement-card__position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 1.625rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-accent);
|
||||
color: white;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engagement-card__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.engagement-card__body {
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.engagement-card__vote {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.engagement-card__inertia {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.engagement-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--mood-text) 6%, transparent);
|
||||
}
|
||||
</style>
|
||||
334
frontend/app/components/documents/GenesisBlock.vue
Normal file
334
frontend/app/components/documents/GenesisBlock.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
|
||||
* for a reference document. Collapsible by default.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
genesisJson: string
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
interface GenesisData {
|
||||
source_document: {
|
||||
title: string
|
||||
url: string
|
||||
repo: string
|
||||
}
|
||||
reference_tools: Record<string, string>
|
||||
forum_synthesis: Array<{
|
||||
title: string
|
||||
url: string
|
||||
status: string
|
||||
posts?: number
|
||||
}>
|
||||
formula_trigger: string
|
||||
contributors: Array<{
|
||||
name: string
|
||||
role: string
|
||||
}>
|
||||
}
|
||||
|
||||
const genesis = computed((): GenesisData | null => {
|
||||
try {
|
||||
return JSON.parse(props.genesisJson)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rejected': return 'error'
|
||||
case 'in_progress': return 'warning'
|
||||
case 'reference': return 'info'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rejected': return 'Rejetee'
|
||||
case 'in_progress': return 'En cours'
|
||||
case 'reference': return 'Reference'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="genesis" class="genesis-block">
|
||||
<!-- Header (always visible) -->
|
||||
<button
|
||||
class="genesis-block__header"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="genesis-block__icon">
|
||||
<UIcon name="i-lucide-file-archive" class="text-lg" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wide" style="color: var(--mood-accent)">
|
||||
Bloc de genese
|
||||
</h3>
|
||||
<p class="text-xs" style="color: var(--mood-text-muted)">
|
||||
Sources, references et formule de depot
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-lg"
|
||||
style="color: var(--mood-text-muted)"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Expandable content -->
|
||||
<Transition name="genesis-expand">
|
||||
<div v-if="expanded" class="genesis-block__body">
|
||||
<!-- Source document -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-file-text" />
|
||||
Document source
|
||||
</h4>
|
||||
<div class="genesis-card">
|
||||
<p class="font-medium text-sm" style="color: var(--mood-text)">
|
||||
{{ genesis.source_document.title }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<a
|
||||
:href="genesis.source_document.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-link"
|
||||
>
|
||||
<UIcon name="i-lucide-external-link" class="text-xs" />
|
||||
Texte officiel
|
||||
</a>
|
||||
<a
|
||||
:href="genesis.source_document.repo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-link"
|
||||
>
|
||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
||||
Depot git
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reference tools -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-wrench" />
|
||||
Outils de reference
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a
|
||||
v-for="(url, name) in genesis.reference_tools"
|
||||
:key="name"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-card genesis-card--tool"
|
||||
>
|
||||
<span class="text-xs font-semibold capitalize" style="color: var(--mood-text)">
|
||||
{{ name.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
<UIcon name="i-lucide-external-link" class="text-xs" style="color: var(--mood-text-muted)" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forum synthesis -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-messages-square" />
|
||||
Synthese des discussions
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<a
|
||||
v-for="topic in genesis.forum_synthesis"
|
||||
:key="topic.url"
|
||||
:href="topic.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-card genesis-card--forum"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-xs font-medium" style="color: var(--mood-text)">
|
||||
{{ topic.title }}
|
||||
</span>
|
||||
<UBadge
|
||||
:color="statusColor(topic.status)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ statusLabel(topic.status) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<span v-if="topic.posts" class="text-xs" style="color: var(--mood-text-muted)">
|
||||
{{ topic.posts }} messages
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula trigger -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-zap" />
|
||||
Processus de depot
|
||||
</h4>
|
||||
<div class="genesis-card">
|
||||
<p class="text-xs leading-relaxed" style="color: var(--mood-text)">
|
||||
{{ genesis.formula_trigger }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contributors -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-users" />
|
||||
Contributeurs
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="c in genesis.contributors"
|
||||
:key="c.name"
|
||||
class="genesis-contributor"
|
||||
>
|
||||
<span class="font-semibold text-xs" style="color: var(--mood-text)">{{ c.name }}</span>
|
||||
<span class="text-xs" style="color: var(--mood-text-muted)">{{ c.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.genesis-block {
|
||||
background: var(--mood-surface);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.genesis-block__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-block__header:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
||||
}
|
||||
|
||||
.genesis-block__icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.genesis-block__body {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.genesis-section__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.genesis-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.genesis-card--tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-card--tool:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.genesis-card--forum {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-card--forum:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.genesis-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.genesis-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.genesis-contributor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
||||
}
|
||||
|
||||
/* Expand/collapse transition */
|
||||
.genesis-expand-enter-active,
|
||||
.genesis-expand-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.genesis-expand-enter-from,
|
||||
.genesis-expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
192
frontend/app/components/documents/InertiaSlider.vue
Normal file
192
frontend/app/components/documents/InertiaSlider.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Inertia slider — displays the inertia preset level for a section.
|
||||
* Read-only indicator (voting on the preset uses the standard vote flow).
|
||||
* Shows the formula parameters underneath.
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
preset: string
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
compact: false,
|
||||
})
|
||||
|
||||
interface InertiaLevel {
|
||||
label: string
|
||||
gradient: number
|
||||
majority: number
|
||||
color: string
|
||||
position: number // 0-100 for slider position
|
||||
description: string
|
||||
}
|
||||
|
||||
const LEVELS: Record<string, InertiaLevel> = {
|
||||
low: {
|
||||
label: 'Basse',
|
||||
gradient: 0.1,
|
||||
majority: 50,
|
||||
color: '#22c55e',
|
||||
position: 10,
|
||||
description: 'Facile a remplacer',
|
||||
},
|
||||
standard: {
|
||||
label: 'Standard',
|
||||
gradient: 0.2,
|
||||
majority: 50,
|
||||
color: '#3b82f6',
|
||||
position: 37,
|
||||
description: 'Equilibre participation/consensus',
|
||||
},
|
||||
high: {
|
||||
label: 'Haute',
|
||||
gradient: 0.4,
|
||||
majority: 60,
|
||||
color: '#f59e0b',
|
||||
position: 63,
|
||||
description: 'Forte mobilisation requise',
|
||||
},
|
||||
very_high: {
|
||||
label: 'Tres haute',
|
||||
gradient: 0.6,
|
||||
majority: 66,
|
||||
color: '#ef4444',
|
||||
position: 90,
|
||||
description: 'Quasi-unanimite requise',
|
||||
},
|
||||
}
|
||||
|
||||
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inertia" :class="{ 'inertia--compact': compact }">
|
||||
<!-- Slider track -->
|
||||
<div class="inertia__track">
|
||||
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
|
||||
<div
|
||||
class="inertia__thumb"
|
||||
:style="{ left: `${level.position}%`, borderColor: level.color }"
|
||||
/>
|
||||
<!-- Level marks -->
|
||||
<div
|
||||
v-for="(lvl, key) in LEVELS"
|
||||
:key="key"
|
||||
class="inertia__mark"
|
||||
:class="{ 'inertia__mark--active': key === preset }"
|
||||
:style="{ left: `${lvl.position}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Label row -->
|
||||
<div class="inertia__info">
|
||||
<span class="inertia__label" :style="{ color: level.color }">
|
||||
{{ level.label }}
|
||||
</span>
|
||||
<span v-if="!compact" class="inertia__params">
|
||||
G={{ level.gradient }} M={{ level.majority }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description (not in compact mode) -->
|
||||
<p v-if="!compact" class="inertia__desc">
|
||||
{{ level.description }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inertia {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.inertia--compact {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.inertia__track {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.inertia--compact .inertia__track {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.inertia__fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.inertia__fill {
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.inertia__thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-bg);
|
||||
border: 3px solid;
|
||||
transition: left 0.3s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.inertia--compact .inertia__thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.inertia__mark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--mood-text) 20%, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.inertia__mark--active {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.inertia__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.inertia__label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.inertia--compact .inertia__label {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.inertia__params {
|
||||
font-size: 0.625rem;
|
||||
font-family: monospace;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.inertia__desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
248
frontend/app/components/documents/MiniVoteBoard.vue
Normal file
248
frontend/app/components/documents/MiniVoteBoard.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MiniVoteBoard — compact inline vote status for an engagement item.
|
||||
*
|
||||
* Shows: vote bar, counts, threshold, pass/fail, and vote buttons.
|
||||
* Uses mock data when no vote session is linked (dev mode).
|
||||
*/
|
||||
import { useVoteFormula } from '~/composables/useVoteFormula'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
votesFor?: number
|
||||
votesAgainst?: number
|
||||
wotSize?: number
|
||||
isPermanent?: boolean
|
||||
inertiaPreset?: string
|
||||
startsAt?: string | null
|
||||
endsAt?: string | null
|
||||
}>(), {
|
||||
votesFor: 0,
|
||||
votesAgainst: 0,
|
||||
wotSize: 7224,
|
||||
isPermanent: true,
|
||||
inertiaPreset: 'standard',
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
})
|
||||
|
||||
const { computeThreshold } = useVoteFormula()
|
||||
|
||||
const INERTIA_PARAMS: Record<string, { majority_pct: number; base_exponent: number; gradient_exponent: number; constant_base: number }> = {
|
||||
low: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.1, constant_base: 0 },
|
||||
standard: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0 },
|
||||
high: { majority_pct: 60, base_exponent: 0.1, gradient_exponent: 0.4, constant_base: 0 },
|
||||
very_high: { majority_pct: 66, base_exponent: 0.1, gradient_exponent: 0.6, constant_base: 0 },
|
||||
}
|
||||
|
||||
const formulaParams = computed(() => INERTIA_PARAMS[props.inertiaPreset] ?? INERTIA_PARAMS.standard!)
|
||||
|
||||
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
|
||||
|
||||
const threshold = computed(() => {
|
||||
if (totalVotes.value === 0) return 1
|
||||
return computeThreshold(props.wotSize, totalVotes.value, formulaParams.value)
|
||||
})
|
||||
|
||||
const isPassing = computed(() => props.votesFor >= threshold.value)
|
||||
|
||||
const forPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesFor / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
const againstPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesAgainst / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
const thresholdPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 50
|
||||
return Math.min((threshold.value / totalVotes.value) * 100, 100)
|
||||
})
|
||||
|
||||
const participationRate = computed(() => {
|
||||
if (props.wotSize === 0) return 0
|
||||
return (totalVotes.value / props.wotSize) * 100
|
||||
})
|
||||
|
||||
const remaining = computed(() => {
|
||||
const diff = threshold.value - props.votesFor
|
||||
return diff > 0 ? diff : 0
|
||||
})
|
||||
|
||||
function formatDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mini-board">
|
||||
<!-- Vote type badge -->
|
||||
<div class="mini-board__header">
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge
|
||||
v-if="isPermanent"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
<UIcon name="i-lucide-infinity" class="mr-1" />
|
||||
Vote permanent
|
||||
</UBadge>
|
||||
<template v-else>
|
||||
<UBadge color="info" variant="subtle" size="xs">
|
||||
<UIcon name="i-lucide-clock" class="mr-1" />
|
||||
Vote temporaire
|
||||
</UBadge>
|
||||
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
|
||||
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<UBadge
|
||||
:color="isPassing ? 'success' : 'neutral'"
|
||||
:variant="isPassing ? 'solid' : 'subtle'"
|
||||
size="xs"
|
||||
>
|
||||
{{ isPassing ? 'Adopte' : 'En attente' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mini-board__bar">
|
||||
<div
|
||||
class="mini-board__bar-for"
|
||||
:style="{ width: `${forPct}%` }"
|
||||
/>
|
||||
<div
|
||||
class="mini-board__bar-against"
|
||||
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
|
||||
/>
|
||||
<!-- Threshold marker -->
|
||||
<div
|
||||
v-if="totalVotes > 0"
|
||||
class="mini-board__bar-threshold"
|
||||
:style="{ left: `${thresholdPct}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="mini-board__stats">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="mini-board__stat mini-board__stat--for">
|
||||
{{ votesFor }} pour
|
||||
</span>
|
||||
<span class="mini-board__stat mini-board__stat--against">
|
||||
{{ votesAgainst }} contre
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="mini-board__stat">
|
||||
{{ votesFor }}/{{ threshold }} requis
|
||||
</span>
|
||||
<span v-if="remaining > 0" class="mini-board__stat mini-board__stat--remaining">
|
||||
{{ remaining }} manquant{{ remaining > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participation -->
|
||||
<div class="mini-board__footer">
|
||||
<span class="text-xs" style="color: var(--mood-text-muted)">
|
||||
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} / {{ wotSize }} membres
|
||||
({{ participationRate.toFixed(2) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mini-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.mini-board__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mini-board__bar {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mini-board__bar-for {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
right: auto;
|
||||
background: #22c55e;
|
||||
border-radius: 3px 0 0 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.mini-board__bar-against {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
right: auto;
|
||||
background: #ef4444;
|
||||
transition: width 0.4s ease, left 0.4s ease;
|
||||
}
|
||||
|
||||
.mini-board__bar-threshold {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
background: #facc15;
|
||||
border-radius: 1px;
|
||||
transform: translateX(-50%);
|
||||
transition: left 0.4s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.mini-board__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mini-board__stat {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.mini-board__stat--for {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.mini-board__stat--against {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.mini-board__stat--remaining {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.mini-board__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,13 @@
|
||||
<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()
|
||||
@@ -6,7 +15,6 @@ const documents = useDocumentsStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
|
||||
const archiving = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -23,6 +31,77 @@ watch(slug, async (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'
|
||||
@@ -55,12 +134,24 @@ async function archiveToSanctuary() {
|
||||
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="space-y-6">
|
||||
<div class="doc-page">
|
||||
<!-- Back link -->
|
||||
<div>
|
||||
<div class="doc-page__nav">
|
||||
<UButton
|
||||
to="/documents"
|
||||
variant="ghost"
|
||||
@@ -94,31 +185,36 @@ async function archiveToSanctuary() {
|
||||
|
||||
<!-- Document detail -->
|
||||
<template v-else-if="documents.current">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="flex items-start justify-between">
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<div class="doc-page__header">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
<h1 class="doc-page__title">
|
||||
{{ documents.current.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<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" />
|
||||
<span class="text-sm text-gray-500 font-mono">
|
||||
<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>
|
||||
|
||||
<!-- Archive button for authenticated users with active documents -->
|
||||
<div v-if="auth.isAuthenticated && documents.current.status === 'active'" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<DocumentTuto />
|
||||
<UButton
|
||||
label="Archiver dans le Sanctuaire"
|
||||
v-if="auth.isAuthenticated && documents.current.status === 'active'"
|
||||
label="Archiver"
|
||||
icon="i-lucide-archive"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:loading="archiving"
|
||||
@click="archiveToSanctuary"
|
||||
/>
|
||||
@@ -126,71 +222,251 @@ async function archiveToSanctuary() {
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="documents.current.description" class="mt-4 text-gray-600 dark:text-gray-400">
|
||||
<p v-if="documents.current.description" class="doc-page__desc">
|
||||
{{ documents.current.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<UCard>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<!-- ═══ METADATA ═══ -->
|
||||
<div class="doc-page__meta">
|
||||
<div class="doc-page__meta-grid">
|
||||
<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>
|
||||
<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="text-gray-500">Mis a jour le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(documents.current.updated_at) }}
|
||||
</p>
|
||||
<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="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="doc-page__meta-label">Ancrage IPFS</p>
|
||||
<div class="mt-1">
|
||||
<IPFSLink :cid="documents.current.ipfs_cid" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chain anchor info -->
|
||||
<div v-if="documents.current.chain_anchor" class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-gray-500">Ancrage on-chain :</p>
|
||||
<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>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<!-- ═══ GENESIS BLOCK ═══ -->
|
||||
<GenesisBlock
|
||||
v-if="documents.current.genesis_json"
|
||||
:genesis-json="documents.current.genesis_json"
|
||||
/>
|
||||
|
||||
<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>
|
||||
<!-- ═══ 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>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<ItemCard
|
||||
v-for="item in documents.items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:document-slug="slug"
|
||||
:show-actions="auth.isAuthenticated"
|
||||
@propose="handlePropose"
|
||||
/>
|
||||
<!-- ═══ 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>
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface DocumentItem {
|
||||
current_text: string
|
||||
voting_protocol_id: string | null
|
||||
sort_order: number
|
||||
section_tag: string | null
|
||||
inertia_preset: string
|
||||
is_permanent_vote: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -27,6 +30,7 @@ export interface Document {
|
||||
description: string | null
|
||||
ipfs_cid: string | null
|
||||
chain_anchor: string | null
|
||||
genesis_json: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
items_count: number
|
||||
|
||||
Reference in New Issue
Block a user