v2 : nettoyage des orphelins v1 + documentation livrable
- 43 fichiers v1 supprimés (composants documents/protocols/sanctuary/toolbox, stores auth/documents/groups/mandates/organizations/protocols/votes, composables api/notifications/formula/websocket, utils doublons du moteur) - nuxt.config épuré (polkadot retiré, KaTeX et apiBase gardés), meta v2 - README.md, CONTRIBUTING.md, CLAUDE.md réécrits pour la v2 - Build zéro erreur, 342/342 tests verts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,373 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SectionLayout — Mise en page pour sections.
|
||||
*
|
||||
* Desktop (≥1024px) : 2 colonnes, toolbox sticky à droite, toujours visible.
|
||||
* Mobile/tablette : toolbox en USlideover droit, bouton flottant.
|
||||
*/
|
||||
|
||||
export interface StatusFilter {
|
||||
id: string
|
||||
label: string
|
||||
count: number
|
||||
cssClass?: string
|
||||
}
|
||||
|
||||
export interface ToolboxItem {
|
||||
title: string
|
||||
description: string
|
||||
actions: Array<{
|
||||
label: string
|
||||
to?: string
|
||||
onClick?: () => void
|
||||
}>
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
statuses: StatusFilter[]
|
||||
toolboxItems?: ToolboxItem[]
|
||||
activeStatus?: string | null
|
||||
toolboxTitle?: string
|
||||
}>(),
|
||||
{
|
||||
subtitle: undefined,
|
||||
toolboxItems: undefined,
|
||||
activeStatus: null,
|
||||
toolboxTitle: 'Boîte à outils',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:activeStatus': [status: string | null]
|
||||
}>()
|
||||
|
||||
const toolboxOpen = ref(false)
|
||||
|
||||
const statusCssMap: Record<string, string> = {
|
||||
draft: 'status-prepa',
|
||||
qualification: 'status-prepa',
|
||||
candidacy: 'status-prepa',
|
||||
voting: 'status-vote',
|
||||
review: 'status-vote',
|
||||
active: 'status-vigueur',
|
||||
executed: 'status-vigueur',
|
||||
completed: 'status-vigueur',
|
||||
closed: 'status-clos',
|
||||
archived: 'status-clos',
|
||||
revoked: 'status-clos',
|
||||
reporting: 'status-vote',
|
||||
}
|
||||
|
||||
function getStatusClass(status: StatusFilter): string {
|
||||
return status.cssClass || statusCssMap[status.id] || 'status-prepa'
|
||||
}
|
||||
|
||||
function toggleStatus(statusId: string) {
|
||||
if (props.activeStatus === statusId) {
|
||||
emit('update:activeStatus', null)
|
||||
}
|
||||
else {
|
||||
emit('update:activeStatus', statusId)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section">
|
||||
<!-- Header -->
|
||||
<div class="section__header">
|
||||
<div class="section__header-left">
|
||||
<h1 class="section__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<!-- Mobile toolbox trigger -->
|
||||
<button
|
||||
class="section__toolbox-fab lg:hidden"
|
||||
:class="{ 'section__toolbox-fab--active': toolboxOpen }"
|
||||
@click="toolboxOpen = true"
|
||||
>
|
||||
<UIcon name="i-lucide-wrench" />
|
||||
<span>Outils</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body: content + toolbox -->
|
||||
<div class="section__body">
|
||||
<div class="section__main">
|
||||
<!-- Status pills -->
|
||||
<div v-if="statuses.length > 0" class="section__pills">
|
||||
<button
|
||||
v-for="status in statuses"
|
||||
:key="status.id"
|
||||
type="button"
|
||||
class="status-pill"
|
||||
:class="[getStatusClass(status), { active: activeStatus === status.id }]"
|
||||
@click="toggleStatus(status.id)"
|
||||
>
|
||||
{{ status.label }}
|
||||
<span v-if="status.count > 0" class="section__pill-count">{{ status.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.search" class="section__search">
|
||||
<slot name="search" />
|
||||
</div>
|
||||
<div class="section__content">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop toolbox sidebar (≥1024px) -->
|
||||
<aside class="section__toolbox">
|
||||
<div class="section__toolbox-head">
|
||||
<UIcon name="i-lucide-wrench" class="section__toolbox-head-icon" />
|
||||
<span>{{ toolboxTitle }}</span>
|
||||
</div>
|
||||
<div class="section__toolbox-body">
|
||||
<div v-if="$slots.toolbox">
|
||||
<slot name="toolbox" />
|
||||
</div>
|
||||
<div v-else-if="toolboxItems && toolboxItems.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="(item, idx) in toolboxItems"
|
||||
:key="idx"
|
||||
:title="item.title"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="section__toolbox-empty">
|
||||
Aucun outil disponible
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Mobile toolbox: USlideover from right -->
|
||||
<USlideover
|
||||
v-model:open="toolboxOpen"
|
||||
side="right"
|
||||
:title="toolboxTitle"
|
||||
:ui="{
|
||||
width: 'max-w-sm',
|
||||
header: { padding: 'p-4' },
|
||||
body: { padding: 'p-4' },
|
||||
}"
|
||||
>
|
||||
<template #body>
|
||||
<div class="section__toolbox-slideover">
|
||||
<div v-if="$slots.toolbox">
|
||||
<slot name="toolbox" />
|
||||
</div>
|
||||
<div v-else-if="toolboxItems && toolboxItems.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="(item, idx) in toolboxItems"
|
||||
:key="idx"
|
||||
:title="item.title"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="section__toolbox-empty">
|
||||
Aucun outil disponible
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.section { gap: 1.5rem; }
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.section__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.section__header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.section__title {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.section__title { font-size: 1.75rem; }
|
||||
}
|
||||
|
||||
.section__subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.section__subtitle { font-size: 1rem; }
|
||||
}
|
||||
|
||||
/* Mobile toolbox trigger */
|
||||
.section__toolbox-fab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.section__toolbox-fab:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.section__toolbox-fab--active {
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
/* Body layout */
|
||||
.section__body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.section__body {
|
||||
grid-template-columns: 1fr 30rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Status pills */
|
||||
.section__pills {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.section__pills::-webkit-scrollbar { display: none; }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.section__pills {
|
||||
flex-wrap: wrap;
|
||||
overflow-x: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.section__pill-count {
|
||||
margin-left: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.section__search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.section__search { flex-direction: column; }
|
||||
}
|
||||
|
||||
.section__content { min-height: 12rem; }
|
||||
|
||||
/* Desktop toolbox sidebar */
|
||||
.section__toolbox {
|
||||
display: none;
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
align-self: start;
|
||||
flex-direction: column;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 16px;
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
box-shadow: 0 4px 24px var(--mood-shadow);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.section__toolbox { display: flex; }
|
||||
}
|
||||
|
||||
.section__toolbox-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.875rem 1rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section__toolbox-head-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.section__toolbox-body {
|
||||
padding: 0 0.75rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section__toolbox-empty {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* Slideover content */
|
||||
.section__toolbox-slideover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,252 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const orgsStore = useOrganizationsStore()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const active = computed(() => orgsStore.active)
|
||||
const organizations = computed(() => orgsStore.organizations)
|
||||
|
||||
function selectOrg(slug: string) {
|
||||
orgsStore.setActive(slug)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// Close on outside click
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', (e) => {
|
||||
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
|
||||
isOpen.value = false
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="ws">
|
||||
<button
|
||||
class="ws__trigger"
|
||||
:class="{ 'ws__trigger--open': isOpen }"
|
||||
:disabled="orgsStore.loading || !active"
|
||||
@click="isOpen = !isOpen"
|
||||
>
|
||||
<div v-if="orgsStore.loading" class="ws__icon ws__icon--muted">
|
||||
<UIcon name="i-lucide-loader-2" class="animate-spin" />
|
||||
</div>
|
||||
<div v-else-if="active" class="ws__icon" :style="{ background: active.color ? active.color + '22' : undefined, color: active.color || undefined }">
|
||||
<UIcon :name="active.icon || 'i-lucide-building'" />
|
||||
</div>
|
||||
<span class="ws__name">{{ active?.name ?? '…' }}</span>
|
||||
<UIcon name="i-lucide-chevrons-up-down" class="ws__caret" />
|
||||
</button>
|
||||
|
||||
<Transition name="dropdown">
|
||||
<div v-if="isOpen && organizations.length" class="ws__dropdown">
|
||||
<div class="ws__dropdown-header">
|
||||
Espace de travail
|
||||
</div>
|
||||
<div class="ws__items">
|
||||
<button
|
||||
v-for="org in organizations"
|
||||
:key="org.id"
|
||||
class="ws__item"
|
||||
:class="{ 'ws__item--active': org.slug === orgsStore.activeSlug }"
|
||||
@click="selectOrg(org.slug)"
|
||||
>
|
||||
<div
|
||||
class="ws__item-icon"
|
||||
:style="{ background: org.color ? org.color + '22' : undefined, color: org.color || undefined }"
|
||||
>
|
||||
<UIcon :name="org.icon || 'i-lucide-building'" />
|
||||
</div>
|
||||
<div class="ws__item-info">
|
||||
<span class="ws__item-name">{{ org.name }}</span>
|
||||
<span class="ws__item-role">{{ org.is_transparent ? 'Public' : 'Membres' }}</span>
|
||||
</div>
|
||||
<UIcon v-if="org.slug === orgsStore.activeSlug" name="i-lucide-check" class="ws__item-check" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="ws__dropdown-footer">
|
||||
<button class="ws__new-btn" disabled>
|
||||
<UIcon name="i-lucide-plus" />
|
||||
Nouveau collectif
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ws {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ws__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s ease;
|
||||
min-height: 2rem;
|
||||
max-width: 11rem;
|
||||
}
|
||||
|
||||
.ws__trigger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--mood-accent-soft) 80%, var(--mood-accent) 20%);
|
||||
}
|
||||
|
||||
.ws__trigger--open {
|
||||
background: color-mix(in srgb, var(--mood-accent-soft) 60%, var(--mood-accent) 40%);
|
||||
}
|
||||
|
||||
.ws__trigger:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ws__icon {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.ws__icon--muted {
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.ws__name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ws__caret {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Dropdown */
|
||||
.ws__dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
min-width: 13rem;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 32px var(--mood-shadow);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ws__dropdown-header {
|
||||
padding: 0.625rem 0.875rem 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.ws__items {
|
||||
padding: 0.25rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ws__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.625rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ws__item:hover { background: var(--mood-accent-soft); }
|
||||
.ws__item--active { background: var(--mood-accent-soft); }
|
||||
|
||||
.ws__item-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ws__item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ws__item-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.ws__item-role {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.ws__item-check {
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws__dropdown-footer {
|
||||
padding: 0.5rem;
|
||||
border-top: 1px solid var(--mood-accent-soft);
|
||||
}
|
||||
|
||||
.ws__new-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
background: none;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.dropdown-enter-active, .dropdown-leave-active {
|
||||
transition: all 0.15s ease;
|
||||
transform-origin: top left;
|
||||
}
|
||||
.dropdown-enter-from, .dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
diff: string
|
||||
}>()
|
||||
|
||||
interface DiffLine {
|
||||
text: string
|
||||
type: 'added' | 'removed' | 'header' | 'context'
|
||||
}
|
||||
|
||||
const parsedLines = computed((): DiffLine[] => {
|
||||
if (!props.diff) return []
|
||||
|
||||
return props.diff.split('\n').map((line) => {
|
||||
if (line.startsWith('@@')) {
|
||||
return { text: line, type: 'header' as const }
|
||||
}
|
||||
if (line.startsWith('+')) {
|
||||
return { text: line, type: 'added' as const }
|
||||
}
|
||||
if (line.startsWith('-')) {
|
||||
return { text: line, type: 'removed' as const }
|
||||
}
|
||||
return { text: line, type: 'context' as const }
|
||||
})
|
||||
})
|
||||
|
||||
function lineClass(type: DiffLine['type']): string {
|
||||
switch (type) {
|
||||
case 'added':
|
||||
return 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
case 'removed':
|
||||
return 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
case 'header':
|
||||
return 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 font-semibold'
|
||||
default:
|
||||
return 'text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<pre class="text-xs font-mono leading-relaxed overflow-x-auto"><template
|
||||
v-for="(line, index) in parsedLines"
|
||||
:key="index"
|
||||
><div
|
||||
:class="['px-4 py-0.5', lineClass(line.type)]"
|
||||
>{{ line.text }}</div></template></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Error boundary component.
|
||||
*
|
||||
* Wraps slot content with NuxtErrorBoundary and displays a user-friendly
|
||||
* error message in French when child components crash.
|
||||
* Logs error details to console and emits error event for monitoring.
|
||||
*/
|
||||
|
||||
const emit = defineEmits<{
|
||||
error: [error: any]
|
||||
}>()
|
||||
|
||||
const hasError = ref(false)
|
||||
const errorDetails = ref<string | null>(null)
|
||||
|
||||
function handleError(error: any) {
|
||||
hasError.value = true
|
||||
errorDetails.value = error?.message || error?.toString() || 'Erreur inconnue'
|
||||
|
||||
// Log to console for debugging
|
||||
console.error('[ErrorBoundary] Erreur capturee:', error)
|
||||
|
||||
// Emit for external monitoring
|
||||
emit('error', error)
|
||||
}
|
||||
|
||||
function retry() {
|
||||
hasError.value = false
|
||||
errorDetails.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtErrorBoundary @error="handleError">
|
||||
<template v-if="!hasError">
|
||||
<slot />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div class="max-w-md space-y-4">
|
||||
<UIcon
|
||||
name="i-lucide-alert-triangle"
|
||||
class="text-5xl text-warning mx-auto"
|
||||
/>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Une erreur est survenue
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Un probleme inattendu s'est produit lors du chargement de ce contenu.
|
||||
Vous pouvez essayer de recharger cette section.
|
||||
</p>
|
||||
<p
|
||||
v-if="errorDetails"
|
||||
class="text-xs text-gray-400 dark:text-gray-500 font-mono bg-gray-100 dark:bg-gray-800 p-2 rounded"
|
||||
>
|
||||
{{ errorDetails }}
|
||||
</p>
|
||||
<UButton
|
||||
icon="i-lucide-refresh-cw"
|
||||
label="Reessayer"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
@click="retry"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</NuxtErrorBoundary>
|
||||
</template>
|
||||
@@ -1,81 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Reusable skeleton loader component.
|
||||
*
|
||||
* Provides multiple skeleton variants for loading states:
|
||||
* - Card: card layout with title and content lines
|
||||
* - List: multiple rows with optional avatar
|
||||
* - Detail: detailed view with mixed content
|
||||
*
|
||||
* Uses Nuxt UI USkeleton components.
|
||||
*/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** Number of skeleton lines to display (default: 3). */
|
||||
lines?: number
|
||||
/** Show an avatar circle placeholder. */
|
||||
avatar?: boolean
|
||||
/** Render as a card skeleton with border and padding. */
|
||||
card?: boolean
|
||||
}>(),
|
||||
{
|
||||
lines: 3,
|
||||
avatar: false,
|
||||
card: false,
|
||||
},
|
||||
)
|
||||
|
||||
/** Generate varying line widths for a natural appearance. */
|
||||
const lineWidths = computed(() => {
|
||||
const widths = ['w-full', 'w-3/4', 'w-5/6', 'w-2/3', 'w-4/5']
|
||||
return Array.from({ length: props.lines }, (_, i) => widths[i % widths.length])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Card variant -->
|
||||
<UCard v-if="card" class="animate-pulse">
|
||||
<div class="space-y-4">
|
||||
<!-- Optional avatar row -->
|
||||
<div v-if="avatar" class="flex items-center gap-3">
|
||||
<USkeleton class="h-10 w-10 rounded-full" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<USkeleton class="h-4 w-1/3" />
|
||||
<USkeleton class="h-3 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title line -->
|
||||
<USkeleton class="h-5 w-2/3" />
|
||||
|
||||
<!-- Content lines -->
|
||||
<div class="space-y-2">
|
||||
<USkeleton
|
||||
v-for="(width, i) in lineWidths"
|
||||
:key="i"
|
||||
class="h-3"
|
||||
:class="width"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- List / default variant -->
|
||||
<div v-else class="space-y-3 animate-pulse">
|
||||
<div
|
||||
v-for="(width, i) in lineWidths"
|
||||
:key="i"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<!-- Optional avatar per line -->
|
||||
<USkeleton v-if="avatar" class="h-8 w-8 rounded-full flex-shrink-0" />
|
||||
|
||||
<!-- Line content -->
|
||||
<div class="flex-1 space-y-1">
|
||||
<USkeleton class="h-3" :class="width" />
|
||||
<USkeleton v-if="i === 0" class="h-2 w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const { currentMood, moods, setMood } = useLibreMood()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mood-switcher" role="radiogroup" aria-label="Ambiance visuelle">
|
||||
<UTooltip
|
||||
v-for="mood in moods"
|
||||
:key="mood.id"
|
||||
:text="mood.label"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="currentMood === mood.id"
|
||||
:aria-label="`Ambiance ${mood.label}`"
|
||||
class="mood-dot"
|
||||
:class="{ 'mood-dot--active': currentMood === mood.id }"
|
||||
:style="{ '--dot-color': mood.color }"
|
||||
@click="setMood(mood.id)"
|
||||
/>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mood-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.mood-dot {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
background: var(--dot-color, var(--mood-text-muted));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mood-dot:hover {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.mood-dot--active {
|
||||
border-color: var(--mood-text);
|
||||
box-shadow: 0 0 0 2px var(--mood-bg), 0 0 0 3px var(--mood-text-muted);
|
||||
transform: scale(1.15);
|
||||
}
|
||||
</style>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Offline detection banner.
|
||||
*
|
||||
* Uses navigator.onLine and online/offline events to detect
|
||||
* network connectivity changes. Shows a warning banner when
|
||||
* offline and a brief success message when back online.
|
||||
*/
|
||||
|
||||
const isOnline = ref(true)
|
||||
const showReconnected = ref(false)
|
||||
|
||||
let reconnectedTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleOffline() {
|
||||
isOnline.value = false
|
||||
showReconnected.value = false
|
||||
if (reconnectedTimer) {
|
||||
clearTimeout(reconnectedTimer)
|
||||
reconnectedTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnline() {
|
||||
isOnline.value = true
|
||||
showReconnected.value = true
|
||||
|
||||
// Show "reconnected" message briefly then hide
|
||||
reconnectedTimer = setTimeout(() => {
|
||||
showReconnected.value = false
|
||||
reconnectedTimer = null
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
isOnline.value = navigator.onLine
|
||||
window.addEventListener('offline', handleOffline)
|
||||
window.addEventListener('online', handleOnline)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('offline', handleOffline)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
if (reconnectedTimer) {
|
||||
clearTimeout(reconnectedTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="slide-down">
|
||||
<div
|
||||
v-if="!isOnline"
|
||||
class="bg-warning-100 dark:bg-warning-900/50 border-b border-warning-300 dark:border-warning-700 px-4 py-2 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-warning-800 dark:text-warning-200">
|
||||
<UIcon name="i-lucide-wifi-off" class="text-lg flex-shrink-0" />
|
||||
<span>Vous etes hors ligne. Certaines fonctionnalites sont indisponibles.</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-down">
|
||||
<div
|
||||
v-if="showReconnected && isOnline"
|
||||
class="bg-success-100 dark:bg-success-900/50 border-b border-success-300 dark:border-success-700 px-4 py-2 text-center"
|
||||
role="status"
|
||||
>
|
||||
<div class="flex items-center justify-center gap-2 text-sm text-success-800 dark:text-success-200">
|
||||
<UIcon name="i-lucide-wifi" class="text-lg flex-shrink-0" />
|
||||
<span>Connexion retablie</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.slide-down-enter-from,
|
||||
.slide-down-leave-to {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-down-enter-to,
|
||||
.slide-down-leave-from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
status: string
|
||||
type?: 'document' | 'decision' | 'mandate' | 'vote' | 'version'
|
||||
clickable?: boolean
|
||||
active?: boolean
|
||||
}>(), {
|
||||
clickable: true,
|
||||
active: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
|
||||
// Universal statuses
|
||||
draft: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
active: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
||||
closed: { label: 'Clos', cssClass: 'status-clos' },
|
||||
|
||||
// Decision/vote specific
|
||||
qualification: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
review: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
voting: { label: 'En vote', cssClass: 'status-vote' },
|
||||
open: { label: 'En vote', cssClass: 'status-vote' },
|
||||
executed: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
||||
|
||||
// Version specific
|
||||
pending: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
accepted: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
||||
rejected: { label: 'Clos', cssClass: 'status-clos' },
|
||||
|
||||
// Mandate specific
|
||||
formulation: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
candidature: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
candidacy: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
investiture: { label: 'En vote', cssClass: 'status-vote' },
|
||||
revoked: { label: 'Clos', cssClass: 'status-clos' },
|
||||
completed: { label: 'Clos', cssClass: 'status-clos' },
|
||||
archived: { label: 'Clos', cssClass: 'status-clos' },
|
||||
reporting: { label: 'En vote', cssClass: 'status-vote' },
|
||||
}
|
||||
|
||||
const resolved = computed(() => {
|
||||
return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (props.clickable) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
v-if="clickable"
|
||||
type="button"
|
||||
class="status-pill"
|
||||
:class="[resolved.cssClass, { active: active }]"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ resolved.label }}
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="status-pill"
|
||||
:class="[resolved.cssClass]"
|
||||
>
|
||||
{{ resolved.label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -1,183 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ToolboxVignette — Carte compacte, collapsible, bullet points + actions.
|
||||
*/
|
||||
|
||||
export interface ToolboxAction {
|
||||
label: string
|
||||
icon?: string
|
||||
to?: string
|
||||
emit?: string
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
bullets?: string[]
|
||||
actions?: ToolboxAction[]
|
||||
defaultOpen?: boolean
|
||||
}>(),
|
||||
{
|
||||
bullets: undefined,
|
||||
actions: undefined,
|
||||
defaultOpen: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: [actionEmit: string]
|
||||
}>()
|
||||
|
||||
const open = ref(props.defaultOpen)
|
||||
|
||||
const defaultActions: ToolboxAction[] = [
|
||||
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
|
||||
{ label: 'Formules', icon: 'i-lucide-calculator', emit: 'formules' },
|
||||
{ label: 'Demarrer', icon: 'i-lucide-play', emit: 'demarrer', primary: true },
|
||||
]
|
||||
|
||||
const resolvedActions = computed(() => props.actions ?? defaultActions)
|
||||
|
||||
function handleAction(action: ToolboxAction) {
|
||||
if (action.to) {
|
||||
navigateTo(action.to)
|
||||
}
|
||||
else if (action.emit) {
|
||||
emit('action', action.emit)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vignette" :class="{ 'vignette--open': open }">
|
||||
<button class="vignette__header" @click="open = !open">
|
||||
<h4 class="vignette__title">{{ title }}</h4>
|
||||
<UIcon name="i-lucide-chevron-down" class="vignette__chevron" />
|
||||
</button>
|
||||
<div v-show="open" class="vignette__content">
|
||||
<ul v-if="bullets && bullets.length > 0" class="vignette__bullets">
|
||||
<li v-for="(b, i) in bullets" :key="i">{{ b }}</li>
|
||||
</ul>
|
||||
<div class="vignette__actions">
|
||||
<button
|
||||
v-for="action in resolvedActions"
|
||||
:key="action.label"
|
||||
class="vignette__btn"
|
||||
:class="{ 'vignette__btn--primary': action.primary }"
|
||||
@click="handleAction(action)"
|
||||
>
|
||||
<UIcon v-if="action.icon" :name="action.icon" />
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vignette {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vignette__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
gap: 0.375rem;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.vignette__header:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.vignette__title {
|
||||
flex: 1;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vignette__chevron {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
opacity: 0.5;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vignette--open .vignette__chevron {
|
||||
transform: rotate(180deg);
|
||||
opacity: 1;
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.vignette__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem 0.625rem;
|
||||
}
|
||||
|
||||
.vignette__bullets {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
list-style-type: disc;
|
||||
}
|
||||
.vignette__bullets li::marker {
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.vignette__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.vignette__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-surface);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.vignette__btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.vignette__btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.vignette__btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.vignette__btn--primary {
|
||||
color: var(--mood-accent-text);
|
||||
background: var(--mood-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,88 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Document } from '~/stores/documents'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
documents: Document[]
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const typeLabel = (docType: string): string => {
|
||||
switch (docType) {
|
||||
case 'licence': return 'Licence'
|
||||
case 'engagement': return 'Engagement'
|
||||
case 'reglement': return 'Reglement'
|
||||
case 'constitution': return 'Constitution'
|
||||
default: return docType
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<USkeleton v-for="i in 6" :key="i" class="h-48 w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<UCard v-else-if="documents.length === 0">
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-book-open" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun document de reference pour le moment</p>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Document grid -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<UCard
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
@click="navigateTo(`/documents/${doc.slug}`)"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
|
||||
{{ doc.title }}
|
||||
</h3>
|
||||
<StatusBadge :status="doc.status" type="document" />
|
||||
</div>
|
||||
|
||||
<!-- Type + Version -->
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(doc.doc_type) }}
|
||||
</UBadge>
|
||||
<span class="text-xs text-gray-500 font-mono">v{{ doc.version }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p
|
||||
v-if="doc.description"
|
||||
class="text-xs text-gray-600 dark:text-gray-400 line-clamp-2"
|
||||
>
|
||||
{{ doc.description }}
|
||||
</p>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
||||
<UIcon name="i-lucide-list" class="text-sm" />
|
||||
<span>{{ doc.items_count }} items</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">{{ formatDate(doc.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,347 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DocumentPreview — clean "PDF-like" viewer for a reference document.
|
||||
*
|
||||
* Two modes:
|
||||
* - current: shows current_text for every item (document en vigueur)
|
||||
* - projected: applies latest "vote" or "proposed" version per item,
|
||||
* highlighting changed clauses (document tel qu'il serait si les
|
||||
* votes en cours passaient)
|
||||
*/
|
||||
import type { Document, DocumentItem, ItemVersion } from '~/stores/documents'
|
||||
|
||||
const props = defineProps<{
|
||||
document: Document
|
||||
items: DocumentItem[]
|
||||
mode: 'current' | 'projected'
|
||||
versionMap: Record<string, ItemVersion | null>
|
||||
}>()
|
||||
|
||||
const sortedItems = computed(() =>
|
||||
[...props.items].sort((a, b) => a.sort_order - b.sort_order),
|
||||
)
|
||||
|
||||
const changedCount = computed(() =>
|
||||
props.mode === 'projected'
|
||||
? Object.values(props.versionMap).filter(Boolean).length
|
||||
: 0,
|
||||
)
|
||||
|
||||
function getDisplayText(item: DocumentItem): string {
|
||||
if (props.mode === 'projected' && props.versionMap[item.id]) {
|
||||
return props.versionMap[item.id]!.proposed_text
|
||||
}
|
||||
return item.current_text
|
||||
}
|
||||
|
||||
function isChanged(item: DocumentItem): boolean {
|
||||
return props.mode === 'projected' && !!props.versionMap[item.id]
|
||||
}
|
||||
|
||||
function itemTypeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'clause': return 'Engagement'
|
||||
case 'rule': return 'Variable'
|
||||
case 'verification': return 'Application'
|
||||
case 'preamble': return 'Préambule'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
const today = new Date().toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="doc-preview">
|
||||
<!-- ══ Document header ══ -->
|
||||
<div class="doc-preview__header">
|
||||
<div class="doc-preview__watermark" aria-hidden="true">
|
||||
{{ mode === 'projected' ? 'PROJECTION' : 'EN VIGUEUR' }}
|
||||
</div>
|
||||
<h1 class="doc-preview__title">{{ document.title }}</h1>
|
||||
<div class="doc-preview__meta">
|
||||
<span>Version {{ document.version }}</span>
|
||||
<span class="doc-preview__sep">·</span>
|
||||
<span>{{ document.items_count }} items</span>
|
||||
<span v-if="mode === 'projected'" class="doc-preview__proj-badge">
|
||||
<UIcon name="i-lucide-flask-conical" class="text-xs" />
|
||||
{{ changedCount }} modification{{ changedCount > 1 ? 's' : '' }} projetée{{ changedCount > 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span v-else class="doc-preview__current-badge">
|
||||
<UIcon name="i-lucide-circle-check" class="text-xs" />
|
||||
Texte officiel
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Items ══ -->
|
||||
<div class="doc-preview__body">
|
||||
<template v-for="item in sortedItems" :key="item.id">
|
||||
<!-- Section heading -->
|
||||
<div v-if="item.item_type === 'section'" class="doc-preview__section">
|
||||
<h2 class="doc-preview__section-title">
|
||||
<UIcon name="i-lucide-bookmark" class="text-sm" style="color: var(--mood-accent)" />
|
||||
{{ item.title || item.current_text }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Preamble -->
|
||||
<div v-else-if="item.item_type === 'preamble'" class="doc-preview__preamble">
|
||||
<MarkdownRenderer :content="getDisplayText(item)" />
|
||||
</div>
|
||||
|
||||
<!-- Regular clause / rule / verification -->
|
||||
<div
|
||||
v-else
|
||||
class="doc-preview__item"
|
||||
:class="{ 'doc-preview__item--changed': isChanged(item) }"
|
||||
>
|
||||
<div class="doc-preview__item-head">
|
||||
<span class="doc-preview__item-pos">{{ item.position }}</span>
|
||||
<span v-if="item.title" class="doc-preview__item-title">{{ item.title }}</span>
|
||||
<span v-if="itemTypeLabel(item.item_type)" class="doc-preview__item-type">
|
||||
{{ itemTypeLabel(item.item_type) }}
|
||||
</span>
|
||||
<span v-if="isChanged(item)" class="doc-preview__change-chip">
|
||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
||||
Vote en cours
|
||||
</span>
|
||||
</div>
|
||||
<div class="doc-preview__item-text">
|
||||
<MarkdownRenderer :content="getDisplayText(item)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══ Footer ══ -->
|
||||
<div class="doc-preview__footer">
|
||||
<div class="doc-preview__footer-main">
|
||||
<span>libreDecision · {{ document.title }} · v{{ document.version }}</span>
|
||||
</div>
|
||||
<div v-if="mode === 'projected'" class="doc-preview__footer-note">
|
||||
Projection non officielle — texte simulé selon {{ changedCount }} vote{{ changedCount > 1 ? 's' : '' }} en cours au {{ today }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-preview {
|
||||
position: relative;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 16px;
|
||||
padding: clamp(1.5rem, 4vw, 3rem);
|
||||
box-shadow: 0 4px 32px var(--mood-shadow);
|
||||
line-height: 1.75;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Watermark ── */
|
||||
.doc-preview__watermark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-35deg);
|
||||
font-size: clamp(2rem, 8vw, 5rem);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--mood-accent);
|
||||
opacity: 0.03;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.doc-preview__header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.doc-preview__title {
|
||||
font-size: clamp(1.25rem, 3vw, 1.875rem);
|
||||
font-weight: 900;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.doc-preview__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.doc-preview__sep { opacity: 0.3; }
|
||||
|
||||
.doc-preview__proj-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2px 0.625rem;
|
||||
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 15%, transparent);
|
||||
color: var(--mood-warning, #d97706);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doc-preview__current-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 2px 0.625rem;
|
||||
background: color-mix(in srgb, var(--mood-success, #16a34a) 12%, transparent);
|
||||
color: var(--mood-success, #16a34a);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ── Body ── */
|
||||
.doc-preview__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
/* Section heading */
|
||||
.doc-preview__section {
|
||||
padding-top: 1.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.doc-preview__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||
padding-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
/* Preamble */
|
||||
.doc-preview__preamble {
|
||||
padding: 1rem 1.25rem;
|
||||
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
||||
border-radius: 12px;
|
||||
font-style: italic;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
/* Item */
|
||||
.doc-preview__item {
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--mood-bg) 35%, transparent);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.doc-preview__item--changed {
|
||||
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 8%, var(--mood-surface));
|
||||
border-left: 3px solid var(--mood-warning, #f59e0b);
|
||||
}
|
||||
|
||||
.doc-preview__item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.doc-preview__item-pos {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
font-family: monospace;
|
||||
color: var(--mood-accent);
|
||||
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 6px;
|
||||
min-width: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.doc-preview__item-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.doc-preview__item-type {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.doc-preview__change-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 1px 0.5rem;
|
||||
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 18%, transparent);
|
||||
color: var(--mood-warning, #d97706);
|
||||
border-radius: 999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.doc-preview__item-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
/* ── Footer ── */
|
||||
.doc-preview__footer {
|
||||
margin-top: 3rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.doc-preview__footer-main {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-preview__footer-note {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
font-style: italic;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ── Print ── */
|
||||
@media print {
|
||||
.doc-preview {
|
||||
box-shadow: none;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
color: black;
|
||||
}
|
||||
.doc-preview__watermark { opacity: 0.05; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<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. À 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 (difficulté de remplacement modérée). Les annexes sont plus faciles à modifier. La formule et ses réglages sont très protégés.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
title: 'Seuil adaptatif',
|
||||
text: 'La formule WoT adapte le seuil à la participation : peu de votants = quasi-unanimité requise ; beaucoup de votants = majorité 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: 'Dépôt automatique',
|
||||
text: 'Quand une alternative est adoptée, le document officiel est mis à jour, ancré sur IPFS et on-chain, puis déployé dans les applications (Cesium, Gecko).',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UButton
|
||||
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 ça 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)">
|
||||
Référence : 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>
|
||||
@@ -1,303 +0,0 @@
|
||||
<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 'Variables'
|
||||
case 'verification': return 'Application'
|
||||
case 'preamble': return 'Préambule'
|
||||
case 'section': return 'Titre'
|
||||
default: return props.item.item_type
|
||||
}
|
||||
})
|
||||
|
||||
// Mock vote data varies by item for demo — items in "bonnes pratiques" (E8-E11) get lower/mixed votes
|
||||
const mockVotes = computed(() => {
|
||||
const order = props.item.sort_order
|
||||
const pos = props.item.position
|
||||
|
||||
// Conseils et bonnes pratiques: varied votes, some non-adopted
|
||||
if (pos === 'E8') return { votesFor: 4, votesAgainst: 3 } // contested
|
||||
if (pos === 'E9') return { votesFor: 2, votesAgainst: 5 } // rejected
|
||||
if (pos === 'E10') return { votesFor: 6, votesAgainst: 2 } // borderline
|
||||
if (pos === 'E11') return { votesFor: 3, votesAgainst: 4 } // rejected
|
||||
|
||||
// Default: well-adopted items
|
||||
const base = ((order * 7 + 13) % 5) + 8 // 8-12
|
||||
const against = (order % 3) // 0-2
|
||||
return { votesFor: base, votesAgainst: against }
|
||||
})
|
||||
|
||||
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>
|
||||
<span class="engagement-card__type-label">
|
||||
{{ itemTypeLabel }}
|
||||
</span>
|
||||
</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="mockVotes.votesFor"
|
||||
:votes-against="mockVotes.votesAgainst"
|
||||
: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__type-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--mood-accent);
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
||||
}
|
||||
|
||||
.engagement-card__body {
|
||||
padding: 0.5rem 1rem 0.625rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.engagement-card__body {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.engagement-card__vote {
|
||||
padding: 0 1rem;
|
||||
opacity: 0.7;
|
||||
transform: scale(0.92);
|
||||
transform-origin: left center;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.engagement-card:hover .engagement-card__vote {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.engagement-card__inertia {
|
||||
padding: 0.25rem 1rem 0.5rem;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.engagement-card:hover .engagement-card__inertia {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -1,489 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
|
||||
* for a reference document. Main block collapsible, each sub-section independently collapsible.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
genesisJson: string
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
// Individual section toggles
|
||||
const sectionOpen = reactive<Record<string, boolean>>({
|
||||
source: true,
|
||||
tools: false,
|
||||
forum: true,
|
||||
process: false,
|
||||
contributors: false,
|
||||
})
|
||||
|
||||
function toggleSection(key: string) {
|
||||
sectionOpen[key] = !sectionOpen[key]
|
||||
}
|
||||
|
||||
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 statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rejected': return 'Rejetée'
|
||||
case 'in_progress': return 'En cours'
|
||||
case 'reference': return 'Référence'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const statusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rejected': return 'genesis-status--rejected'
|
||||
case 'in_progress': return 'genesis-status--progress'
|
||||
case 'reference': return 'genesis-status--reference'
|
||||
default: return 'genesis-status--default'
|
||||
}
|
||||
}
|
||||
</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 genesis-accent">
|
||||
Bloc de genèse
|
||||
</h3>
|
||||
<p class="text-xs genesis-text-muted">
|
||||
Sources, références et formule de dépôt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-lg genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Expandable content -->
|
||||
<Transition name="genesis-expand">
|
||||
<div v-if="expanded" class="genesis-block__body">
|
||||
<!-- Source document -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('source')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-file-text" />
|
||||
Document source
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.source ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.source" class="genesis-section__content">
|
||||
<div class="genesis-card">
|
||||
<p class="font-medium text-sm genesis-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" />
|
||||
Dépôt git
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reference tools -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('tools')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-wrench" />
|
||||
Outils de référence
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.tools ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.tools" class="genesis-section__content">
|
||||
<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 genesis-text">
|
||||
{{ name.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
<UIcon name="i-lucide-external-link" class="text-xs genesis-text-muted" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forum synthesis -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('forum')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-messages-square" />
|
||||
Synthèse des discussions
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.forum ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.forum" class="genesis-section__content">
|
||||
<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 genesis-text">
|
||||
{{ topic.title }}
|
||||
</span>
|
||||
<span
|
||||
class="genesis-status shrink-0"
|
||||
:class="statusClass(topic.status)"
|
||||
>
|
||||
{{ statusLabel(topic.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="topic.posts" class="text-xs genesis-text-muted">
|
||||
{{ topic.posts }} messages
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula trigger -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('process')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-zap" />
|
||||
Processus de dépôt
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.process ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.process" class="genesis-section__content">
|
||||
<div class="genesis-card">
|
||||
<p class="text-xs leading-relaxed genesis-text">
|
||||
{{ genesis.formula_trigger }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contributors -->
|
||||
<div class="genesis-section">
|
||||
<button class="genesis-section__toggle" @click="toggleSection('contributors')">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-users" />
|
||||
Contributeurs
|
||||
</h4>
|
||||
<UIcon
|
||||
:name="sectionOpen.contributors ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-sm genesis-muted-icon"
|
||||
/>
|
||||
</button>
|
||||
<div v-if="sectionOpen.contributors" class="genesis-section__content">
|
||||
<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 genesis-text">{{ c.name }}</span>
|
||||
<span class="text-xs genesis-text-muted">{{ c.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.genesis-block {
|
||||
background: color-mix(in srgb, var(--mood-accent) 8%, var(--mood-surface));
|
||||
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.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) 4%, transparent);
|
||||
}
|
||||
|
||||
.genesis-block__header h3 {
|
||||
color: var(--mood-accent) !important;
|
||||
}
|
||||
|
||||
.genesis-block__header p {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
.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) 15%, transparent);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.genesis-block__body {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.genesis-section {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.genesis-section__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-section__toggle:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 6%, transparent);
|
||||
}
|
||||
|
||||
.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: 0;
|
||||
}
|
||||
|
||||
.genesis-section__toggle .text-sm {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
.genesis-section__content {
|
||||
padding: 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.genesis-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 5%, var(--mood-surface));
|
||||
}
|
||||
|
||||
.genesis-card .font-medium,
|
||||
.genesis-card .text-xs,
|
||||
.genesis-text {
|
||||
color: var(--mood-text) !important;
|
||||
}
|
||||
|
||||
.genesis-card--tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-card--tool .text-xs {
|
||||
color: var(--mood-text) !important;
|
||||
}
|
||||
|
||||
.genesis-card--tool:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
||||
}
|
||||
|
||||
.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-surface));
|
||||
}
|
||||
|
||||
.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) 5%, var(--mood-surface));
|
||||
}
|
||||
|
||||
.genesis-contributor .font-semibold {
|
||||
color: var(--mood-text) !important;
|
||||
}
|
||||
|
||||
.genesis-contributor .text-xs:not(.font-semibold) {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
/* Status badges — palette-aware */
|
||||
.genesis-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.genesis-status--reference {
|
||||
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.genesis-status--progress {
|
||||
background: color-mix(in srgb, var(--mood-warning) 20%, transparent);
|
||||
color: var(--mood-warning);
|
||||
}
|
||||
|
||||
.genesis-status--rejected {
|
||||
background: color-mix(in srgb, var(--mood-error) 20%, transparent);
|
||||
color: var(--mood-error);
|
||||
}
|
||||
|
||||
.genesis-status--default {
|
||||
background: color-mix(in srgb, var(--mood-text) 8%, transparent);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
/* Genesis-context text utilities */
|
||||
.genesis-accent {
|
||||
color: var(--mood-accent) !important;
|
||||
}
|
||||
|
||||
.genesis-text {
|
||||
color: var(--mood-text) !important;
|
||||
}
|
||||
|
||||
.genesis-text-muted {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
.genesis-muted-icon {
|
||||
color: var(--mood-text-muted) !important;
|
||||
}
|
||||
|
||||
/* 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>
|
||||
@@ -1,419 +0,0 @@
|
||||
<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).
|
||||
* In full mode: shows formula diagram with simplified curve visualization.
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
preset: string
|
||||
compact?: boolean
|
||||
mini?: boolean
|
||||
}>(), {
|
||||
compact: false,
|
||||
mini: 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: 'Remplacement facile',
|
||||
gradient: 0.1,
|
||||
majority: 50,
|
||||
color: '#22c55e',
|
||||
position: 10,
|
||||
description: 'Majorité simple suffit, même à faible participation',
|
||||
},
|
||||
standard: {
|
||||
label: 'Inertie pour le remplacement',
|
||||
gradient: 0.2,
|
||||
majority: 50,
|
||||
color: '#3b82f6',
|
||||
position: 37,
|
||||
description: 'Équilibre : consensus croissant avec la participation',
|
||||
},
|
||||
high: {
|
||||
label: 'Remplacement difficile',
|
||||
gradient: 0.4,
|
||||
majority: 60,
|
||||
color: '#f59e0b',
|
||||
position: 63,
|
||||
description: 'Forte mobilisation et super-majorité requises',
|
||||
},
|
||||
very_high: {
|
||||
label: 'Remplacement très difficile',
|
||||
gradient: 0.6,
|
||||
majority: 66,
|
||||
color: '#ef4444',
|
||||
position: 90,
|
||||
description: 'Quasi-unanimité requise à toute participation',
|
||||
},
|
||||
}
|
||||
|
||||
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
|
||||
|
||||
// Generate SVG curve points for the inertia function
|
||||
// Formula simplified: Seuil% = M + (1-M) × (1 - (T/W)^G)
|
||||
// Where T/W = participation rate, so Seuil% goes from ~100% at low participation to M at full participation
|
||||
const curvePath = computed(() => {
|
||||
const G = level.value.gradient
|
||||
const M = level.value.majority / 100
|
||||
const points: string[] = []
|
||||
const steps = 40
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const participation = i / steps // T/W ratio 0..1
|
||||
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
|
||||
// SVG coordinates: x = participation (0..200), y = threshold inverted (0=100%, 80=20%)
|
||||
const x = 30 + participation * 170
|
||||
const y = 10 + (1 - threshold) * 70
|
||||
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
}
|
||||
|
||||
return `M ${points.join(' L ')}`
|
||||
})
|
||||
|
||||
// The 4 curve paths for the diagram overlay
|
||||
const allCurves = computed(() => {
|
||||
return Object.entries(LEVELS).map(([key, lvl]) => {
|
||||
const G = lvl.gradient
|
||||
const M = lvl.majority / 100
|
||||
const points: string[] = []
|
||||
const steps = 40
|
||||
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const participation = i / steps
|
||||
const threshold = M + (1 - M) * (1 - Math.pow(participation, G))
|
||||
const x = 30 + participation * 170
|
||||
const y = 10 + (1 - threshold) * 70
|
||||
points.push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
color: lvl.color,
|
||||
path: `M ${points.join(' L ')}`,
|
||||
active: key === props.preset,
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inertia" :class="{ 'inertia--compact': compact, 'inertia--mini': mini }">
|
||||
<!-- 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 v-if="mini" class="inertia__info">
|
||||
<span class="inertia__label inertia__label--mini" :style="{ color: level.color }">
|
||||
Inertie
|
||||
</span>
|
||||
</div>
|
||||
<div v-else 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>
|
||||
|
||||
<!-- Formula diagram (not in compact mode) -->
|
||||
<div v-if="!compact" class="inertia__diagram">
|
||||
<svg viewBox="0 0 220 100" class="inertia__svg">
|
||||
<!-- Grid -->
|
||||
<line x1="30" y1="10" x2="30" y2="80" class="inertia__axis" />
|
||||
<line x1="30" y1="80" x2="200" y2="80" class="inertia__axis" />
|
||||
|
||||
<!-- Grid lines -->
|
||||
<line x1="30" y1="10" x2="200" y2="10" class="inertia__grid" />
|
||||
<line x1="30" y1="45" x2="200" y2="45" class="inertia__grid" />
|
||||
|
||||
<!-- Majority line M -->
|
||||
<line
|
||||
x1="30"
|
||||
:y1="10 + (1 - level.majority / 100) * 70"
|
||||
x2="200"
|
||||
:y2="10 + (1 - level.majority / 100) * 70"
|
||||
class="inertia__majority-line"
|
||||
/>
|
||||
<text
|
||||
x="203"
|
||||
:y="13 + (1 - level.majority / 100) * 70"
|
||||
class="inertia__axis-label"
|
||||
style="fill: var(--mood-accent)"
|
||||
>M={{ level.majority }}%</text>
|
||||
|
||||
<!-- Background curves (ghosted) -->
|
||||
<path
|
||||
v-for="curve in allCurves"
|
||||
:key="curve.key"
|
||||
:d="curve.path"
|
||||
fill="none"
|
||||
:stroke="curve.color"
|
||||
:stroke-width="curve.active ? 0 : 1"
|
||||
:opacity="curve.active ? 0 : 0.15"
|
||||
stroke-dasharray="3 3"
|
||||
/>
|
||||
|
||||
<!-- Active curve -->
|
||||
<path
|
||||
:d="curvePath"
|
||||
fill="none"
|
||||
:stroke="level.color"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- Axis labels -->
|
||||
<text x="15" y="14" class="inertia__axis-label">100%</text>
|
||||
<text x="15" y="49" class="inertia__axis-label">50%</text>
|
||||
<text x="15" y="84" class="inertia__axis-label">0%</text>
|
||||
|
||||
<text x="28" y="95" class="inertia__axis-label">0%</text>
|
||||
<text x="105" y="95" class="inertia__axis-label">50%</text>
|
||||
<text x="185" y="95" class="inertia__axis-label">100%</text>
|
||||
|
||||
<!-- Axis titles -->
|
||||
<text x="3" y="50" class="inertia__axis-title" transform="rotate(-90, 6, 50)">Seuil</text>
|
||||
<text x="110" y="100" class="inertia__axis-title">Participation (T/W)</text>
|
||||
</svg>
|
||||
|
||||
<!-- Simplified formula -->
|
||||
<div class="inertia__formula">
|
||||
<span class="inertia__formula-label">Formule :</span>
|
||||
<code class="inertia__formula-code">Seuil = M + (1-M) × (1 - (T/W)<sup>G</sup>)</code>
|
||||
</div>
|
||||
<div class="inertia__formula-legend">
|
||||
<span><strong>T</strong> = votes exprimés</span>
|
||||
<span><strong>W</strong> = taille WoT</span>
|
||||
<span><strong>M</strong> = majorité cible</span>
|
||||
<span><strong>G</strong> = gradient d'inertie</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inertia {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.inertia--compact {
|
||||
gap: 0.25rem;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.inertia--mini {
|
||||
gap: 0.125rem;
|
||||
width: fit-content;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.inertia--mini .inertia__track {
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
.inertia--mini .inertia__thumb {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.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;
|
||||
right: auto;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.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__label--mini {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Diagram */
|
||||
.inertia__diagram {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.inertia__svg {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.inertia__axis {
|
||||
stroke: color-mix(in srgb, var(--mood-text) 25%, transparent);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.inertia__grid {
|
||||
stroke: color-mix(in srgb, var(--mood-text) 8%, transparent);
|
||||
stroke-width: 0.5;
|
||||
stroke-dasharray: 2 4;
|
||||
}
|
||||
|
||||
.inertia__majority-line {
|
||||
stroke: var(--mood-accent);
|
||||
stroke-width: 0.75;
|
||||
stroke-dasharray: 4 3;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.inertia__axis-label {
|
||||
font-size: 5px;
|
||||
fill: var(--mood-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.inertia__axis-title {
|
||||
font-size: 5px;
|
||||
fill: var(--mood-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inertia__formula {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inertia__formula-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.inertia__formula-code {
|
||||
font-size: 0.6875rem;
|
||||
font-family: monospace;
|
||||
color: var(--mood-text);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 6%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.inertia__formula-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.5625rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.inertia__formula-legend strong {
|
||||
color: var(--mood-text);
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
@@ -1,89 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { DocumentItem } from '~/stores/documents'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
item: DocumentItem
|
||||
documentSlug: string
|
||||
showActions?: boolean
|
||||
}>(), {
|
||||
showActions: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
propose: [item: DocumentItem]
|
||||
}>()
|
||||
|
||||
const itemTypeLabel = (itemType: string): string => {
|
||||
switch (itemType) {
|
||||
case 'clause': return 'Clause'
|
||||
case 'rule': return 'Regle'
|
||||
case 'verification': return 'Verification'
|
||||
case 'preamble': return 'Preambule'
|
||||
case 'section': return 'Section'
|
||||
default: return itemType
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToItem() {
|
||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
@click="navigateToItem"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<!-- Item header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UBadge variant="solid" color="primary" size="xs">
|
||||
{{ item.position }}
|
||||
</UBadge>
|
||||
<span v-if="item.title" class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<UBadge variant="subtle" color="neutral" size="xs">
|
||||
{{ itemTypeLabel(item.item_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge
|
||||
v-if="item.voting_protocol_id"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Sous vote
|
||||
</UBadge>
|
||||
<UBadge
|
||||
v-else
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
Pas de vote
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item text -->
|
||||
<div class="pl-2">
|
||||
<MarkdownRenderer :content="item.current_text" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="showActions" class="flex justify-end pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<UButton
|
||||
label="Proposer une modification"
|
||||
icon="i-lucide-pen-line"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click.stop="emit('propose', item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -1,95 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ItemVersion } from '~/stores/documents'
|
||||
|
||||
const props = defineProps<{
|
||||
version: ItemVersion
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
accept: [versionId: string]
|
||||
reject: [versionId: string]
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function truncateAddress(address: string | null): string {
|
||||
if (!address) return 'Inconnu'
|
||||
if (address.length <= 16) return address
|
||||
return address.slice(0, 8) + '...' + address.slice(-6)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<div class="space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<StatusBadge :status="version.status" type="version" />
|
||||
<span class="text-sm text-gray-500">
|
||||
Propose par
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs">
|
||||
{{ truncateAddress(version.proposed_by) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-400">
|
||||
{{ formatDate(version.created_at) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Rationale -->
|
||||
<div v-if="version.rationale" class="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">Justification</p>
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ version.rationale }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Diff view -->
|
||||
<div v-if="version.diff">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
|
||||
<DiffView :diff="version.diff" />
|
||||
</div>
|
||||
|
||||
<!-- Proposed text (fallback if no diff) -->
|
||||
<div v-else-if="version.proposed_text">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p>
|
||||
<div class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<MarkdownRenderer :content="version.proposed_text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions for authenticated users -->
|
||||
<div
|
||||
v-if="auth.isAuthenticated && version.status === 'proposed'"
|
||||
class="flex items-center gap-3 pt-2 border-t border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<UButton
|
||||
label="Accepter"
|
||||
icon="i-lucide-check"
|
||||
color="success"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('accept', version.id)"
|
||||
/>
|
||||
<UButton
|
||||
label="Rejeter"
|
||||
icon="i-lucide-x"
|
||||
color="error"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
@click="emit('reject', version.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -1,240 +0,0 @@
|
||||
<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 + status on same line -->
|
||||
<div class="mini-board__header">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<template v-if="isPermanent">
|
||||
<UIcon name="i-lucide-infinity" class="text-xs" style="color: var(--mood-accent)" />
|
||||
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote permanent :</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UIcon name="i-lucide-clock" class="text-xs" style="color: var(--mood-accent)" />
|
||||
<span class="text-xs font-semibold" style="color: var(--mood-text-muted)">Vote temporaire :</span>
|
||||
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
|
||||
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
|
||||
</span>
|
||||
</template>
|
||||
<UBadge
|
||||
:color="isPassing ? 'success' : 'warning'"
|
||||
:variant="isPassing ? 'solid' : 'subtle'"
|
||||
size="xs"
|
||||
>
|
||||
{{ isPassing ? 'Adopté' : 'En attente' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</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.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
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.625rem;
|
||||
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,243 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Interactive formula parameter editor.
|
||||
*
|
||||
* Provides sliders and inputs for adjusting all formula parameters,
|
||||
* emitting the updated config on each change.
|
||||
*/
|
||||
import type { FormulaConfig } from '~/stores/protocols'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: FormulaConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: FormulaConfig]
|
||||
}>()
|
||||
|
||||
/** Local reactive copy to avoid direct prop mutation. */
|
||||
const local = reactive({ ...props.modelValue })
|
||||
|
||||
/** Sync incoming prop changes. */
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
Object.assign(local, newVal)
|
||||
}, { deep: true })
|
||||
|
||||
/** Emit on any local change. */
|
||||
watch(local, () => {
|
||||
emit('update:modelValue', { ...local })
|
||||
}, { deep: true })
|
||||
|
||||
/** Track optional fields. */
|
||||
const showSmith = ref(local.smith_exponent !== null)
|
||||
const showTechcomm = ref(local.techcomm_exponent !== null)
|
||||
const showNuancedMin = ref(local.nuanced_min_participants !== null)
|
||||
const showNuancedThreshold = ref(local.nuanced_threshold_pct !== null)
|
||||
|
||||
watch(showSmith, (v) => {
|
||||
local.smith_exponent = v ? 0.5 : null
|
||||
})
|
||||
watch(showTechcomm, (v) => {
|
||||
local.techcomm_exponent = v ? 0.5 : null
|
||||
})
|
||||
watch(showNuancedMin, (v) => {
|
||||
local.nuanced_min_participants = v ? 10 : null
|
||||
})
|
||||
watch(showNuancedThreshold, (v) => {
|
||||
local.nuanced_threshold_pct = v ? 66 : null
|
||||
})
|
||||
|
||||
interface ParamDef {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
type: 'input' | 'slider'
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
}
|
||||
|
||||
const mainParams: ParamDef[] = [
|
||||
{
|
||||
key: 'duration_days',
|
||||
label: 'Duree (jours)',
|
||||
description: 'Duree du vote en jours',
|
||||
type: 'input',
|
||||
min: 1,
|
||||
max: 365,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: 'majority_pct',
|
||||
label: 'Majorite (%)',
|
||||
description: 'Ratio de majorite cible a haute participation',
|
||||
type: 'slider',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: 'base_exponent',
|
||||
label: 'Exposant de base (B)',
|
||||
description: 'B^W tend vers 0 si B < 1 ; plancher dynamique',
|
||||
type: 'slider',
|
||||
min: 0.01,
|
||||
max: 1.0,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: 'gradient_exponent',
|
||||
label: 'Gradient d\'inertie (G)',
|
||||
description: 'Controle la vitesse de transition vers la majorite simple',
|
||||
type: 'slider',
|
||||
min: 0.01,
|
||||
max: 2.0,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: 'constant_base',
|
||||
label: 'Constante de base (C)',
|
||||
description: 'Plancher fixe de votes requis',
|
||||
type: 'input',
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Main parameters -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div v-for="param in mainParams" :key="param.key" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ param.label }}
|
||||
</label>
|
||||
<span class="text-sm font-mono font-bold text-primary">
|
||||
{{ (local as any)[param.key] }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="param.type === 'slider'">
|
||||
<URange
|
||||
v-model="(local as any)[param.key]"
|
||||
:min="param.min"
|
||||
:max="param.max"
|
||||
:step="param.step"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UInput
|
||||
v-model.number="(local as any)[param.key]"
|
||||
type="number"
|
||||
:min="param.min"
|
||||
:max="param.max"
|
||||
:step="param.step"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<p class="text-xs text-gray-500">{{ param.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional parameters -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">
|
||||
Parametres optionnels
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Smith exponent -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox v-model="showSmith" />
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Critere Smith (S)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="showSmith && local.smith_exponent !== null">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">Exposant Smith</span>
|
||||
<span class="text-sm font-mono font-bold text-primary">{{ local.smith_exponent }}</span>
|
||||
</div>
|
||||
<URange
|
||||
v-model="local.smith_exponent"
|
||||
:min="0.01"
|
||||
:max="1.0"
|
||||
:step="0.01"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">ceil(SmithWotSize^S) votes Smith requis</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- TechComm exponent -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox v-model="showTechcomm" />
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Critere TechComm (T)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="showTechcomm && local.techcomm_exponent !== null">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">Exposant TechComm</span>
|
||||
<span class="text-sm font-mono font-bold text-primary">{{ local.techcomm_exponent }}</span>
|
||||
</div>
|
||||
<URange
|
||||
v-model="local.techcomm_exponent"
|
||||
:min="0.01"
|
||||
:max="1.0"
|
||||
:step="0.01"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">ceil(CoTecSize^T) votes TechComm requis</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Nuanced min participants -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox v-model="showNuancedMin" />
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Participants minimum (nuance)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="showNuancedMin && local.nuanced_min_participants !== null">
|
||||
<UInput
|
||||
v-model.number="local.nuanced_min_participants"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="1000"
|
||||
:step="1"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Nombre minimum de participants pour un vote nuance</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Nuanced threshold pct -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UCheckbox v-model="showNuancedThreshold" />
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Seuil nuance (%)
|
||||
</label>
|
||||
</div>
|
||||
<template v-if="showNuancedThreshold && local.nuanced_threshold_pct !== null">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500">Pourcentage du seuil</span>
|
||||
<span class="text-sm font-mono font-bold text-primary">{{ local.nuanced_threshold_pct }}%</span>
|
||||
</div>
|
||||
<URange
|
||||
v-model="local.nuanced_threshold_pct"
|
||||
:min="50"
|
||||
:max="100"
|
||||
:step="1"
|
||||
/>
|
||||
<p class="text-xs text-gray-500">Seuil de score moyen pour adoption en vote nuance</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,61 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Display decoded mode params string as labeled badges/chips.
|
||||
*
|
||||
* Parses the compact mode params string and renders each parameter
|
||||
* as a human-readable chip.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
modeParams: string
|
||||
}>()
|
||||
|
||||
interface ParamChip {
|
||||
code: string
|
||||
label: string
|
||||
value: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const chips = computed((): ParamChip[] => {
|
||||
if (!props.modeParams) return []
|
||||
|
||||
try {
|
||||
const parsed = parseModeParams(props.modeParams)
|
||||
const result: ParamChip[] = []
|
||||
|
||||
result.push({ code: 'D', label: 'Duree', value: `${parsed.duration_days}j`, color: 'primary' })
|
||||
result.push({ code: 'M', label: 'Majorite', value: `${parsed.majority_pct}%`, color: 'info' })
|
||||
result.push({ code: 'B', label: 'Base', value: String(parsed.base_exponent), color: 'neutral' })
|
||||
result.push({ code: 'G', label: 'Gradient', value: String(parsed.gradient_exponent), color: 'neutral' })
|
||||
|
||||
if (parsed.constant_base > 0) {
|
||||
result.push({ code: 'C', label: 'Constante', value: String(parsed.constant_base), color: 'warning' })
|
||||
}
|
||||
if (parsed.smith_exponent !== null) {
|
||||
result.push({ code: 'S', label: 'Smith', value: String(parsed.smith_exponent), color: 'success' })
|
||||
}
|
||||
if (parsed.techcomm_exponent !== null) {
|
||||
result.push({ code: 'T', label: 'TechComm', value: String(parsed.techcomm_exponent), color: 'purple' })
|
||||
}
|
||||
|
||||
return result
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono text-xs font-bold text-primary mr-1">{{ modeParams }}</span>
|
||||
<UBadge
|
||||
v-for="chip in chips"
|
||||
:key="chip.code"
|
||||
:color="(chip.color as any)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ chip.label }}: {{ chip.value }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Dropdown/select to pick a voting protocol.
|
||||
*
|
||||
* Fetches protocols from the store and renders them in a USelect
|
||||
* with protocol name, mode params, and vote type badge.
|
||||
*/
|
||||
import type { VotingProtocol } from '~/stores/protocols'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null
|
||||
voteType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string | null]
|
||||
}>()
|
||||
|
||||
const protocols = useProtocolsStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (protocols.protocols.length === 0) {
|
||||
await protocols.fetchProtocols(props.voteType ? { vote_type: props.voteType } : undefined)
|
||||
}
|
||||
})
|
||||
|
||||
const filteredProtocols = computed(() => {
|
||||
if (!props.voteType) return protocols.protocols
|
||||
return protocols.protocols.filter(p => p.vote_type === props.voteType)
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
return filteredProtocols.value.map(p => ({
|
||||
label: buildLabel(p),
|
||||
value: p.id,
|
||||
}))
|
||||
})
|
||||
|
||||
function buildLabel(p: VotingProtocol): string {
|
||||
const typeLabel = p.vote_type === 'binary' ? 'Binaire' : 'Nuance'
|
||||
const params = p.mode_params ? ` [${p.mode_params}]` : ''
|
||||
return `${p.name} - ${typeLabel}${params}`
|
||||
}
|
||||
|
||||
function onSelect(value: string) {
|
||||
emit('update:modelValue', value || null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<USelect
|
||||
:model-value="modelValue ?? undefined"
|
||||
:items="options"
|
||||
placeholder="Selectionnez un protocole..."
|
||||
:loading="protocols.loading"
|
||||
value-key="value"
|
||||
@update:model-value="onSelect"
|
||||
/>
|
||||
<p v-if="protocols.error" class="text-xs text-red-500 mt-1">
|
||||
{{ protocols.error }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
txHash: string | null
|
||||
block: number | null
|
||||
}>()
|
||||
|
||||
const truncatedHash = computed(() => {
|
||||
if (!props.txHash) return null
|
||||
if (props.txHash.length <= 20) return props.txHash
|
||||
return props.txHash.slice(0, 10) + '...' + props.txHash.slice(-6)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="txHash">
|
||||
<div class="inline-flex items-center gap-2">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-link" class="text-sm text-gray-500" />
|
||||
<span class="font-mono text-xs text-gray-700 dark:text-gray-300">
|
||||
{{ truncatedHash }}
|
||||
</span>
|
||||
</div>
|
||||
<UBadge v-if="block" color="neutral" variant="subtle" size="xs">
|
||||
Bloc #{{ block.toLocaleString('fr-FR') }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UBadge color="warning" variant="subtle" size="xs">
|
||||
Non ancre
|
||||
</UBadge>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
cid: string | null
|
||||
}>()
|
||||
|
||||
const IPFS_GATEWAY = 'https://ipfs.io/ipfs/'
|
||||
|
||||
const truncatedCid = computed(() => {
|
||||
if (!props.cid) return null
|
||||
if (props.cid.length <= 20) return props.cid
|
||||
return props.cid.slice(0, 12) + '...' + props.cid.slice(-6)
|
||||
})
|
||||
|
||||
const gatewayUrl = computed(() => {
|
||||
if (!props.cid) return null
|
||||
return `${IPFS_GATEWAY}${props.cid}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="cid">
|
||||
<a
|
||||
:href="gatewayUrl!"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 font-mono text-xs text-primary hover:underline"
|
||||
>
|
||||
<UIcon name="i-lucide-hard-drive" class="text-sm" />
|
||||
<span>{{ truncatedCid }}</span>
|
||||
<UIcon name="i-lucide-external-link" class="text-sm" />
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<UBadge color="neutral" variant="subtle" size="xs">
|
||||
Non disponible
|
||||
</UBadge>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,171 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
export interface SanctuaryEntryOut {
|
||||
id: string
|
||||
entry_type: string
|
||||
reference_id: string
|
||||
title: string | null
|
||||
content_hash: string
|
||||
ipfs_cid: string | null
|
||||
chain_tx_hash: string | null
|
||||
chain_block: number | null
|
||||
metadata_json: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entry: SanctuaryEntryOut
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
verify: [id: string]
|
||||
}>()
|
||||
|
||||
const typeLabel = (entryType: string): string => {
|
||||
switch (entryType) {
|
||||
case 'document': return 'Document'
|
||||
case 'decision': return 'Decision'
|
||||
case 'vote_result': return 'Resultat de vote'
|
||||
default: return entryType
|
||||
}
|
||||
}
|
||||
|
||||
const typeColor = (entryType: string): string => {
|
||||
switch (entryType) {
|
||||
case 'document': return 'primary'
|
||||
case 'decision': return 'success'
|
||||
case 'vote_result': return 'info'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function truncateHash(hash: string | null, length: number = 16): string {
|
||||
if (!hash) return '-'
|
||||
if (hash.length <= length * 2) return hash
|
||||
return hash.slice(0, length) + '...' + hash.slice(-8)
|
||||
}
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyHash() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.entry.content_hash)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
// Clipboard API not available
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard
|
||||
class="cursor-pointer hover:ring-2 hover:ring-primary/50 transition-all"
|
||||
@click="navigateTo(`/sanctuary/${entry.id}`)"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Entry header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-shield-check" class="text-xl text-primary" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ entry.title || 'Entree sans titre' }}
|
||||
</h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
{{ formatDate(entry.created_at) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UBadge :color="(typeColor(entry.entry_type) as any)" variant="subtle" size="xs">
|
||||
{{ typeLabel(entry.entry_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Hashes and anchors -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Content hash -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-hash" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">SHA-256</span>
|
||||
</div>
|
||||
<UButton
|
||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-copy'"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
class="p-0"
|
||||
@click.stop="copyHash"
|
||||
/>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-gray-700 dark:text-gray-300 break-all">
|
||||
{{ truncateHash(entry.content_hash) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- IPFS CID -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<UIcon name="i-lucide-hard-drive" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
|
||||
</div>
|
||||
<div @click.stop>
|
||||
<IPFSLink :cid="entry.ipfs_cid" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chain anchor -->
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
|
||||
</div>
|
||||
<ChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification status indicators -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon
|
||||
:name="entry.ipfs_cid ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
||||
:class="entry.ipfs_cid ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span :class="entry.ipfs_cid ? 'text-green-600' : 'text-gray-400'">
|
||||
IPFS {{ entry.ipfs_cid ? 'epingle' : 'en attente' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<UIcon
|
||||
:name="entry.chain_tx_hash ? 'i-lucide-check-circle' : 'i-lucide-clock'"
|
||||
:class="entry.chain_tx_hash ? 'text-green-500' : 'text-gray-400'"
|
||||
/>
|
||||
<span :class="entry.chain_tx_hash ? 'text-green-600' : 'text-gray-400'">
|
||||
Chain {{ entry.chain_tx_hash ? 'ancre' : 'en attente' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
label="Verifier"
|
||||
icon="i-lucide-shield-check"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click.stop="emit('verify', entry.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
@@ -1,659 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ContextMapper — Recommandeur de méthode de décision.
|
||||
* 4 questions de contexte → méthode optimale + justification.
|
||||
* Basé sur : Smith (WoT G1), Laloux (advice process), sociocracie.
|
||||
*/
|
||||
|
||||
interface Option { value: string; label: string; icon: string }
|
||||
interface Question { id: string; question: string; hint?: string; options: Option[] }
|
||||
|
||||
interface MethodRec {
|
||||
name: string
|
||||
icon: string
|
||||
tag: string
|
||||
tagColor: string
|
||||
description: string
|
||||
formula?: string
|
||||
when: string
|
||||
pros: string[]
|
||||
cons: string[]
|
||||
}
|
||||
|
||||
const questions: Question[] = [
|
||||
{
|
||||
id: 'urgency',
|
||||
question: 'Quelle est l\'urgence ?',
|
||||
hint: 'Le délai disponible avant que la décision soit nécessaire',
|
||||
options: [
|
||||
{ value: 'immediate', label: 'Immédiate', icon: 'i-lucide-zap' },
|
||||
{ value: 'short', label: '< 48h', icon: 'i-lucide-clock' },
|
||||
{ value: 'normal', label: 'Planifiable', icon: 'i-lucide-calendar' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stakes',
|
||||
question: 'Quel est l\'enjeu ?',
|
||||
hint: 'L\'impact et la réversibilité de la décision',
|
||||
options: [
|
||||
{ value: 'irreversible', label: 'Irréversible', icon: 'i-lucide-lock' },
|
||||
{ value: 'major', label: 'Majeur', icon: 'i-lucide-alert-triangle' },
|
||||
{ value: 'moderate', label: 'Modéré', icon: 'i-lucide-minus-circle' },
|
||||
{ value: 'minor', label: 'Mineur', icon: 'i-lucide-info' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'groupSize',
|
||||
question: 'Taille du groupe ?',
|
||||
hint: 'Nombre de personnes concernées ou habilitées à voter',
|
||||
options: [
|
||||
{ value: 'small', label: '< 10', icon: 'i-lucide-user' },
|
||||
{ value: 'medium', label: '10 – 100', icon: 'i-lucide-users' },
|
||||
{ value: 'large', label: '100+', icon: 'i-lucide-globe' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nature',
|
||||
question: 'Nature de la décision ?',
|
||||
hint: 'Le type de compétence principalement sollicité',
|
||||
options: [
|
||||
{ value: 'technical', label: 'Technique', icon: 'i-lucide-cpu' },
|
||||
{ value: 'political', label: 'Politique', icon: 'i-lucide-landmark' },
|
||||
{ value: 'operational', label: 'Opérationnelle', icon: 'i-lucide-settings' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const answers = ref<Record<string, string>>({})
|
||||
const step = ref(0)
|
||||
const animating = ref(false)
|
||||
|
||||
const currentQuestion = computed(() => questions[step.value])
|
||||
const isComplete = computed(() => Object.keys(answers.value).length === questions.length)
|
||||
const progress = computed(() => (step.value / questions.length) * 100)
|
||||
|
||||
function selectAnswer(questionId: string, value: string) {
|
||||
answers.value = { ...answers.value, [questionId]: value }
|
||||
if (step.value < questions.length - 1) {
|
||||
animating.value = true
|
||||
setTimeout(() => {
|
||||
step.value++
|
||||
animating.value = false
|
||||
}, 160)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (step.value > 0) step.value--
|
||||
}
|
||||
|
||||
function reset() {
|
||||
answers.value = {}
|
||||
step.value = 0
|
||||
}
|
||||
|
||||
const recommendation = computed((): MethodRec | null => {
|
||||
if (!isComplete.value) return null
|
||||
const { urgency, stakes, groupSize, nature } = answers.value
|
||||
|
||||
// Immediate → Advice process (Laloux)
|
||||
if (urgency === 'immediate') {
|
||||
return {
|
||||
name: 'Processus de sollicitation d\'avis',
|
||||
icon: 'i-lucide-message-circle',
|
||||
tag: 'Laloux / Teal',
|
||||
tagColor: 'teal',
|
||||
description: 'Le décideur identifié consulte les personnes expertes et impactées, puis décide seul et en rend compte. Rapide, non-bloquant, responsabilisant.',
|
||||
formula: 'Pas de vote — consultation libre → décision documentée → compte-rendu',
|
||||
when: 'Urgence opérationnelle, décision réversible, responsable clairement identifié.',
|
||||
pros: ['Rapide (< 2h)', 'Non-bloquant', 'Responsabilise le décideur'],
|
||||
cons: ['Requiert confiance dans le décideur', 'Pas de validation collective'],
|
||||
}
|
||||
}
|
||||
|
||||
// Technical + medium/large → Smith WoT
|
||||
if (nature === 'technical' && groupSize !== 'small') {
|
||||
return {
|
||||
name: 'Vote inertiel WoT + critère Smith',
|
||||
icon: 'i-lucide-network',
|
||||
tag: 'G1 standard',
|
||||
tagColor: 'accent',
|
||||
description: 'Vote communautaire avec seuil adaptatif à la participation. Le critère Smith garantit que la décision reflète l\'expertise des validateurs.',
|
||||
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)\nSeuil Smith : ⌈SmithWoT^S⌉',
|
||||
when: 'Décision technique nécessitant validation par les experts WoT (forgerons, CoTec).',
|
||||
pros: ['Validé par expertise', 'Adaptatif à la participation', 'Tracé on-chain'],
|
||||
cons: ['Durée minimum 7-30j', 'Complexité de la formule'],
|
||||
}
|
||||
}
|
||||
|
||||
// Irreversible + large → High threshold WoT
|
||||
if (stakes === 'irreversible' && groupSize === 'large') {
|
||||
return {
|
||||
name: 'Vote inertiel WoT (inertie forte)',
|
||||
icon: 'i-lucide-shield',
|
||||
tag: 'G1 renforcé',
|
||||
tagColor: 'secondary',
|
||||
description: 'Pour les décisions irréversibles à fort impact : seuil de quasi-unanimité si faible participation, majorité qualifiée avec forte participation.',
|
||||
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)\nParamètres : M=67%, G=0.3 (inertie forte)',
|
||||
when: 'Textes fondateurs, modifications structurelles, décisions irréversibles pour 100+ membres.',
|
||||
pros: ['Protection maximale', 'Légitimité forte', 'Résistant aux minorités actives'],
|
||||
cons: ['Durée longue (30+ jours)', 'Peut bloquer les évolutions nécessaires'],
|
||||
}
|
||||
}
|
||||
|
||||
// Small group → Sociocratic consent
|
||||
if (groupSize === 'small') {
|
||||
return {
|
||||
name: 'Consentement sociocratique',
|
||||
icon: 'i-lucide-check-circle-2',
|
||||
tag: 'Sociocracie',
|
||||
tagColor: 'tertiary',
|
||||
description: 'Adoption si aucune objection grave n\'est soulevée. Une objection grave = la décision nuit à la mission commune, pas juste une préférence personnelle.',
|
||||
formula: 'Adoptée si : aucune objection grave parmi les membres du cercle',
|
||||
when: 'Cercle de travail (< 10 membres), enjeu modéré, décision réversible.',
|
||||
pros: ['Rapide', 'Inclusif', 'Distingue objection grave et préférence'],
|
||||
cons: ['Ne convient pas aux grands groupes', 'Risque de pression sociale'],
|
||||
}
|
||||
}
|
||||
|
||||
// Political + medium → WoT majority
|
||||
if (nature === 'political') {
|
||||
return {
|
||||
name: 'Vote majoritaire WoT',
|
||||
icon: 'i-lucide-vote',
|
||||
tag: 'G1 standard',
|
||||
tagColor: 'accent',
|
||||
description: 'Vote binaire (Pour/Contre) avec seuil adaptatif à la participation WoT. Standard pour les décisions politiques de la communauté.',
|
||||
formula: 'R = C + B^W + (M + (1−M)·(1−(T/W)^G))·max(0,T−C)',
|
||||
when: 'Décision politique communautaire, participation variable, groupe >10.',
|
||||
pros: ['Standard WoT', 'Adaptatif', 'Tracé on-chain'],
|
||||
cons: ['Durée 7-30j', 'Participation faible possible'],
|
||||
}
|
||||
}
|
||||
|
||||
// Default: minor/operational
|
||||
return {
|
||||
name: 'Advice process + validation légère',
|
||||
icon: 'i-lucide-thumbs-up',
|
||||
tag: 'Léger',
|
||||
tagColor: 'teal',
|
||||
description: 'Pour les décisions mineures ou opérationnelles : consultation des parties concernées, décision par le responsable désigné, notification de la communauté.',
|
||||
formula: 'Consultation → Décision → Notification (sans vote formel)',
|
||||
when: 'Décision opérationnelle de faible impact, facilement réversible.',
|
||||
pros: ['Très rapide', 'Non-bloquant', 'Adapté à l\'opérationnel'],
|
||||
cons: ['Légitimité limitée', 'Ne convient pas aux enjeux majeurs'],
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ use: [name: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cmap">
|
||||
<!-- Header -->
|
||||
<div class="cmap__head">
|
||||
<UIcon name="i-lucide-compass" class="cmap__head-icon" />
|
||||
<div>
|
||||
<h3 class="cmap__title">Choisir une méthode</h3>
|
||||
<p class="cmap__subtitle">4 questions pour la méthode adaptée</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<Transition name="fade-up" mode="out-in">
|
||||
<div v-if="isComplete" key="result" class="cmap__result">
|
||||
<div class="cmap__result-header">
|
||||
<div class="cmap__result-icon">
|
||||
<UIcon :name="recommendation!.icon" />
|
||||
</div>
|
||||
<div class="cmap__result-info">
|
||||
<span class="cmap__result-tag" :class="`cmap__result-tag--${recommendation!.tagColor}`">
|
||||
{{ recommendation!.tag }}
|
||||
</span>
|
||||
<h4 class="cmap__result-name">{{ recommendation!.name }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="cmap__result-desc">{{ recommendation!.description }}</p>
|
||||
|
||||
<div v-if="recommendation!.formula" class="cmap__formula">
|
||||
<span class="cmap__formula-label">Formule</span>
|
||||
<pre class="cmap__formula-code">{{ recommendation!.formula }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="cmap__pros-cons">
|
||||
<div>
|
||||
<span class="cmap__pros-label">Pour</span>
|
||||
<ul class="cmap__list cmap__list--pro">
|
||||
<li v-for="p in recommendation!.pros" :key="p">{{ p }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span class="cmap__cons-label">Contre</span>
|
||||
<ul class="cmap__list cmap__list--con">
|
||||
<li v-for="c in recommendation!.cons" :key="c">{{ c }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="cmap__when">
|
||||
<UIcon name="i-lucide-lightbulb" />
|
||||
{{ recommendation!.when }}
|
||||
</p>
|
||||
|
||||
<div class="cmap__result-actions">
|
||||
<button class="cmap__btn-reset" @click="reset">
|
||||
<UIcon name="i-lucide-refresh-cw" />
|
||||
Recommencer
|
||||
</button>
|
||||
<button class="cmap__btn-use" @click="emit('use', recommendation!.name)">
|
||||
<UIcon name="i-lucide-play" />
|
||||
Utiliser cette méthode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quiz -->
|
||||
<div v-else key="quiz" class="cmap__quiz">
|
||||
<!-- Progress -->
|
||||
<div class="cmap__progress">
|
||||
<div class="cmap__progress-bar" :style="{ width: `${progress}%` }" />
|
||||
</div>
|
||||
<span class="cmap__step-label">{{ step + 1 }} / {{ questions.length }}</span>
|
||||
|
||||
<!-- Question -->
|
||||
<Transition name="slide-right" mode="out-in">
|
||||
<div :key="step" class="cmap__question-block">
|
||||
<p class="cmap__question">{{ currentQuestion.question }}</p>
|
||||
<p v-if="currentQuestion.hint" class="cmap__hint">{{ currentQuestion.hint }}</p>
|
||||
|
||||
<div class="cmap__options">
|
||||
<button
|
||||
v-for="opt in currentQuestion.options"
|
||||
:key="opt.value"
|
||||
class="cmap__option"
|
||||
:class="{ 'cmap__option--selected': answers[currentQuestion.id] === opt.value }"
|
||||
@click="selectAnswer(currentQuestion.id, opt.value)"
|
||||
>
|
||||
<div class="cmap__option-icon">
|
||||
<UIcon :name="opt.icon" />
|
||||
</div>
|
||||
<span class="cmap__option-label">{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<button v-if="step > 0" class="cmap__back" @click="goBack">
|
||||
<UIcon name="i-lucide-chevron-left" />
|
||||
Retour
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.cmap__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cmap__head-icon {
|
||||
font-size: 1.375rem;
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.cmap__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cmap__subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Progress */
|
||||
.cmap__progress {
|
||||
height: 4px;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cmap__progress-bar {
|
||||
height: 100%;
|
||||
background: var(--mood-accent);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.cmap__step-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
/* Question */
|
||||
.cmap__question-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cmap__question {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cmap__hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cmap__options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cmap__option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease, background 0.1s ease;
|
||||
text-align: left;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
.cmap__option:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.cmap__option:active { transform: translateY(0); }
|
||||
|
||||
.cmap__option--selected {
|
||||
background: var(--mood-accent);
|
||||
}
|
||||
|
||||
.cmap__option--selected .cmap__option-icon,
|
||||
.cmap__option--selected .cmap__option-label {
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
.cmap__option-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.cmap__option--selected .cmap__option-icon {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
.cmap__option-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.cmap__back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0;
|
||||
transition: color 0.1s ease;
|
||||
}
|
||||
.cmap__back:hover { color: var(--mood-text); }
|
||||
|
||||
/* Result */
|
||||
.cmap__result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
|
||||
.cmap__result-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cmap__result-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.cmap__result-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.cmap__result-tag {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
width: fit-content;
|
||||
}
|
||||
.cmap__result-tag--accent {
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
.cmap__result-tag--teal {
|
||||
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
||||
color: var(--mood-success);
|
||||
}
|
||||
.cmap__result-tag--secondary {
|
||||
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 15%, transparent);
|
||||
color: var(--mood-secondary, var(--mood-accent));
|
||||
}
|
||||
.cmap__result-tag--tertiary {
|
||||
background: color-mix(in srgb, var(--mood-tertiary, var(--mood-accent)) 15%, transparent);
|
||||
color: var(--mood-tertiary, var(--mood-accent));
|
||||
}
|
||||
|
||||
.cmap__result-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cmap__result-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cmap__formula {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
padding: 0.625rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.cmap__formula-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.cmap__formula-code {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.cmap__pros-cons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cmap__pros-label,
|
||||
.cmap__cons-label {
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.cmap__pros-label { color: var(--mood-success); }
|
||||
.cmap__cons-label { color: var(--mood-error); }
|
||||
|
||||
.cmap__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.cmap__list li {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
padding-left: 0.875rem;
|
||||
position: relative;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cmap__list--pro li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--mood-success);
|
||||
font-weight: 700;
|
||||
font-size: 0.5rem;
|
||||
top: 0.2em;
|
||||
}
|
||||
|
||||
.cmap__list--con li::before {
|
||||
content: '·';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--mood-error);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cmap__when {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cmap__result-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cmap__btn-reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
.cmap__btn-reset:hover { transform: translateY(-1px); color: var(--mood-text); }
|
||||
|
||||
.cmap__btn-use {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1.125rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent-text);
|
||||
background: var(--mood-accent);
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
||||
}
|
||||
.cmap__btn-use:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
.fade-up-enter-active, .fade-up-leave-active { transition: all 0.2s ease; }
|
||||
.fade-up-enter-from { opacity: 0; transform: translateY(8px); }
|
||||
.fade-up-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
|
||||
.slide-right-enter-active, .slide-right-leave-active { transition: all 0.16s ease; }
|
||||
.slide-right-enter-from { opacity: 0; transform: translateX(12px); }
|
||||
.slide-right-leave-to { opacity: 0; transform: translateX(-8px); }
|
||||
</style>
|
||||
@@ -1,666 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SocioElection — Guide processus d'élection sociocratique.
|
||||
* 6 étapes canoniques + advice process Laloux + clarté de rôle.
|
||||
* Référence : "La Sociocracie" (Robertson), "Reinventing Organizations" (Laloux).
|
||||
*/
|
||||
|
||||
interface Step {
|
||||
num: number
|
||||
title: string
|
||||
actor: string
|
||||
duration: string
|
||||
icon: string
|
||||
description: string
|
||||
tips: string[]
|
||||
pitfall?: string
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
num: 1,
|
||||
title: 'Clarifier le rôle',
|
||||
actor: 'Facilitateur + cercle',
|
||||
duration: '10-15 min',
|
||||
icon: 'i-lucide-clipboard-list',
|
||||
description: 'Définir ensemble la mission du rôle, ses domaines d\'autorité, ses redevabilités et la durée du mandat. Le rôle précède la personne.',
|
||||
tips: [
|
||||
'Distinguer redevabilités (obligations) et autorité (domaine de décision)',
|
||||
'Fixer une durée standard (ex: 1 an renouvelable)',
|
||||
'Identifier les compétences nécessaires — pas souhaitables',
|
||||
],
|
||||
pitfall: 'Ne pas définir le rôle sur mesure pour un candidat déjà imaginé.',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
title: 'Nommer en silence',
|
||||
actor: 'Tous les membres',
|
||||
duration: '3-5 min',
|
||||
icon: 'i-lucide-pencil',
|
||||
description: 'Chacun écrit sur papier le nom d\'une personne (y compris soi-même) et la raison principale de son choix. En silence, sans influence mutuelle.',
|
||||
tips: [
|
||||
'Pas de discussion pendant cette étape',
|
||||
'S\'auto-nommer est bienvenu et valorisé',
|
||||
'Une seule nomination par personne',
|
||||
],
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
title: 'Recueillir les nominations',
|
||||
actor: 'Facilitateur',
|
||||
duration: '5-10 min',
|
||||
icon: 'i-lucide-list-checks',
|
||||
description: 'Le facilitateur lit chaque nomination à voix haute avec la raison. Pas de commentaire, pas de débat. Pure collecte.',
|
||||
tips: [
|
||||
'Lire nom + raison tels qu\'écrits',
|
||||
'Le facilitateur lit aussi sa propre nomination',
|
||||
'Compter et afficher les nominations',
|
||||
],
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
title: 'Argumenter',
|
||||
actor: 'Chaque membre',
|
||||
duration: '1-2 min / personne',
|
||||
icon: 'i-lucide-message-square',
|
||||
description: 'Chaque membre peut changer sa nomination et expliquer pourquoi (brièvement). Tour de table structuré, pas de croisements.',
|
||||
tips: [
|
||||
'1 minute maximum par personne',
|
||||
'Argumenter pour, pas contre',
|
||||
'Les candidats s\'expriment aussi brièvement',
|
||||
],
|
||||
pitfall: 'Éviter les longues plaidoiries — la clarté du rôle doit guider.',
|
||||
},
|
||||
{
|
||||
num: 5,
|
||||
title: 'Lever les objections',
|
||||
actor: 'Facilitateur + cercle',
|
||||
duration: '5-15 min',
|
||||
icon: 'i-lucide-shield-check',
|
||||
description: 'Le facilitateur propose l\'élection de la personne la plus nommée. Silence = consentement. Une objection grave peut être soulevée et traitée.',
|
||||
tips: [
|
||||
'Objection grave ≠ préférence — nuit-elle à la mission du cercle ?',
|
||||
'Une objection peut mener à reconsidérer une candidature',
|
||||
'L\'élu·e peut décliner — c\'est légitime',
|
||||
],
|
||||
pitfall: 'Une objection n\'est pas un veto — elle doit être travaillée collectivement.',
|
||||
},
|
||||
{
|
||||
num: 6,
|
||||
title: 'Célébrer',
|
||||
actor: 'Tous',
|
||||
duration: '2-3 min',
|
||||
icon: 'i-lucide-star',
|
||||
description: 'L\'élection est proclamée. L\'élu·e remercie et s\'engage publiquement. La communauté accueille le nouveau rôle.',
|
||||
tips: [
|
||||
'Documenter l\'élection (date, durée, personnes présentes)',
|
||||
'Annoncer à la communauté au sens large',
|
||||
'Fixer la prochaine évaluation du rôle',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const expandedStep = ref<number | null>(null)
|
||||
|
||||
function toggleStep(num: number) {
|
||||
expandedStep.value = expandedStep.value === num ? null : num
|
||||
}
|
||||
|
||||
// Advice process (Laloux)
|
||||
const adviceSteps = [
|
||||
{ icon: 'i-lucide-search', text: 'Identifier les personnes expertes ET impactées' },
|
||||
{ icon: 'i-lucide-message-circle', text: 'Les consulter — écouter vraiment' },
|
||||
{ icon: 'i-lucide-user-check', text: 'Décider seul·e, en intégrant les avis reçus' },
|
||||
{ icon: 'i-lucide-file-text', text: 'Documenter et communiquer la décision + raisons' },
|
||||
]
|
||||
|
||||
// Role clarity framework
|
||||
interface RoleAxis {
|
||||
label: string
|
||||
icon: string
|
||||
question: string
|
||||
example: string
|
||||
}
|
||||
|
||||
const roleAxes: RoleAxis[] = [
|
||||
{
|
||||
label: 'Mission',
|
||||
icon: 'i-lucide-target',
|
||||
question: 'Pourquoi ce rôle existe-t-il ?',
|
||||
example: 'Assurer la disponibilité des nœuds validateurs 24h/24',
|
||||
},
|
||||
{
|
||||
label: 'Domaine',
|
||||
icon: 'i-lucide-shield',
|
||||
question: 'Sur quoi a-t-il autorité exclusive ?',
|
||||
example: 'Configuration des serveurs de forge, rotation des clés',
|
||||
},
|
||||
{
|
||||
label: 'Redevabilités',
|
||||
icon: 'i-lucide-check-square',
|
||||
question: 'Quelles activités doit-il assurer ?',
|
||||
example: 'Publier un rapport mensuel, alerter en cas d\'incident',
|
||||
},
|
||||
{
|
||||
label: 'Durée',
|
||||
icon: 'i-lucide-calendar',
|
||||
question: 'Pour combien de temps ?',
|
||||
example: '1 an, renouvelable une fois, réévaluation à 6 mois',
|
||||
},
|
||||
]
|
||||
|
||||
const activeTab = ref<'election' | 'advice' | 'role'>('election')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="se">
|
||||
<!-- Tabs -->
|
||||
<div class="se__tabs">
|
||||
<button
|
||||
class="se__tab"
|
||||
:class="{ 'se__tab--active': activeTab === 'election' }"
|
||||
@click="activeTab = 'election'"
|
||||
>
|
||||
<UIcon name="i-lucide-users" />
|
||||
Élection
|
||||
</button>
|
||||
<button
|
||||
class="se__tab"
|
||||
:class="{ 'se__tab--active': activeTab === 'advice' }"
|
||||
@click="activeTab = 'advice'"
|
||||
>
|
||||
<UIcon name="i-lucide-message-circle" />
|
||||
Conseil
|
||||
</button>
|
||||
<button
|
||||
class="se__tab"
|
||||
:class="{ 'se__tab--active': activeTab === 'role' }"
|
||||
@click="activeTab = 'role'"
|
||||
>
|
||||
<UIcon name="i-lucide-clipboard-list" />
|
||||
Rôle
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Election sociocratique -->
|
||||
<div v-if="activeTab === 'election'" class="se__panel">
|
||||
<p class="se__intro">
|
||||
Processus en 6 étapes garantissant que l'élection repose sur la clarté du rôle
|
||||
et le consentement collectif — pas sur la popularité.
|
||||
</p>
|
||||
|
||||
<div class="se__steps">
|
||||
<div
|
||||
v-for="s in steps"
|
||||
:key="s.num"
|
||||
class="se__step"
|
||||
:class="{ 'se__step--open': expandedStep === s.num }"
|
||||
>
|
||||
<button class="se__step-head" @click="toggleStep(s.num)">
|
||||
<div class="se__step-num">{{ s.num }}</div>
|
||||
<div class="se__step-icon">
|
||||
<UIcon :name="s.icon" />
|
||||
</div>
|
||||
<div class="se__step-info">
|
||||
<span class="se__step-title">{{ s.title }}</span>
|
||||
<span class="se__step-meta">{{ s.actor }} · {{ s.duration }}</span>
|
||||
</div>
|
||||
<UIcon
|
||||
:name="expandedStep === s.num ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="se__step-toggle"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Transition name="expand">
|
||||
<div v-if="expandedStep === s.num" class="se__step-body">
|
||||
<p class="se__step-desc">{{ s.description }}</p>
|
||||
<ul class="se__step-tips">
|
||||
<li v-for="tip in s.tips" :key="tip">{{ tip }}</li>
|
||||
</ul>
|
||||
<div v-if="s.pitfall" class="se__step-pitfall">
|
||||
<UIcon name="i-lucide-alert-triangle" />
|
||||
{{ s.pitfall }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advice process -->
|
||||
<div v-if="activeTab === 'advice'" class="se__panel">
|
||||
<div class="se__advice-header">
|
||||
<span class="se__advice-tag">Laloux / Teal</span>
|
||||
<h4 class="se__advice-title">Processus de sollicitation d'avis</h4>
|
||||
<p class="se__advice-subtitle">
|
||||
Toute personne peut prendre une décision — à condition d'avoir d'abord
|
||||
consulté les experts et les impactés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="se__advice-steps">
|
||||
<div v-for="(as, i) in adviceSteps" :key="i" class="se__advice-step">
|
||||
<div class="se__advice-dot">
|
||||
<UIcon :name="as.icon" />
|
||||
</div>
|
||||
<span class="se__advice-text">{{ as.text }}</span>
|
||||
<div v-if="i < adviceSteps.length - 1" class="se__advice-line" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="se__advice-rule">
|
||||
<UIcon name="i-lucide-lightbulb" class="se__advice-rule-icon" />
|
||||
<div>
|
||||
<strong>Règle d'or :</strong> plus la décision est impactante, plus il faut
|
||||
consulter largement. Mais la décision finale appartient toujours à celui ou
|
||||
celle qui l'a initiée.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="se__advice-when">
|
||||
<div class="se__advice-when-item se__advice-when-item--yes">
|
||||
<span class="se__advice-when-label">Adapter pour</span>
|
||||
<ul>
|
||||
<li>Décisions urgentes</li>
|
||||
<li>Rôles bien définis</li>
|
||||
<li>Culture de confiance</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="se__advice-when-item se__advice-when-item--no">
|
||||
<span class="se__advice-when-label">Éviter si</span>
|
||||
<ul>
|
||||
<li>Décision irréversible</li>
|
||||
<li>Groupe > 100 personnes</li>
|
||||
<li>Enjeu fondateur</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role clarity -->
|
||||
<div v-if="activeTab === 'role'" class="se__panel">
|
||||
<p class="se__intro">
|
||||
Un rôle bien défini évite les zones grises, les conflits d'autorité
|
||||
et les mandats flous. Quatre axes suffisent.
|
||||
</p>
|
||||
|
||||
<div class="se__role-axes">
|
||||
<div v-for="axis in roleAxes" :key="axis.label" class="se__role-axis">
|
||||
<div class="se__role-axis-icon">
|
||||
<UIcon :name="axis.icon" />
|
||||
</div>
|
||||
<div class="se__role-axis-body">
|
||||
<span class="se__role-axis-label">{{ axis.label }}</span>
|
||||
<p class="se__role-axis-question">{{ axis.question }}</p>
|
||||
<p class="se__role-axis-example">ex: {{ axis.example }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="se__role-tip">
|
||||
<UIcon name="i-lucide-info" />
|
||||
<span>Un rôle n'est pas une fiche de poste. Il peut évoluer au prochain cycle
|
||||
de gouvernance sans changer la personne qui le tient.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.se { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
/* Tabs */
|
||||
.se__tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.se__tab {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text-muted);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.se__tab--active {
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-accent);
|
||||
box-shadow: 0 1px 4px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.se__panel { display: flex; flex-direction: column; gap: 0.875rem; }
|
||||
|
||||
.se__intro {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Steps */
|
||||
.se__steps { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
|
||||
.se__step {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.se__step--open { background: var(--mood-surface); }
|
||||
|
||||
.se__step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.875rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.se__step-num {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.se__step-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.se__step-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.se__step-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.se__step-meta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.se__step-toggle {
|
||||
color: var(--mood-text-muted);
|
||||
font-size: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se__step-body {
|
||||
padding: 0 0.875rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
|
||||
.se__step-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.se__step-tips {
|
||||
margin: 0;
|
||||
padding: 0 0 0 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
list-style-type: disc;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.se__step-tips li::marker { color: var(--mood-accent); }
|
||||
|
||||
.se__step-pitfall {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: color-mix(in srgb, var(--mood-error) 10%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-error);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Advice */
|
||||
.se__advice-header { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.se__advice-tag {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
background: color-mix(in srgb, var(--mood-success) 15%, transparent);
|
||||
color: var(--mood-success);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.se__advice-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.se__advice-subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.se__advice-steps { display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
.se__advice-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
position: relative;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.se__advice-dot {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.se__advice-text {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
padding-top: 0.375rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.se__advice-line {
|
||||
position: absolute;
|
||||
left: calc(1rem - 1px);
|
||||
top: calc(0.5rem + 2rem);
|
||||
width: 2px;
|
||||
height: calc(100% - 2rem + 0.5rem);
|
||||
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||
}
|
||||
|
||||
.se__advice-rule {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.se__advice-rule-icon { color: var(--mood-accent); flex-shrink: 0; margin-top: 0.1rem; }
|
||||
.se__advice-rule strong { color: var(--mood-text); }
|
||||
|
||||
.se__advice-when {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.se__advice-when-item {
|
||||
padding: 0.625rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.se__advice-when-item--yes {
|
||||
background: color-mix(in srgb, var(--mood-success) 10%, transparent);
|
||||
}
|
||||
|
||||
.se__advice-when-item--no {
|
||||
background: color-mix(in srgb, var(--mood-error) 8%, transparent);
|
||||
}
|
||||
|
||||
.se__advice-when-label {
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.se__advice-when-item--yes .se__advice-when-label { color: var(--mood-success); }
|
||||
.se__advice-when-item--no .se__advice-when-label { color: var(--mood-error); }
|
||||
|
||||
.se__advice-when-item ul {
|
||||
margin: 0;
|
||||
padding: 0 0 0 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
list-style-type: disc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
/* Role */
|
||||
.se__role-axes { display: flex; flex-direction: column; gap: 0.625rem; }
|
||||
|
||||
.se__role-axis {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
padding: 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.se__role-axis-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.se__role-axis-body { flex: 1; min-width: 0; }
|
||||
|
||||
.se__role-axis-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-accent);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.se__role-axis-question {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.se__role-axis-example {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0.125rem 0 0;
|
||||
line-height: 1.4;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.se__role-tip {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Expand transition */
|
||||
.expand-enter-active, .expand-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.expand-enter-from, .expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.expand-enter-to, .expand-leave-from {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* ToolboxSection — Wrapper accordéon pour la boîte à outils.
|
||||
* Toggle le contenu pour économiser la hauteur visible.
|
||||
*/
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
icon?: string
|
||||
defaultOpen?: boolean
|
||||
}>(),
|
||||
{
|
||||
icon: undefined,
|
||||
defaultOpen: false,
|
||||
},
|
||||
)
|
||||
|
||||
const open = ref(props.defaultOpen)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tsection" :class="{ 'tsection--open': open }">
|
||||
<button class="tsection__header" @click="open = !open">
|
||||
<UIcon v-if="icon" :name="icon" class="tsection__icon" />
|
||||
<span class="tsection__title">{{ title }}</span>
|
||||
<UIcon name="i-lucide-chevron-down" class="tsection__chevron" />
|
||||
</button>
|
||||
<div v-show="open" class="tsection__body">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tsection {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tsection__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 0.875rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.tsection__header:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.tsection__icon {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tsection__title {
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-accent);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.tsection__chevron {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-accent);
|
||||
opacity: 0.6;
|
||||
transition: transform 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tsection--open .tsection__chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.tsection__body {
|
||||
padding: 0 0.875rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,551 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* WorkflowMilestones — 11 jalons de protocole de fonctionnement.
|
||||
* Sélectif et qualitatif : ce qui fait la différence entre un protocole
|
||||
* qui tient et un qui dérive.
|
||||
* Référence : g1vote, sociocracie, Laloux, Elinor Ostrom (gouvernance des communs).
|
||||
*/
|
||||
|
||||
interface Milestone {
|
||||
num: number
|
||||
name: string
|
||||
icon: string
|
||||
actor: string
|
||||
duration: { min: string; standard: string; major: string }
|
||||
description: string
|
||||
essential: boolean
|
||||
tip?: string
|
||||
ostrom?: string
|
||||
}
|
||||
|
||||
const milestones: Milestone[] = [
|
||||
{
|
||||
num: 1,
|
||||
name: 'Prise d\'initiative',
|
||||
icon: 'i-lucide-lightbulb',
|
||||
actor: 'Tout membre',
|
||||
duration: { min: '—', standard: '1-2j', major: '1-2j' },
|
||||
description: 'Formaliser l\'intention : quel problème, quel besoin, quelle cible visée. Nommer un·e porteur·euse responsable.',
|
||||
essential: true,
|
||||
tip: 'Une initiative sans porteur identifié ne décolle pas. La responsabilité individuelle est le premier jalon.',
|
||||
ostrom: 'Principe 1 — Frontières claires : qui est concerné, pourquoi.',
|
||||
},
|
||||
{
|
||||
num: 2,
|
||||
name: 'Processus d\'avis (advice)',
|
||||
icon: 'i-lucide-message-circle',
|
||||
actor: 'Porteur + experts + impactés',
|
||||
duration: { min: '1j', standard: '3-7j', major: '7-14j' },
|
||||
description: 'Consulter les personnes qui ont l\'expertise ET celles qui seront impactées. Écouter vraiment, intégrer ou expliquer pourquoi on n\'intègre pas.',
|
||||
essential: true,
|
||||
tip: 'Ce jalon est souvent escamoté. C\'est la principale cause d\'échec ou de résistance en implémentation.',
|
||||
ostrom: 'Principe 5 — Résolution des conflits accessible et peu coûteuse.',
|
||||
},
|
||||
{
|
||||
num: 3,
|
||||
name: 'Rédaction + amendements',
|
||||
icon: 'i-lucide-file-edit',
|
||||
actor: 'Porteur + communauté',
|
||||
duration: { min: '1-2j', standard: '3-7j', major: '7-21j' },
|
||||
description: 'Rédiger la proposition formelle. Ouvrir une période d\'amendements publics. Intégrer les modifications acceptées, rejeter les autres avec justification.',
|
||||
essential: true,
|
||||
tip: 'Distinguer amendements substantiels (re-vote possible) et de forme (porteur décide).',
|
||||
},
|
||||
{
|
||||
num: 4,
|
||||
name: 'Qualification technique',
|
||||
icon: 'i-lucide-shield-check',
|
||||
actor: 'Comité technique (si applicable)',
|
||||
duration: { min: '—', standard: '2-5j', major: '5-10j' },
|
||||
description: 'Pour les décisions techniques : revue par les experts désignés. Évaluation de faisabilité, risques, impact. Avis formel (non bloquant, sauf veto défini).',
|
||||
essential: false,
|
||||
tip: 'Optionnel selon la nature de la décision. Systématique pour les Runtime Upgrades.',
|
||||
},
|
||||
{
|
||||
num: 5,
|
||||
name: 'Ouverture du vote',
|
||||
icon: 'i-lucide-vote',
|
||||
actor: 'Porteur + plateforme',
|
||||
duration: { min: '—', standard: '1j', major: '1j' },
|
||||
description: 'Publier la proposition finale. Notifier la communauté. Ouvrir la session de vote avec les paramètres définis (protocole, formule, durée).',
|
||||
essential: true,
|
||||
tip: 'L\'ouverture doit être annoncée à l\'avance (délai de préavis selon règlement).',
|
||||
},
|
||||
{
|
||||
num: 6,
|
||||
name: 'Phase de vote',
|
||||
icon: 'i-lucide-bar-chart-2',
|
||||
actor: 'Membres habilités',
|
||||
duration: { min: '3j', standard: '7-14j', major: '21-30j' },
|
||||
description: 'Les membres habilités votent selon le protocole. Seuil de participation minimal surveillé. Résultats intermédiaires visibles (ou non, selon le protocole).',
|
||||
essential: true,
|
||||
ostrom: 'Principe 3 — Choix collectifs : ceux qui sont concernés participent aux décisions.',
|
||||
},
|
||||
{
|
||||
num: 7,
|
||||
name: 'Contrôle du quorum',
|
||||
icon: 'i-lucide-check-circle',
|
||||
actor: 'Plateforme + porteur',
|
||||
duration: { min: '—', standard: '—', major: '—' },
|
||||
description: 'Vérifier que le quorum minimum est atteint avant clôture. Si non atteint : prolonger, relancer, ou annuler selon les règles préétablies.',
|
||||
essential: true,
|
||||
tip: 'Définir à l\'avance le quorum et la procédure si non atteint — évite les ambiguïtés.',
|
||||
ostrom: 'Principe 4 — Supervision des règles par les membres.',
|
||||
},
|
||||
{
|
||||
num: 8,
|
||||
name: 'Proclamation des résultats',
|
||||
icon: 'i-lucide-megaphone',
|
||||
actor: 'Plateforme + porteur',
|
||||
duration: { min: '—', standard: '1j', major: '1j' },
|
||||
description: 'Annoncer le résultat officiel avec les chiffres détaillés (votes pour, contre, abstentions, taux participation, seuil requis). Archiver on-chain si adopté.',
|
||||
essential: true,
|
||||
tip: 'La transparence des résultats est aussi importante que le résultat lui-même.',
|
||||
ostrom: 'Principe 8 — Gouvernance emboîtée : résultats remontés aux niveaux supérieurs.',
|
||||
},
|
||||
{
|
||||
num: 9,
|
||||
name: 'Mise en application',
|
||||
icon: 'i-lucide-play-circle',
|
||||
actor: 'Porteur + implémenteurs',
|
||||
duration: { min: '—', standard: 'Variable', major: 'Variable' },
|
||||
description: 'Planifier l\'application effective de la décision. Désigner les responsables. Fixer des jalons d\'implémentation si complexe.',
|
||||
essential: true,
|
||||
tip: 'Une décision adoptée mais non implémentée érode la confiance dans le processus.',
|
||||
},
|
||||
{
|
||||
num: 10,
|
||||
name: 'Suivi et accountability',
|
||||
icon: 'i-lucide-activity',
|
||||
actor: 'Porteur + communauté',
|
||||
duration: { min: '—', standard: 'Continu', major: 'Continu' },
|
||||
description: 'Rapports réguliers sur l\'avancement. Signalement des écarts. Mécanisme de remontée si la décision produit des effets inattendus.',
|
||||
essential: false,
|
||||
tip: 'Intégrer dans le prochain cycle de gouvernance si des ajustements s\'imposent.',
|
||||
ostrom: 'Principe 4 — Surveillance continue des comportements et résultats.',
|
||||
},
|
||||
{
|
||||
num: 11,
|
||||
name: 'Rétrospective',
|
||||
icon: 'i-lucide-rotate-ccw',
|
||||
actor: 'Cercle concerné',
|
||||
duration: { min: '—', standard: '1-2h', major: '1-2j' },
|
||||
description: 'Évaluer : le processus a-t-il bien fonctionné ? La décision produit-elle les effets attendus ? Quoi améliorer pour la prochaine fois ?',
|
||||
essential: false,
|
||||
tip: 'La rétrospective est le moteur d\'amélioration du protocole lui-même (méta-gouvernance).',
|
||||
ostrom: 'Principe 7 — Reconnaissance externe de l\'organisation par des autorités supérieures.',
|
||||
},
|
||||
]
|
||||
|
||||
const showOstrom = ref(false)
|
||||
const activeDecisionType = ref<'minor' | 'standard' | 'major'>('standard')
|
||||
|
||||
const decisionTypes = [
|
||||
{ value: 'minor', label: 'Mineur', color: 'teal' },
|
||||
{ value: 'standard', label: 'Standard', color: 'accent' },
|
||||
{ value: 'major', label: 'Majeur', color: 'secondary' },
|
||||
]
|
||||
|
||||
const essentialMilestones = computed(() =>
|
||||
milestones.filter(m => m.essential),
|
||||
)
|
||||
|
||||
const optionalMilestones = computed(() =>
|
||||
milestones.filter(m => !m.essential),
|
||||
)
|
||||
|
||||
const totalDuration = computed(() => {
|
||||
const type = activeDecisionType.value
|
||||
const durations = {
|
||||
minor: '5-10 jours',
|
||||
standard: '14-30 jours',
|
||||
major: '45-90 jours',
|
||||
}
|
||||
return durations[type]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wm">
|
||||
<!-- Header -->
|
||||
<div class="wm__header">
|
||||
<h3 class="wm__title">Jalons de protocole</h3>
|
||||
<p class="wm__subtitle">
|
||||
11 jalons, dont 7 indispensables. Durées recommandées selon le type de décision.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Decision type selector -->
|
||||
<div class="wm__type-selector">
|
||||
<button
|
||||
v-for="dt in decisionTypes"
|
||||
:key="dt.value"
|
||||
class="wm__type-btn"
|
||||
:class="[
|
||||
`wm__type-btn--${dt.color}`,
|
||||
{ 'wm__type-btn--active': activeDecisionType === dt.value },
|
||||
]"
|
||||
@click="activeDecisionType = dt.value as 'minor' | 'standard' | 'major'"
|
||||
>
|
||||
{{ dt.label }}
|
||||
</button>
|
||||
<span class="wm__total-duration">≈ {{ totalDuration }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Essential milestones -->
|
||||
<div class="wm__section">
|
||||
<div class="wm__section-label">
|
||||
<span class="wm__section-badge wm__section-badge--essential">7 essentiels</span>
|
||||
</div>
|
||||
<div class="wm__milestones">
|
||||
<div
|
||||
v-for="m in essentialMilestones"
|
||||
:key="m.num"
|
||||
class="wm__milestone wm__milestone--essential"
|
||||
>
|
||||
<div class="wm__milestone-left">
|
||||
<div class="wm__milestone-num">{{ m.num }}</div>
|
||||
<div v-if="m.num < milestones.length" class="wm__milestone-line" />
|
||||
</div>
|
||||
<div class="wm__milestone-icon">
|
||||
<UIcon :name="m.icon" />
|
||||
</div>
|
||||
<div class="wm__milestone-body">
|
||||
<div class="wm__milestone-head">
|
||||
<span class="wm__milestone-name">{{ m.name }}</span>
|
||||
<span class="wm__milestone-duration">
|
||||
{{ m.duration[activeDecisionType] || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="wm__milestone-desc">{{ m.description }}</p>
|
||||
<div v-if="m.tip" class="wm__milestone-tip">
|
||||
<UIcon name="i-lucide-lightbulb" />
|
||||
{{ m.tip }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Optional milestones -->
|
||||
<div class="wm__section">
|
||||
<div class="wm__section-label">
|
||||
<span class="wm__section-badge wm__section-badge--optional">4 contextuels</span>
|
||||
</div>
|
||||
<div class="wm__milestones">
|
||||
<div
|
||||
v-for="m in optionalMilestones"
|
||||
:key="m.num"
|
||||
class="wm__milestone wm__milestone--optional"
|
||||
>
|
||||
<div class="wm__milestone-left">
|
||||
<div class="wm__milestone-num wm__milestone-num--optional">{{ m.num }}</div>
|
||||
</div>
|
||||
<div class="wm__milestone-icon wm__milestone-icon--optional">
|
||||
<UIcon :name="m.icon" />
|
||||
</div>
|
||||
<div class="wm__milestone-body">
|
||||
<div class="wm__milestone-head">
|
||||
<span class="wm__milestone-name">{{ m.name }}</span>
|
||||
<span class="wm__milestone-duration">
|
||||
{{ m.duration[activeDecisionType] || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="wm__milestone-desc">{{ m.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ostrom toggle -->
|
||||
<button class="wm__ostrom-toggle" @click="showOstrom = !showOstrom">
|
||||
<UIcon name="i-lucide-book-open" />
|
||||
<span>Principes Ostrom appliqués</span>
|
||||
<UIcon :name="showOstrom ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'" />
|
||||
</button>
|
||||
|
||||
<Transition name="expand">
|
||||
<div v-if="showOstrom" class="wm__ostrom">
|
||||
<p class="wm__ostrom-intro">
|
||||
Elinor Ostrom (Nobel 2009) a identifié 8 principes pour la gouvernance
|
||||
durable des communs. Les jalons ci-dessus les incarnent.
|
||||
</p>
|
||||
<div class="wm__ostrom-items">
|
||||
<div
|
||||
v-for="m in milestones.filter(x => x.ostrom)"
|
||||
:key="m.num"
|
||||
class="wm__ostrom-item"
|
||||
>
|
||||
<span class="wm__ostrom-jalon">Jalon {{ m.num }}</span>
|
||||
<span class="wm__ostrom-text">{{ m.ostrom }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wm { display: flex; flex-direction: column; gap: 1rem; }
|
||||
|
||||
.wm__header { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
.wm__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wm__subtitle {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Type selector */
|
||||
.wm__type-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wm__type-btn {
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-text-muted);
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.wm__type-btn--accent.wm__type-btn--active {
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
}
|
||||
|
||||
.wm__type-btn--teal.wm__type-btn--active {
|
||||
background: color-mix(in srgb, var(--mood-success) 20%, transparent);
|
||||
color: var(--mood-success);
|
||||
}
|
||||
|
||||
.wm__type-btn--secondary.wm__type-btn--active {
|
||||
background: color-mix(in srgb, var(--mood-secondary, var(--mood-accent)) 20%, transparent);
|
||||
color: var(--mood-secondary, var(--mood-accent));
|
||||
}
|
||||
|
||||
.wm__total-duration {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Section */
|
||||
.wm__section { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
|
||||
.wm__section-label { display: flex; align-items: center; gap: 0.5rem; }
|
||||
|
||||
.wm__section-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 10px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.wm__section-badge--essential {
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.wm__section-badge--optional {
|
||||
background: color-mix(in srgb, var(--mood-text-muted) 12%, transparent);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
/* Milestones */
|
||||
.wm__milestones { display: flex; flex-direction: column; gap: 0; }
|
||||
|
||||
.wm__milestone {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.wm__milestone-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 1.375rem;
|
||||
}
|
||||
|
||||
.wm__milestone-num {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-accent);
|
||||
color: var(--mood-accent-text);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.wm__milestone-num--optional {
|
||||
background: color-mix(in srgb, var(--mood-text-muted) 20%, transparent);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.wm__milestone-line {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
min-height: 1.25rem;
|
||||
background: color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wm__milestone-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.wm__milestone-icon--optional {
|
||||
background: color-mix(in srgb, var(--mood-text-muted) 10%, transparent);
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.wm__milestone-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.wm__milestone-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wm__milestone-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.wm__milestone--optional .wm__milestone-name {
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.wm__milestone-duration {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft);
|
||||
padding: 1px 6px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.wm__milestone-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
margin: 0.125rem 0 0;
|
||||
}
|
||||
|
||||
.wm__milestone-tip {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
background: color-mix(in srgb, var(--mood-accent) 8%, transparent);
|
||||
border-radius: 8px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-accent);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Ostrom */
|
||||
.wm__ostrom-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
transition: color 0.12s ease;
|
||||
text-align: left;
|
||||
}
|
||||
.wm__ostrom-toggle:hover { color: var(--mood-text); }
|
||||
.wm__ostrom-toggle .i-lucide-book-open { color: var(--mood-accent); }
|
||||
|
||||
.wm__ostrom {
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 12px;
|
||||
padding: 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.wm__ostrom-intro {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wm__ostrom-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.wm__ostrom-item {
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wm__ostrom-jalon {
|
||||
font-weight: 700;
|
||||
color: var(--mood-accent);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wm__ostrom-text { color: var(--mood-text-muted); }
|
||||
|
||||
/* Expand transition */
|
||||
.expand-enter-active, .expand-leave-active { transition: all 0.2s ease; overflow: hidden; }
|
||||
.expand-enter-from, .expand-leave-to { max-height: 0; opacity: 0; }
|
||||
.expand-enter-to, .expand-leave-from { max-height: 1000px; opacity: 1; }
|
||||
</style>
|
||||
@@ -1,110 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Display vote formula with KaTeX rendering.
|
||||
*
|
||||
* Renders the WoT threshold formula using KaTeX when available,
|
||||
* falling back to a code display. Shows parameter values and
|
||||
* optional Smith/TechComm criteria formulas.
|
||||
*/
|
||||
import type { FormulaConfig } from '~/stores/protocols'
|
||||
|
||||
const props = defineProps<{
|
||||
formulaConfig: FormulaConfig
|
||||
showExplanation?: boolean
|
||||
}>()
|
||||
|
||||
const showExplain = ref(props.showExplanation ?? false)
|
||||
|
||||
/**
|
||||
* Render a LaTeX string to HTML using KaTeX, with code fallback.
|
||||
*/
|
||||
function renderFormula(tex: string): string {
|
||||
if (typeof window !== 'undefined' && (window as any).katex) {
|
||||
return (window as any).katex.renderToString(tex, { throwOnError: false, displayMode: true })
|
||||
}
|
||||
return `<code class="text-sm font-mono">${tex}</code>`
|
||||
}
|
||||
|
||||
/** Main threshold formula in LaTeX. */
|
||||
const mainFormulaTeX = 'Seuil = C + B^W + \\left(M + (1-M) \\cdot \\left(1 - \\left(\\frac{T}{W}\\right)^G\\right)\\right) \\cdot \\max(0,\\, T - C)'
|
||||
|
||||
/** Smith criterion formula. */
|
||||
const smithFormulaTeX = computed(() => {
|
||||
if (props.formulaConfig.smith_exponent === null) return null
|
||||
return `Seuil_{Smith} = \\lceil W_{Smith}^{${props.formulaConfig.smith_exponent}} \\rceil`
|
||||
})
|
||||
|
||||
/** TechComm criterion formula. */
|
||||
const techcommFormulaTeX = computed(() => {
|
||||
if (props.formulaConfig.techcomm_exponent === null) return null
|
||||
return `Seuil_{TechComm} = \\lceil W_{TechComm}^{${props.formulaConfig.techcomm_exponent}} \\rceil`
|
||||
})
|
||||
|
||||
const mainFormulaHtml = computed(() => renderFormula(mainFormulaTeX))
|
||||
const smithFormulaHtml = computed(() => smithFormulaTeX.value ? renderFormula(smithFormulaTeX.value) : null)
|
||||
const techcommFormulaHtml = computed(() => techcommFormulaTeX.value ? renderFormula(techcommFormulaTeX.value) : null)
|
||||
|
||||
const parameters = computed(() => [
|
||||
{ label: 'Duree', code: 'D', value: `${props.formulaConfig.duration_days} jours`, description: 'Duree du vote en jours' },
|
||||
{ label: 'Majorite', code: 'M', value: `${props.formulaConfig.majority_pct}%`, description: 'Ratio de majorite cible a haute participation' },
|
||||
{ label: 'Base', code: 'B', value: String(props.formulaConfig.base_exponent), description: 'Exposant de base (B^W tend vers 0 si B < 1)' },
|
||||
{ label: 'Gradient', code: 'G', value: String(props.formulaConfig.gradient_exponent), description: 'Exposant du gradient d\'inertie' },
|
||||
{ label: 'Constante', code: 'C', value: String(props.formulaConfig.constant_base), description: 'Plancher fixe de votes requis' },
|
||||
...(props.formulaConfig.smith_exponent !== null ? [{
|
||||
label: 'Smith', code: 'S', value: String(props.formulaConfig.smith_exponent), description: 'Exposant du critere Smith',
|
||||
}] : []),
|
||||
...(props.formulaConfig.techcomm_exponent !== null ? [{
|
||||
label: 'TechComm', code: 'T', value: String(props.formulaConfig.techcomm_exponent), description: 'Exposant du critere TechComm',
|
||||
}] : []),
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Main formula -->
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg overflow-x-auto">
|
||||
<div v-html="mainFormulaHtml" class="text-center" />
|
||||
</div>
|
||||
|
||||
<!-- Smith criterion -->
|
||||
<div v-if="smithFormulaHtml" class="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg overflow-x-auto">
|
||||
<p class="text-xs font-semibold text-blue-600 dark:text-blue-400 mb-2">Critere Smith</p>
|
||||
<div v-html="smithFormulaHtml" class="text-center" />
|
||||
</div>
|
||||
|
||||
<!-- TechComm criterion -->
|
||||
<div v-if="techcommFormulaHtml" class="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg overflow-x-auto">
|
||||
<p class="text-xs font-semibold text-purple-600 dark:text-purple-400 mb-2">Critere TechComm</p>
|
||||
<div v-html="techcommFormulaHtml" class="text-center" />
|
||||
</div>
|
||||
|
||||
<!-- Parameters grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div
|
||||
v-for="param in parameters"
|
||||
:key="param.code"
|
||||
class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="font-mono font-bold text-primary text-sm">{{ param.code }}</span>
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">{{ param.label }}</span>
|
||||
</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ param.value }}</div>
|
||||
<p v-if="showExplain" class="text-xs text-gray-500 mt-1">{{ param.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation toggle -->
|
||||
<div class="flex justify-end">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
:icon="showExplain ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
||||
@click="showExplain = !showExplain"
|
||||
>
|
||||
{{ showExplain ? 'Masquer les explications' : 'Afficher les explications' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,114 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Visual gauge showing votes vs threshold.
|
||||
*
|
||||
* Displays a horizontal progress bar with green (pour) and red (contre) fills,
|
||||
* a vertical threshold marker, participation statistics, and a pass/fail badge.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
votesFor: number
|
||||
votesAgainst: number
|
||||
threshold: number
|
||||
wotSize: number
|
||||
}>()
|
||||
|
||||
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
|
||||
|
||||
/** Percentage of "pour" votes relative to total votes. */
|
||||
const forPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesFor / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
/** Percentage of "contre" votes relative to total votes. */
|
||||
const againstPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesAgainst / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
/** Position of the threshold marker as a percentage of total votes. */
|
||||
const thresholdPosition = computed(() => {
|
||||
if (totalVotes.value === 0) return 50
|
||||
// Threshold as a percentage of total votes
|
||||
const pct = (props.threshold / totalVotes.value) * 100
|
||||
return Math.min(pct, 100)
|
||||
})
|
||||
|
||||
/** Whether the vote passes (votes_for >= threshold). */
|
||||
const isPassing = computed(() => props.votesFor >= props.threshold)
|
||||
|
||||
/** Participation rate. */
|
||||
const participationRate = computed(() => {
|
||||
if (props.wotSize === 0) return 0
|
||||
return (totalVotes.value / props.wotSize) * 100
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<!-- Progress bar -->
|
||||
<div class="relative h-8 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<!-- Green fill (pour) -->
|
||||
<div
|
||||
class="absolute inset-y-0 left-0 bg-green-500 transition-all duration-500"
|
||||
:style="{ width: `${forPct}%` }"
|
||||
/>
|
||||
<!-- Red fill (contre) -->
|
||||
<div
|
||||
class="absolute inset-y-0 bg-red-500 transition-all duration-500"
|
||||
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
|
||||
/>
|
||||
|
||||
<!-- Threshold marker -->
|
||||
<div
|
||||
v-if="totalVotes > 0"
|
||||
class="absolute inset-y-0 w-0.5 bg-yellow-400 z-10"
|
||||
:style="{ left: `${thresholdPosition}%` }"
|
||||
>
|
||||
<div class="absolute -top-5 left-1/2 -translate-x-1/2 text-xs font-bold text-yellow-600 dark:text-yellow-400 whitespace-nowrap">
|
||||
Seuil
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Percentage labels inside the bar -->
|
||||
<div class="absolute inset-0 flex items-center px-3 text-xs font-bold text-white">
|
||||
<span v-if="forPct > 10">{{ forPct.toFixed(1) }}%</span>
|
||||
<span class="flex-1" />
|
||||
<span v-if="againstPct > 10">{{ againstPct.toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Counts and threshold text -->
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-green-600 dark:text-green-400 font-medium">
|
||||
{{ votesFor }} pour
|
||||
</span>
|
||||
<span class="text-red-600 dark:text-red-400 font-medium">
|
||||
{{ votesAgainst }} contre
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="font-medium" :class="isPassing ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400'">
|
||||
{{ votesFor }} / {{ threshold }} requis
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participation rate -->
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} sur {{ wotSize }} membres
|
||||
({{ participationRate.toFixed(2) }}%)
|
||||
</span>
|
||||
|
||||
<!-- Pass/fail badge -->
|
||||
<UBadge
|
||||
:color="isPassing ? 'success' : 'error'"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
{{ isPassing ? 'Adopte' : 'Non adopte' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user