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:
Yvv
2026-03-02 07:59:05 +01:00
parent 11e4a4d60a
commit 62808b974d
10 changed files with 2116 additions and 120 deletions

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

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

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

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

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