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