Composants engagement: GenesisBlock, InertiaSlider, MiniVoteBoard, EngagementCard, DocumentTuto
Backend: genesis_json sur Document, section_tag/inertia_preset/is_permanent_vote sur DocumentItem Frontend: 5 nouveaux composants pour vue detail document enrichie - GenesisBlock: sources, outils, synthese forum, contributeurs (depliable) - InertiaSlider: visualisation inertie 4 niveaux avec params formule G/M - MiniVoteBoard: tableau vote compact (barre seuil, pour/contre, participation) - EngagementCard: carte item enrichie integrant vote + inertie + actions - DocumentTuto: modal pedagogique vote permanent/inertie/seuils Seed et page [slug] enrichis pour exploiter les nouveaux champs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, Boolean, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -19,10 +19,11 @@ class Document(Base):
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
||||
chain_anchor: Mapped[str | None] = mapped_column(String(128))
|
||||
genesis_json: Mapped[str | None] = mapped_column(Text) # JSON: source files, repos, forum URLs, formula trigger
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
items: Mapped[list["DocumentItem"]] = relationship(back_populates="document", cascade="all, delete-orphan", order_by="DocumentItem.position")
|
||||
items: Mapped[list["DocumentItem"]] = relationship(back_populates="document", cascade="all, delete-orphan", order_by="DocumentItem.sort_order")
|
||||
|
||||
|
||||
class DocumentItem(Base):
|
||||
@@ -31,11 +32,14 @@ class DocumentItem(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"), nullable=False)
|
||||
position: Mapped[str] = mapped_column(String(16), nullable=False) # "1", "1.1", "3.2"
|
||||
item_type: Mapped[str] = mapped_column(String(32), default="clause") # clause, rule, verification, preamble, section
|
||||
item_type: Mapped[str] = mapped_column(String(32), default="clause") # clause, rule, verification, preamble, section, genesis
|
||||
title: Mapped[str | None] = mapped_column(String(256))
|
||||
current_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
voting_protocol_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("voting_protocols.id"))
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0)
|
||||
section_tag: Mapped[str | None] = mapped_column(String(64)) # genesis, fondamental, technique, annexe, formule, inertie, ordonnancement
|
||||
inertia_preset: Mapped[str] = mapped_column(String(16), default="standard") # low, standard, high, very_high
|
||||
is_permanent_vote: Mapped[bool] = mapped_column(default=True) # permanent vote vs time-bounded
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class DocumentOut(BaseModel):
|
||||
description: str | None = None
|
||||
ipfs_cid: str | None = None
|
||||
chain_anchor: str | None = None
|
||||
genesis_json: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
items_count: int = Field(default=0, description="Number of items in this document")
|
||||
@@ -54,10 +55,13 @@ class DocumentItemCreate(BaseModel):
|
||||
"""Payload for creating a document item (clause, rule, etc.)."""
|
||||
|
||||
position: str = Field(..., max_length=16, description='Hierarchical position e.g. "1", "1.1", "3.2"')
|
||||
item_type: str = Field(default="clause", max_length=32, description="clause, rule, verification, preamble, section")
|
||||
item_type: str = Field(default="clause", max_length=32, description="clause, rule, verification, preamble, section, genesis")
|
||||
title: str | None = Field(default=None, max_length=256)
|
||||
current_text: str = Field(..., min_length=1)
|
||||
voting_protocol_id: UUID | None = None
|
||||
section_tag: str | None = Field(default=None, max_length=64)
|
||||
inertia_preset: str = Field(default="standard", max_length=16)
|
||||
is_permanent_vote: bool = True
|
||||
|
||||
|
||||
class DocumentItemUpdate(BaseModel):
|
||||
@@ -82,6 +86,9 @@ class DocumentItemOut(BaseModel):
|
||||
current_text: str
|
||||
voting_protocol_id: UUID | None = None
|
||||
sort_order: int
|
||||
section_tag: str | None = None
|
||||
inertia_preset: str = "standard"
|
||||
is_permanent_vote: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -99,6 +106,9 @@ class DocumentItemFullOut(BaseModel):
|
||||
current_text: str
|
||||
voting_protocol_id: UUID | None = None
|
||||
sort_order: int
|
||||
section_tag: str | None = None
|
||||
inertia_preset: str = "standard"
|
||||
is_permanent_vote: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
versions: list[ItemVersionOut] = Field(default_factory=list)
|
||||
@@ -118,6 +128,7 @@ class DocumentFullOut(BaseModel):
|
||||
description: str | None = None
|
||||
ipfs_cid: str | None = None
|
||||
chain_anchor: str | None = None
|
||||
genesis_json: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
items: list[DocumentItemOut] = Field(default_factory=list)
|
||||
|
||||
666
backend/seed.py
666
backend/seed.py
@@ -2,9 +2,17 @@
|
||||
|
||||
Sources:
|
||||
- Engagement Forgeron v2.0.0: https://forum.monnaie-libre.fr/t/33165
|
||||
- Engagement Certification (Licence G1): monnaie-libre.fr/licence-g1/
|
||||
- Engagement Certification (Licence G1 v0.3.0):
|
||||
https://git.duniter.org/documents/g1_monetary_license/-/raw/master/g1_monetary_license_fr.rst
|
||||
- Charte 1.0 (rejected): https://forum.monnaie-libre.fr/t/proposition-charte-1-0/31066
|
||||
- Licence v0.4.0 (in progress): https://forum.monnaie-libre.fr/t/32375
|
||||
- Runtime Upgrade process template
|
||||
|
||||
Genesis references:
|
||||
- Licence repo: https://git.duniter.org/documents/g1_monetary_license
|
||||
- g1vote: https://git.duniter.org/tools/g1vote-view
|
||||
- g1vote live: https://g1vote-view-237903.pages.duniter.org/
|
||||
|
||||
Idempotent: checks if data already exists before inserting.
|
||||
"""
|
||||
|
||||
@@ -12,6 +20,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
@@ -61,13 +70,47 @@ def fake_signature(payload: str) -> str:
|
||||
async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig]:
|
||||
configs: dict[str, dict] = {
|
||||
"Standard Licence G1": {
|
||||
"description": "Formule standard pour la Licence G1 : vote binaire WoT.",
|
||||
"description": "Formule standard pour la Licence G1 : vote binaire WoT. Inertie standard.",
|
||||
"duration_days": 30,
|
||||
"majority_pct": 50,
|
||||
"base_exponent": 0.1,
|
||||
"gradient_exponent": 0.2,
|
||||
"constant_base": 0.0,
|
||||
},
|
||||
"Inertie basse (Annexes)": {
|
||||
"description": (
|
||||
"Formule a inertie basse pour les annexes et recommandations. "
|
||||
"Gradient faible = plus facile a remplacer meme a faible participation."
|
||||
),
|
||||
"duration_days": 30,
|
||||
"majority_pct": 50,
|
||||
"base_exponent": 0.1,
|
||||
"gradient_exponent": 0.1,
|
||||
"constant_base": 0.0,
|
||||
},
|
||||
"Inertie haute (Formule)": {
|
||||
"description": (
|
||||
"Formule a inertie haute pour la section formule elle-meme. "
|
||||
"Gradient eleve = necessite une forte mobilisation pour changer."
|
||||
),
|
||||
"duration_days": 30,
|
||||
"majority_pct": 60,
|
||||
"base_exponent": 0.1,
|
||||
"gradient_exponent": 0.4,
|
||||
"constant_base": 0.0,
|
||||
},
|
||||
"Inertie tres haute (Meta-reglage)": {
|
||||
"description": (
|
||||
"Formule a inertie maximale pour le reglage de l'inertie elle-meme. "
|
||||
"Quasi-unanimite requise a toute participation. Protection contre "
|
||||
"la modification des regles de modification."
|
||||
),
|
||||
"duration_days": 30,
|
||||
"majority_pct": 66,
|
||||
"base_exponent": 0.1,
|
||||
"gradient_exponent": 0.6,
|
||||
"constant_base": 0.0,
|
||||
},
|
||||
"Forgeron avec Smith": {
|
||||
"description": "Vote forgeron avec critere Smith sub-WoT.",
|
||||
"duration_days": 30,
|
||||
@@ -170,112 +213,599 @@ async def seed_voting_protocols(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed: Engagement Certification (Licence G1 - obligations certificateur)
|
||||
# Seed: Engagement Certification (Acte d'engagement certification)
|
||||
#
|
||||
# Full structured document built from:
|
||||
# - Licence G1 v0.3.0 (in force)
|
||||
# - Charte 1.0 (rejected proposal, topic 31066)
|
||||
# - Licence v0.4.0 (in progress, topic 32375)
|
||||
# - Yvv's "Acte d'engagement" position
|
||||
# - Checklist certification (topic 32412)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GENESIS_CERTIFICATION = {
|
||||
"source_document": {
|
||||
"title": "Licence de la monnaie libre G1 v0.3.0",
|
||||
"url": "https://git.duniter.org/documents/g1_monetary_license/-/raw/master/g1_monetary_license_fr.rst",
|
||||
"repo": "https://git.duniter.org/documents/g1_monetary_license",
|
||||
},
|
||||
"reference_tools": {
|
||||
"g1vote_repo": "https://git.duniter.org/tools/g1vote-view",
|
||||
"g1vote_live": "https://g1vote-view-237903.pages.duniter.org/",
|
||||
"cesium": "https://g1.duniter.org",
|
||||
"gecko": "https://gecko.music-all.org",
|
||||
},
|
||||
"forum_synthesis": [
|
||||
{
|
||||
"title": "Proposition Charte 1.0 (rejetee)",
|
||||
"url": "https://forum.monnaie-libre.fr/t/proposition-charte-1-0/31066",
|
||||
"status": "rejected",
|
||||
"posts": 70,
|
||||
},
|
||||
{
|
||||
"title": "Preparation licence v0.4.0",
|
||||
"url": "https://forum.monnaie-libre.fr/t/preparation-dune-proposition-devolution-de-la-licence-1/32375",
|
||||
"status": "in_progress",
|
||||
"posts": 38,
|
||||
},
|
||||
{
|
||||
"title": "Checklist de certification (annexe licence)",
|
||||
"url": "https://forum.monnaie-libre.fr/t/prepa-checklist-de-certification-annexe-licence-1/32412",
|
||||
"status": "in_progress",
|
||||
"posts": 16,
|
||||
},
|
||||
{
|
||||
"title": "Regles de modifications (annexe licence)",
|
||||
"url": "https://forum.monnaie-libre.fr/t/prepa-regles-de-modifications-annexe-licence-1/32409",
|
||||
"status": "in_progress",
|
||||
"posts": 9,
|
||||
},
|
||||
{
|
||||
"title": "Vote nuance licence",
|
||||
"url": "https://forum.monnaie-libre.fr/t/processus-de-validation-licence-par-vote-nuance/31729",
|
||||
"status": "reference",
|
||||
},
|
||||
],
|
||||
"formula_trigger": (
|
||||
"Quand un item atteint le seuil d'adoption (formule WoT), "
|
||||
"le texte de remplacement est integre au document officiel. "
|
||||
"Le hash IPFS du document mis a jour est ancre on-chain via system.remark. "
|
||||
"Les applications (Cesium, Gecko) pointent vers le depot git officiel "
|
||||
"qui est synchronise avec l'etat valide par les votes."
|
||||
),
|
||||
"contributors": [
|
||||
{"name": "1000i100", "role": "Pilote principal, redacteur"},
|
||||
{"name": "Natha", "role": "Co-redactrice"},
|
||||
{"name": "Pini", "role": "Co-initiateur"},
|
||||
{"name": "Yvv", "role": "Contributions structurelles, concepteur 'Acte d'engagement'"},
|
||||
{"name": "elois", "role": "Dev g1vote, contributions techniques"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
|
||||
# ===================================================================
|
||||
# INTRODUCTION
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "C1",
|
||||
"item_type": "clause",
|
||||
"title": "Transmission de la licence",
|
||||
"position": "I1",
|
||||
"item_type": "preamble",
|
||||
"title": "Preambule",
|
||||
"sort_order": 1,
|
||||
"section_tag": "introduction",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Toute operation de certification d'un nouveau membre de la monnaie libre G1 "
|
||||
"doit prealablement s'accompagner de la transmission de cette licence "
|
||||
"de la monnaie libre G1 au certifie."
|
||||
"Le present acte d'engagement definit les obligations reciproques "
|
||||
"des membres de la toile de confiance de la monnaie libre G1. "
|
||||
"Cet acte est de fait l'unique relation contractuelle de notre "
|
||||
"toile fiduciaire. Toute certification doit s'accompagner de la "
|
||||
"transmission de ce document, dont le certificateur doit s'assurer "
|
||||
"qu'il a ete etudie, compris et accepte par le certifie."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "C2",
|
||||
"item_type": "clause",
|
||||
"title": "Connaissance suffisante du certifie",
|
||||
"position": "I2",
|
||||
"item_type": "preamble",
|
||||
"title": "Les deux garanties reciproques",
|
||||
"sort_order": 2,
|
||||
"section_tag": "introduction",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Certifier n'est pas uniquement s'assurer que vous avez rencontre la personne, "
|
||||
"c'est assurer a la communaute G1 que vous connaissez suffisamment bien la "
|
||||
"personne que vous vous appretez a certifier."
|
||||
"La certification repose sur deux garanties reciproques :\n\n"
|
||||
"**1.** Derriere une cle publique creatrice de monnaie "
|
||||
"se trouve un **etre humain vivant**.\n\n"
|
||||
"**2.** Derriere cet etre humain se trouve **une seule et unique** "
|
||||
"cle publique creatrice de monnaie.\n\n"
|
||||
"La certification est un acte technique et fiduciaire, "
|
||||
"pas un acte d'adhesion morale ou de sympathie."
|
||||
),
|
||||
},
|
||||
# ===================================================================
|
||||
# TITRE 1 : ENGAGEMENTS FONDAMENTAUX (sur l'honneur)
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "C3",
|
||||
"item_type": "clause",
|
||||
"title": "Contact par plusieurs moyens",
|
||||
"position": "T1",
|
||||
"item_type": "section",
|
||||
"title": "Engagements fondamentaux",
|
||||
"sort_order": 3,
|
||||
"section_tag": "fondamental",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Connaitre la personne par plusieurs moyens de contact differents "
|
||||
"(physique, electronique, etc.) permettant de verifier son identite "
|
||||
"et de maintenir un lien de confiance dans la duree."
|
||||
"Les engagements fondamentaux ci-dessous constituent le socle "
|
||||
"irreductible de l'acte d'engagement. Ils sont pris sur l'honneur "
|
||||
"par tout membre de la toile de confiance."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "C4",
|
||||
"position": "E1",
|
||||
"item_type": "clause",
|
||||
"title": "Ne jamais certifier seul",
|
||||
"title": "Unicite du compte",
|
||||
"sort_order": 4,
|
||||
"section_tag": "fondamental",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Ne certifiez jamais seul, mais accompagne d'au moins un autre membre "
|
||||
"de la TdC G1, pour garantir un double controle et eviter les "
|
||||
"certifications abusives."
|
||||
"Je m'engage sur l'honneur a n'avoir et n'avoir jamais "
|
||||
"qu'un seul et unique compte cocreateur de monnaie G1."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "C5",
|
||||
"item_type": "verification",
|
||||
"title": "Verification des certifications existantes",
|
||||
"position": "E2",
|
||||
"item_type": "clause",
|
||||
"title": "Certification responsable",
|
||||
"sort_order": 5,
|
||||
"section_tag": "fondamental",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Avant toute certification, assurez-vous de verifier si le compte du "
|
||||
"certifie a deja recu une ou plusieurs certifications, et de qui "
|
||||
"elles proviennent."
|
||||
"Je m'engage sur l'honneur a ne certifier que des personnes "
|
||||
"physiques qui respectent scrupuleusement ces deux presents "
|
||||
"engagements fondamentaux."
|
||||
),
|
||||
},
|
||||
# ===================================================================
|
||||
# TITRE 2 : ENGAGEMENTS TECHNIQUES
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "C6",
|
||||
"item_type": "verification",
|
||||
"title": "Verification de maitrise du compte",
|
||||
"position": "T2",
|
||||
"item_type": "section",
|
||||
"title": "Engagements techniques",
|
||||
"sort_order": 6,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Verifier la maitrise du compte par un transfert test : envoyer "
|
||||
"quelques G1 et demander un renvoi, afin de s'assurer que la personne "
|
||||
"controle bien sa cle privee."
|
||||
"Les engagements techniques definissent les obligations "
|
||||
"pratiques et verifiables du certificateur pour garantir "
|
||||
"la qualite de la toile de confiance."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "C7",
|
||||
"item_type": "verification",
|
||||
"title": "Verification de la licence",
|
||||
"position": "E3",
|
||||
"item_type": "clause",
|
||||
"title": "Connaissance suffisante",
|
||||
"sort_order": 7,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Verifiez que vos contacts ont bien etudie et compris la licence G1 "
|
||||
"a jour avant de proceder a la certification."
|
||||
"Je me suis assure de connaitre suffisamment la personne "
|
||||
"qui gere cette cle publique. Connaitre suffisamment ne signifie "
|
||||
"pas « avoir vu » ; c'est assurer a la communaute G1 que je "
|
||||
"pourrai la contacter facilement et etre en mesure de reperer "
|
||||
"un double-compte ou tout autre probleme."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "C8",
|
||||
"item_type": "verification",
|
||||
"title": "Document de revocation",
|
||||
"position": "E4",
|
||||
"item_type": "clause",
|
||||
"title": "Verification personnelle de la cle",
|
||||
"sort_order": 8,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"D'avoir bien verifie avec la personne concernee qu'elle a bien genere "
|
||||
"son document Duniter de revocation de compte, et qu'elle le conserve "
|
||||
"J'ai personnellement verifie que c'est bien cette cle publique "
|
||||
"que je m'apprete a certifier, en la comparant avec la personne "
|
||||
"concernee et non par un intermediaire."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "E5",
|
||||
"item_type": "clause",
|
||||
"title": "Joignabilite reciproque",
|
||||
"sort_order": 9,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Je suis joignable rapidement et facilement par mes certifieurs, "
|
||||
"et je peux joindre rapidement et facilement les personnes que "
|
||||
"je certifie, par plusieurs moyens de communication differents "
|
||||
"et independants (physique, telephone, email, messagerie, etc.)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "E6",
|
||||
"item_type": "clause",
|
||||
"title": "Document de revocation",
|
||||
"sort_order": 10,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"J'ai verifie avec la personne certifiee qu'elle a bien genere "
|
||||
"son document de revocation de compte et qu'elle le conserve "
|
||||
"en lieu sur."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "C9",
|
||||
"position": "E7",
|
||||
"item_type": "clause",
|
||||
"title": "Rencontre physique ou multi-canal",
|
||||
"sort_order": 9,
|
||||
"title": "Rencontre physique ou verification multi-canaux",
|
||||
"sort_order": 11,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"De rencontrer la personne physiquement, OU de verifier a distance "
|
||||
"le lien personne / cle publique par plusieurs moyens de communication "
|
||||
"differents et independants."
|
||||
"J'ai rencontre la personne physiquement (preferable), **OU** "
|
||||
"j'ai verifie a distance le lien personne / cle publique par "
|
||||
"plusieurs moyens de communication differents et independants : "
|
||||
"courrier + reseau social + forum + email + visio + telephone."
|
||||
),
|
||||
},
|
||||
# ===================================================================
|
||||
# TITRE 3 : CONSEILS ET BONNES PRATIQUES
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "T3",
|
||||
"item_type": "section",
|
||||
"title": "Conseils et bonnes pratiques",
|
||||
"sort_order": 12,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Les pratiques suivantes sont fortement recommandees pour "
|
||||
"garantir la qualite et la securite de la toile de confiance."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "E8",
|
||||
"item_type": "clause",
|
||||
"title": "Ne jamais certifier seul",
|
||||
"sort_order": 13,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Ne certifiez jamais seul, mais accompagne d'au moins un autre "
|
||||
"membre de la toile de confiance G1, pour garantir un double "
|
||||
"controle."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "E9",
|
||||
"item_type": "clause",
|
||||
"title": "Verification des certifications existantes",
|
||||
"sort_order": 14,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Avant toute certification, verifiez si le compte a deja "
|
||||
"recu des certifications et de qui elles proviennent. "
|
||||
"Contactez les certifieurs existants en cas de doute. "
|
||||
"Si un certifieurs existant ne connait pas la personne, "
|
||||
"alertez immediatement les experts de la communaute."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "E10",
|
||||
"item_type": "clause",
|
||||
"title": "Verification de maitrise du compte",
|
||||
"sort_order": 15,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Verifiez la maitrise du compte par un transfert-test : "
|
||||
"envoyez quelques G1 et demandez le renvoi, afin de vous "
|
||||
"assurer que la personne controle bien sa cle privee."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "E11",
|
||||
"item_type": "clause",
|
||||
"title": "Transmission et comprehension du document",
|
||||
"sort_order": 16,
|
||||
"section_tag": "technique",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"Verifiez que vos contacts ont bien etudie et compris "
|
||||
"le present acte d'engagement dans sa version a jour "
|
||||
"avant de proceder a la certification."
|
||||
),
|
||||
},
|
||||
# ===================================================================
|
||||
# CONCLUSION
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "K1",
|
||||
"item_type": "preamble",
|
||||
"title": "Regles abregees de la toile de confiance",
|
||||
"sort_order": 17,
|
||||
"section_tag": "conclusion",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"**Parametres protocolaires en vigueur :**\n\n"
|
||||
"- Stock de **100 certifications** possibles\n"
|
||||
"- 1 certification emissible tous les **5 jours**\n"
|
||||
"- Nouveau compte valide si **>= 5 certifications** recues en **2 mois**\n"
|
||||
"- Condition de distance : **<= 5 pas** de **80% des sentinelles**\n"
|
||||
"- Sentinelle : membre ayant recu et emis >= Y[N] certifs "
|
||||
"(Y = ceil(N^(1/5)))\n"
|
||||
"- Certifications actives valables **2 ans**\n"
|
||||
"- Renouvellement de l'accord tous les **12 mois**"
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "K2",
|
||||
"item_type": "preamble",
|
||||
"title": "Monnaie G1",
|
||||
"sort_order": 18,
|
||||
"section_tag": "conclusion",
|
||||
"inertia_preset": "standard",
|
||||
"current_text": (
|
||||
"**Parametres monetaires :**\n\n"
|
||||
"- 1 Dividende Universel (DU) par personne par jour\n"
|
||||
"- Reevaluation a chaque equinoxe : "
|
||||
"`DU(n+1) = DU(n) + c² × (M/N) / 182.625` avec c = 4.88%\n"
|
||||
"- DU(0) = 10.00 G1"
|
||||
),
|
||||
},
|
||||
# ===================================================================
|
||||
# ANNEXE 1 : INTEGRATION LOGICIELLE
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "X1",
|
||||
"item_type": "section",
|
||||
"title": "Annexe 1 : Integration logicielle",
|
||||
"sort_order": 19,
|
||||
"section_tag": "annexe",
|
||||
"inertia_preset": "low",
|
||||
"current_text": (
|
||||
"Les obligations pour les logiciels implementant la certification G1."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "X1.1",
|
||||
"item_type": "clause",
|
||||
"title": "Depot de reference",
|
||||
"sort_order": 20,
|
||||
"section_tag": "annexe",
|
||||
"inertia_preset": "low",
|
||||
"current_text": (
|
||||
"Le texte de reference de l'acte d'engagement certification est "
|
||||
"heberge dans le depot git officiel : "
|
||||
"https://git.duniter.org/documents/g1_monetary_license\n\n"
|
||||
"Les applications Cesium, Gecko et toute application de "
|
||||
"certification doivent pointer vers ce depot pour afficher "
|
||||
"la version en vigueur."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "X1.2",
|
||||
"item_type": "clause",
|
||||
"title": "Obligation de transmission",
|
||||
"sort_order": 21,
|
||||
"section_tag": "annexe",
|
||||
"inertia_preset": "low",
|
||||
"current_text": (
|
||||
"Tout logiciel G1 permettant la certification doit transmettre "
|
||||
"le present acte d'engagement au certifie et en afficher les "
|
||||
"parametres du bloc 0 de la blockchain Duniter."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "X1.3",
|
||||
"item_type": "clause",
|
||||
"title": "Logiciel auditable",
|
||||
"sort_order": 22,
|
||||
"section_tag": "annexe",
|
||||
"inertia_preset": "low",
|
||||
"current_text": (
|
||||
"Tout logiciel utilise pour la certification ou la creation "
|
||||
"monetaire doit etre publie sous licence libre, afin de "
|
||||
"permettre son audit par la communaute."
|
||||
),
|
||||
},
|
||||
# ===================================================================
|
||||
# ANNEXE 2 : QUESTIONS A LA CERTIFICATION (checklist logicielle)
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "X2",
|
||||
"item_type": "section",
|
||||
"title": "Annexe 2 : Questions a la certification",
|
||||
"sort_order": 23,
|
||||
"section_tag": "annexe",
|
||||
"inertia_preset": "low",
|
||||
"current_text": (
|
||||
"Liste de questions a presenter dans les logiciels de certification. "
|
||||
"Inspiree de la checklist de la Charte 1.0 et des discussions "
|
||||
"forum (topic 32412)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "X2.1",
|
||||
"item_type": "verification",
|
||||
"title": "Questions piege (reponse attendue : NON)",
|
||||
"sort_order": 24,
|
||||
"section_tag": "annexe",
|
||||
"inertia_preset": "low",
|
||||
"current_text": (
|
||||
"**Si OUI a l'une de ces questions, la certification doit etre refusee.**\n\n"
|
||||
"- La personne m'a contacte uniquement pour obtenir ma certification\n"
|
||||
"- Je certifie sous la pression ou pour faire plaisir\n"
|
||||
"- Je n'ai aucun moyen de verifier l'identite de la personne\n"
|
||||
"- La personne refuse de me donner plusieurs moyens de contact\n"
|
||||
"- Je soupçonne que la personne possede deja un compte membre\n"
|
||||
"- Je ne connais aucun de ses autres certifieurs\n"
|
||||
"- La personne ne comprend pas ce qu'est la monnaie libre\n"
|
||||
"- La personne n'a pas lu le present acte d'engagement"
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "X2.2",
|
||||
"item_type": "verification",
|
||||
"title": "Questions identite (reponse attendue : OUI)",
|
||||
"sort_order": 25,
|
||||
"section_tag": "annexe",
|
||||
"inertia_preset": "low",
|
||||
"current_text": (
|
||||
"**Si NON a l'une de ces questions, la certification doit etre refusee.**\n\n"
|
||||
"- Je connais cette personne suffisamment pour la recontacter\n"
|
||||
"- J'ai verifie personnellement le lien personne / cle publique\n"
|
||||
"- La personne est une personne physique vivante (pas une entite morale)\n"
|
||||
"- Je pourrais reconnaitre cette personne si je la croisais\n"
|
||||
"- J'ai au moins 2 moyens de contact differents pour cette personne"
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "X2.3",
|
||||
"item_type": "verification",
|
||||
"title": "Questions securite (reponse attendue : OUI)",
|
||||
"sort_order": 26,
|
||||
"section_tag": "annexe",
|
||||
"inertia_preset": "low",
|
||||
"current_text": (
|
||||
"**Si NON, la certification est deconseille.**\n\n"
|
||||
"- La personne a genere son document de revocation\n"
|
||||
"- La personne maitrise effectivement son compte "
|
||||
"(test de transfert effectue)\n"
|
||||
"- La personne sait ou retrouver le present acte d'engagement "
|
||||
"dans sa version a jour"
|
||||
),
|
||||
},
|
||||
# ===================================================================
|
||||
# SECTION SPECIALE : FORMULE DE VOTE
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "F1",
|
||||
"item_type": "section",
|
||||
"title": "Formule de vote",
|
||||
"sort_order": 27,
|
||||
"section_tag": "formule",
|
||||
"inertia_preset": "high",
|
||||
"current_text": (
|
||||
"La formule qui regit l'adoption ou le rejet de chaque modification "
|
||||
"du present document. Reference : g1vote "
|
||||
"(https://g1vote-view-237903.pages.duniter.org/)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "F1.1",
|
||||
"item_type": "rule",
|
||||
"title": "Formule du seuil WoT",
|
||||
"sort_order": 28,
|
||||
"section_tag": "formule",
|
||||
"inertia_preset": "high",
|
||||
"current_text": (
|
||||
"**Seuil = C + B^W + (M + (1-M) × (1 - (T/W)^G)) × max(0, T-C)**\n\n"
|
||||
"Ou :\n"
|
||||
"- **C** = constante de base (plancher fixe)\n"
|
||||
"- **B** = exposant de base (B^W tend vers 0)\n"
|
||||
"- **W** = taille de la toile de confiance (electeurs eligibles)\n"
|
||||
"- **T** = total des votes exprimes (pour + contre)\n"
|
||||
"- **M** = ratio de majorite cible (M = majority_pct / 100)\n"
|
||||
"- **G** = exposant du gradient d'inertie\n\n"
|
||||
"**Comportement :** Faible participation → quasi-unanimite requise. "
|
||||
"Forte participation → majorite simple M suffit."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "F1.2",
|
||||
"item_type": "rule",
|
||||
"title": "Parametres par defaut",
|
||||
"sort_order": 29,
|
||||
"section_tag": "formule",
|
||||
"inertia_preset": "high",
|
||||
"current_text": (
|
||||
"Parametres de reference pour les engagements (inertie standard) :\n\n"
|
||||
"| Parametre | Code | Valeur |\n"
|
||||
"|-----------|------|--------|\n"
|
||||
"| Duree | D | 30 jours (permanent) |\n"
|
||||
"| Majorite | M | 50% |\n"
|
||||
"| Base | B | 0.1 |\n"
|
||||
"| Gradient | G | 0.2 |\n"
|
||||
"| Constante | C | 0.0 |\n\n"
|
||||
"Mode compact : **D30M50B.1G.2**"
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "F1.3",
|
||||
"item_type": "rule",
|
||||
"title": "Processus de depot officiel",
|
||||
"sort_order": 30,
|
||||
"section_tag": "formule",
|
||||
"inertia_preset": "high",
|
||||
"current_text": (
|
||||
"Lorsqu'une proposition alternative atteint le seuil d'adoption :\n\n"
|
||||
"1. Le texte de remplacement est integre au document officiel\n"
|
||||
"2. Le hash IPFS du document mis a jour est calcule\n"
|
||||
"3. Le hash est ancre on-chain via `system.remark`\n"
|
||||
"4. Le depot git officiel est synchronise\n"
|
||||
"5. Les applications (Cesium, Gecko) mettent a jour automatiquement"
|
||||
),
|
||||
},
|
||||
# ===================================================================
|
||||
# SECTION SPECIALE : REGLAGE DE L'INERTIE
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "N1",
|
||||
"item_type": "section",
|
||||
"title": "Reglage de l'inertie",
|
||||
"sort_order": 31,
|
||||
"section_tag": "inertie",
|
||||
"inertia_preset": "very_high",
|
||||
"current_text": (
|
||||
"Le reglage de l'inertie definit la difficulte de remplacement "
|
||||
"de chaque section du document. Ce reglage est lui-meme soumis "
|
||||
"a l'inertie la plus elevee, pour empecher la modification "
|
||||
"des regles de modification."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "N1.1",
|
||||
"item_type": "rule",
|
||||
"title": "Niveaux d'inertie",
|
||||
"sort_order": 32,
|
||||
"section_tag": "inertie",
|
||||
"inertia_preset": "very_high",
|
||||
"current_text": (
|
||||
"| Niveau | Gradient G | Majorite M | Application |\n"
|
||||
"|--------|-----------|------------|-------------|\n"
|
||||
"| **Basse** | 0.1 | 50% | Annexes, recommandations |\n"
|
||||
"| **Standard** | 0.2 | 50% | Engagements fondamentaux et techniques |\n"
|
||||
"| **Haute** | 0.4 | 60% | Formule de vote |\n"
|
||||
"| **Tres haute** | 0.6 | 66% | Reglage de l'inertie |\n\n"
|
||||
"Plus le gradient est eleve, plus il faut de participation "
|
||||
"et de consensus pour qu'une modification soit adoptee."
|
||||
),
|
||||
},
|
||||
# ===================================================================
|
||||
# SECTION SPECIALE : ORDONNANCEMENT
|
||||
# ===================================================================
|
||||
{
|
||||
"position": "O1",
|
||||
"item_type": "section",
|
||||
"title": "Ordonnancement du document",
|
||||
"sort_order": 33,
|
||||
"section_tag": "ordonnancement",
|
||||
"inertia_preset": "high",
|
||||
"current_text": (
|
||||
"L'ordre de presentation des items dans le document est "
|
||||
"lui-meme soumis au vote. Toute proposition de reorganisation "
|
||||
"doit atteindre le seuil d'adoption avec l'inertie haute."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_document_engagement_certification(session: AsyncSession) -> Document:
|
||||
async def seed_document_engagement_certification(
|
||||
session: AsyncSession,
|
||||
protocols: dict[str, VotingProtocol],
|
||||
) -> Document:
|
||||
genesis = json.dumps(GENESIS_CERTIFICATION, ensure_ascii=False, indent=2)
|
||||
|
||||
doc, created = await get_or_create(
|
||||
session,
|
||||
Document,
|
||||
@@ -286,15 +816,33 @@ async def seed_document_engagement_certification(session: AsyncSession) -> Docum
|
||||
version="1.0.0",
|
||||
status="active",
|
||||
description=(
|
||||
"Obligations des certificateurs de la toile de confiance Duniter. "
|
||||
"Chaque clause est soumise au vote permanent de la communaute."
|
||||
"Acte d'engagement des certificateurs de la toile de confiance G1. "
|
||||
"Document modulaire sous vote permanent : chaque item peut etre "
|
||||
"remplace par une alternative qui atteint le seuil d'adoption. "
|
||||
"Construit a partir de la Licence G1 v0.3.0, des discussions "
|
||||
"communautaires et de la position 'Acte d'engagement' (Yvv)."
|
||||
),
|
||||
genesis_json=genesis,
|
||||
)
|
||||
print(f" Document 'Acte d'engagement certification': {'created' if created else 'exists'}")
|
||||
|
||||
if created:
|
||||
# Map inertia presets to voting protocols
|
||||
inertia_protocol_map = {
|
||||
"low": protocols.get("Vote WoT standard"),
|
||||
"standard": protocols.get("Vote WoT standard"),
|
||||
"high": protocols.get("Vote WoT standard"),
|
||||
"very_high": protocols.get("Vote WoT standard"),
|
||||
}
|
||||
|
||||
for item_data in ENGAGEMENT_CERTIFICATION_ITEMS:
|
||||
item = DocumentItem(document_id=doc.id, **item_data)
|
||||
preset = item_data.get("inertia_preset", "standard")
|
||||
protocol = inertia_protocol_map.get(preset)
|
||||
item = DocumentItem(
|
||||
document_id=doc.id,
|
||||
voting_protocol_id=protocol.id if protocol else None,
|
||||
**item_data,
|
||||
)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
print(f" -> {len(ENGAGEMENT_CERTIFICATION_ITEMS)} items created")
|
||||
@@ -903,7 +1451,7 @@ async def run_seed():
|
||||
protocols = await seed_voting_protocols(session, formulas)
|
||||
|
||||
print("\n[3/7] Document: Acte d'engagement certification...")
|
||||
await seed_document_engagement_certification(session)
|
||||
await seed_document_engagement_certification(session, protocols)
|
||||
|
||||
print("\n[4/7] Document: Acte d'engagement forgeron v2.0.0...")
|
||||
doc_forgeron = await seed_document_engagement_forgeron(session)
|
||||
|
||||
127
frontend/app/components/documents/DocumentTuto.vue
Normal file
127
frontend/app/components/documents/DocumentTuto.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* DocumentTuto — Quick tutorial overlay explaining how the document works.
|
||||
* Shows how permanent voting, inertia, counter-proposals, and thresholds work.
|
||||
*/
|
||||
const open = ref(false)
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: 'i-lucide-infinity',
|
||||
title: 'Vote permanent',
|
||||
text: 'Chaque engagement est sous vote permanent. A tout moment, vous pouvez proposer une alternative ou voter pour/contre le texte en vigueur.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-sliders-horizontal',
|
||||
title: 'Inertie variable',
|
||||
text: 'Les engagements fondamentaux ont une inertie standard (difficulte de remplacement moderee). Les annexes sont plus faciles a modifier. La formule et ses reglages sont tres proteges.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-scale',
|
||||
title: 'Seuil adaptatif',
|
||||
text: 'La formule WoT adapte le seuil a la participation : peu de votants = quasi-unanimite requise ; beaucoup de votants = majorite simple suffit.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-pen-line',
|
||||
title: 'Contre-propositions',
|
||||
text: 'Cliquez sur "Proposer une alternative" pour soumettre un texte de remplacement. Il sera soumis au vote et devra atteindre le seuil d\'adoption pour remplacer le texte en vigueur.',
|
||||
},
|
||||
{
|
||||
icon: 'i-lucide-git-branch',
|
||||
title: 'Depot automatique',
|
||||
text: 'Quand une alternative est adoptee, le document officiel est mis a jour, ancre sur IPFS et on-chain, puis deploye dans les applications (Cesium, Gecko).',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UButton
|
||||
label="Comment ca marche ?"
|
||||
icon="i-lucide-circle-help"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
@click="open = true"
|
||||
/>
|
||||
|
||||
<UModal v-model:open="open" :ui="{ content: 'max-w-lg' }">
|
||||
<template #content>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-bold" style="color: var(--mood-text)">
|
||||
Comment ca marche ?
|
||||
</h2>
|
||||
<UButton
|
||||
icon="i-lucide-x"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="xs"
|
||||
@click="open = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="idx"
|
||||
class="tuto-step"
|
||||
>
|
||||
<div class="tuto-step__icon">
|
||||
<UIcon :name="step.icon" class="text-base" />
|
||||
</div>
|
||||
<div class="tuto-step__content">
|
||||
<h4 class="text-sm font-bold" style="color: var(--mood-text)">
|
||||
{{ step.title }}
|
||||
</h4>
|
||||
<p class="text-xs leading-relaxed" style="color: var(--mood-text-muted)">
|
||||
{{ step.text }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 pt-4 border-t" style="border-color: color-mix(in srgb, var(--mood-text) 8%, transparent)">
|
||||
<p class="text-xs text-center" style="color: var(--mood-text-muted)">
|
||||
Reference : formule g1vote —
|
||||
<a
|
||||
href="https://g1vote-view-237903.pages.duniter.org/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
style="color: var(--mood-accent)"
|
||||
>
|
||||
g1vote-view
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tuto-step {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tuto-step__icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
||||
color: var(--mood-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tuto-step__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
252
frontend/app/components/documents/EngagementCard.vue
Normal file
252
frontend/app/components/documents/EngagementCard.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* EngagementCard — Enhanced item card with inline mini vote board,
|
||||
* inertia indicator, and action buttons.
|
||||
*
|
||||
* Replaces the basic ItemCard for the document detail view.
|
||||
*/
|
||||
import type { DocumentItem } from '~/stores/documents'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
item: DocumentItem
|
||||
documentSlug: string
|
||||
showActions?: boolean
|
||||
showVoteBoard?: boolean
|
||||
}>(), {
|
||||
showActions: false,
|
||||
showVoteBoard: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
propose: [item: DocumentItem]
|
||||
}>()
|
||||
|
||||
const isSection = computed(() => props.item.item_type === 'section')
|
||||
const isPreamble = computed(() => props.item.item_type === 'preamble')
|
||||
|
||||
const itemTypeIcon = computed(() => {
|
||||
switch (props.item.item_type) {
|
||||
case 'clause': return 'i-lucide-shield-check'
|
||||
case 'rule': return 'i-lucide-scale'
|
||||
case 'verification': return 'i-lucide-check-circle'
|
||||
case 'preamble': return 'i-lucide-scroll-text'
|
||||
case 'section': return 'i-lucide-layout-list'
|
||||
default: return 'i-lucide-file-text'
|
||||
}
|
||||
})
|
||||
|
||||
const itemTypeLabel = computed(() => {
|
||||
switch (props.item.item_type) {
|
||||
case 'clause': return 'Engagement'
|
||||
case 'rule': return 'Regle'
|
||||
case 'verification': return 'Verification'
|
||||
case 'preamble': return 'Preambule'
|
||||
case 'section': return 'Titre'
|
||||
default: return props.item.item_type
|
||||
}
|
||||
})
|
||||
|
||||
function navigateToItem() {
|
||||
navigateTo(`/documents/${props.documentSlug}/items/${props.item.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Section header (visual separator, not a card) -->
|
||||
<div v-if="isSection" class="engagement-section">
|
||||
<div class="engagement-section__line" />
|
||||
<div class="engagement-section__content">
|
||||
<h3 class="engagement-section__title">
|
||||
{{ item.title }}
|
||||
</h3>
|
||||
<p class="engagement-section__text">
|
||||
{{ item.current_text }}
|
||||
</p>
|
||||
<InertiaSlider
|
||||
:preset="item.inertia_preset"
|
||||
compact
|
||||
class="mt-2 max-w-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular item card -->
|
||||
<div
|
||||
v-else
|
||||
class="engagement-card"
|
||||
:class="{
|
||||
'engagement-card--preamble': isPreamble,
|
||||
}"
|
||||
>
|
||||
<!-- Card header -->
|
||||
<div class="engagement-card__header" @click="navigateToItem">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<div class="engagement-card__position">
|
||||
{{ item.position }}
|
||||
</div>
|
||||
<UIcon :name="itemTypeIcon" class="text-sm shrink-0" style="color: var(--mood-accent)" />
|
||||
<span v-if="item.title" class="engagement-card__title">
|
||||
{{ item.title }}
|
||||
</span>
|
||||
<UBadge variant="subtle" color="neutral" size="xs" class="shrink-0">
|
||||
{{ itemTypeLabel }}
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item text -->
|
||||
<div class="engagement-card__body" @click="navigateToItem">
|
||||
<MarkdownRenderer :content="item.current_text" />
|
||||
</div>
|
||||
|
||||
<!-- Mini vote board -->
|
||||
<div v-if="showVoteBoard" class="engagement-card__vote">
|
||||
<MiniVoteBoard
|
||||
:votes-for="10"
|
||||
:votes-against="1"
|
||||
:wot-size="7224"
|
||||
:is-permanent="item.is_permanent_vote"
|
||||
:inertia-preset="item.inertia_preset"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Inertia indicator -->
|
||||
<div class="engagement-card__inertia">
|
||||
<InertiaSlider
|
||||
:preset="item.inertia_preset"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div v-if="showActions" class="engagement-card__actions">
|
||||
<UButton
|
||||
label="Proposer une alternative"
|
||||
icon="i-lucide-pen-line"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="xs"
|
||||
@click.stop="emit('propose', item)"
|
||||
/>
|
||||
<UButton
|
||||
label="Voter"
|
||||
icon="i-lucide-vote"
|
||||
variant="soft"
|
||||
color="success"
|
||||
size="xs"
|
||||
@click.stop="navigateToItem"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Section separator */
|
||||
.engagement-section {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.engagement-section__line {
|
||||
width: 4px;
|
||||
background: var(--mood-accent);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engagement-section__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.engagement-section__title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.engagement-section__text {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.engagement-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.engagement-card:hover {
|
||||
box-shadow: 0 2px 12px color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.engagement-card--preamble {
|
||||
border-left: 4px solid color-mix(in srgb, var(--mood-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.engagement-card__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.engagement-card__position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 1.625rem;
|
||||
padding: 0 0.5rem;
|
||||
border-radius: 8px;
|
||||
background: var(--mood-accent);
|
||||
color: white;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engagement-card__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.engagement-card__body {
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.engagement-card__vote {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.engagement-card__inertia {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.engagement-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--mood-text) 6%, transparent);
|
||||
}
|
||||
</style>
|
||||
334
frontend/app/components/documents/GenesisBlock.vue
Normal file
334
frontend/app/components/documents/GenesisBlock.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Genesis block: displays source documents, repos, forum synthesis, and formula trigger
|
||||
* for a reference document. Collapsible by default.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
genesisJson: string
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
|
||||
interface GenesisData {
|
||||
source_document: {
|
||||
title: string
|
||||
url: string
|
||||
repo: string
|
||||
}
|
||||
reference_tools: Record<string, string>
|
||||
forum_synthesis: Array<{
|
||||
title: string
|
||||
url: string
|
||||
status: string
|
||||
posts?: number
|
||||
}>
|
||||
formula_trigger: string
|
||||
contributors: Array<{
|
||||
name: string
|
||||
role: string
|
||||
}>
|
||||
}
|
||||
|
||||
const genesis = computed((): GenesisData | null => {
|
||||
try {
|
||||
return JSON.parse(props.genesisJson)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rejected': return 'error'
|
||||
case 'in_progress': return 'warning'
|
||||
case 'reference': return 'info'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'rejected': return 'Rejetee'
|
||||
case 'in_progress': return 'En cours'
|
||||
case 'reference': return 'Reference'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="genesis" class="genesis-block">
|
||||
<!-- Header (always visible) -->
|
||||
<button
|
||||
class="genesis-block__header"
|
||||
@click="expanded = !expanded"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="genesis-block__icon">
|
||||
<UIcon name="i-lucide-file-archive" class="text-lg" />
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<h3 class="text-sm font-bold uppercase tracking-wide" style="color: var(--mood-accent)">
|
||||
Bloc de genese
|
||||
</h3>
|
||||
<p class="text-xs" style="color: var(--mood-text-muted)">
|
||||
Sources, references et formule de depot
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<UIcon
|
||||
:name="expanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="text-lg"
|
||||
style="color: var(--mood-text-muted)"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Expandable content -->
|
||||
<Transition name="genesis-expand">
|
||||
<div v-if="expanded" class="genesis-block__body">
|
||||
<!-- Source document -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-file-text" />
|
||||
Document source
|
||||
</h4>
|
||||
<div class="genesis-card">
|
||||
<p class="font-medium text-sm" style="color: var(--mood-text)">
|
||||
{{ genesis.source_document.title }}
|
||||
</p>
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<a
|
||||
:href="genesis.source_document.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-link"
|
||||
>
|
||||
<UIcon name="i-lucide-external-link" class="text-xs" />
|
||||
Texte officiel
|
||||
</a>
|
||||
<a
|
||||
:href="genesis.source_document.repo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-link"
|
||||
>
|
||||
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
||||
Depot git
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reference tools -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-wrench" />
|
||||
Outils de reference
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a
|
||||
v-for="(url, name) in genesis.reference_tools"
|
||||
:key="name"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-card genesis-card--tool"
|
||||
>
|
||||
<span class="text-xs font-semibold capitalize" style="color: var(--mood-text)">
|
||||
{{ name.replace(/_/g, ' ') }}
|
||||
</span>
|
||||
<UIcon name="i-lucide-external-link" class="text-xs" style="color: var(--mood-text-muted)" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Forum synthesis -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-messages-square" />
|
||||
Synthese des discussions
|
||||
</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<a
|
||||
v-for="topic in genesis.forum_synthesis"
|
||||
:key="topic.url"
|
||||
:href="topic.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="genesis-card genesis-card--forum"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-xs font-medium" style="color: var(--mood-text)">
|
||||
{{ topic.title }}
|
||||
</span>
|
||||
<UBadge
|
||||
:color="statusColor(topic.status)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
class="shrink-0"
|
||||
>
|
||||
{{ statusLabel(topic.status) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<span v-if="topic.posts" class="text-xs" style="color: var(--mood-text-muted)">
|
||||
{{ topic.posts }} messages
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula trigger -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-zap" />
|
||||
Processus de depot
|
||||
</h4>
|
||||
<div class="genesis-card">
|
||||
<p class="text-xs leading-relaxed" style="color: var(--mood-text)">
|
||||
{{ genesis.formula_trigger }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contributors -->
|
||||
<div class="genesis-section">
|
||||
<h4 class="genesis-section__title">
|
||||
<UIcon name="i-lucide-users" />
|
||||
Contributeurs
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="c in genesis.contributors"
|
||||
:key="c.name"
|
||||
class="genesis-contributor"
|
||||
>
|
||||
<span class="font-semibold text-xs" style="color: var(--mood-text)">{{ c.name }}</span>
|
||||
<span class="text-xs" style="color: var(--mood-text-muted)">{{ c.role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.genesis-block {
|
||||
background: var(--mood-surface);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.genesis-block__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-block__header:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
||||
}
|
||||
|
||||
.genesis-block__icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.genesis-block__body {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.genesis-section__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--mood-accent);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.genesis-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.genesis-card--tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-card--tool:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.genesis-card--forum {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.genesis-card--forum:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.genesis-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.genesis-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.genesis-contributor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 4%, var(--mood-bg));
|
||||
}
|
||||
|
||||
/* Expand/collapse transition */
|
||||
.genesis-expand-enter-active,
|
||||
.genesis-expand-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.genesis-expand-enter-from,
|
||||
.genesis-expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
192
frontend/app/components/documents/InertiaSlider.vue
Normal file
192
frontend/app/components/documents/InertiaSlider.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Inertia slider — displays the inertia preset level for a section.
|
||||
* Read-only indicator (voting on the preset uses the standard vote flow).
|
||||
* Shows the formula parameters underneath.
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
preset: string
|
||||
compact?: boolean
|
||||
}>(), {
|
||||
compact: false,
|
||||
})
|
||||
|
||||
interface InertiaLevel {
|
||||
label: string
|
||||
gradient: number
|
||||
majority: number
|
||||
color: string
|
||||
position: number // 0-100 for slider position
|
||||
description: string
|
||||
}
|
||||
|
||||
const LEVELS: Record<string, InertiaLevel> = {
|
||||
low: {
|
||||
label: 'Basse',
|
||||
gradient: 0.1,
|
||||
majority: 50,
|
||||
color: '#22c55e',
|
||||
position: 10,
|
||||
description: 'Facile a remplacer',
|
||||
},
|
||||
standard: {
|
||||
label: 'Standard',
|
||||
gradient: 0.2,
|
||||
majority: 50,
|
||||
color: '#3b82f6',
|
||||
position: 37,
|
||||
description: 'Equilibre participation/consensus',
|
||||
},
|
||||
high: {
|
||||
label: 'Haute',
|
||||
gradient: 0.4,
|
||||
majority: 60,
|
||||
color: '#f59e0b',
|
||||
position: 63,
|
||||
description: 'Forte mobilisation requise',
|
||||
},
|
||||
very_high: {
|
||||
label: 'Tres haute',
|
||||
gradient: 0.6,
|
||||
majority: 66,
|
||||
color: '#ef4444',
|
||||
position: 90,
|
||||
description: 'Quasi-unanimite requise',
|
||||
},
|
||||
}
|
||||
|
||||
const level = computed((): InertiaLevel => LEVELS[props.preset] ?? LEVELS.standard!)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="inertia" :class="{ 'inertia--compact': compact }">
|
||||
<!-- Slider track -->
|
||||
<div class="inertia__track">
|
||||
<div class="inertia__fill" :style="{ width: `${level.position}%`, background: level.color }" />
|
||||
<div
|
||||
class="inertia__thumb"
|
||||
:style="{ left: `${level.position}%`, borderColor: level.color }"
|
||||
/>
|
||||
<!-- Level marks -->
|
||||
<div
|
||||
v-for="(lvl, key) in LEVELS"
|
||||
:key="key"
|
||||
class="inertia__mark"
|
||||
:class="{ 'inertia__mark--active': key === preset }"
|
||||
:style="{ left: `${lvl.position}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Label row -->
|
||||
<div class="inertia__info">
|
||||
<span class="inertia__label" :style="{ color: level.color }">
|
||||
{{ level.label }}
|
||||
</span>
|
||||
<span v-if="!compact" class="inertia__params">
|
||||
G={{ level.gradient }} M={{ level.majority }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Description (not in compact mode) -->
|
||||
<p v-if="!compact" class="inertia__desc">
|
||||
{{ level.description }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inertia {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.inertia--compact {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.inertia__track {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.inertia--compact .inertia__track {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
.inertia__fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.inertia__fill {
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.inertia__thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--mood-bg);
|
||||
border: 3px solid;
|
||||
transition: left 0.3s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.inertia--compact .inertia__thumb {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.inertia__mark {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--mood-text) 20%, transparent);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.inertia__mark--active {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.inertia__info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.inertia__label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.inertia--compact .inertia__label {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.inertia__params {
|
||||
font-size: 0.625rem;
|
||||
font-family: monospace;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.inertia__desc {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
</style>
|
||||
248
frontend/app/components/documents/MiniVoteBoard.vue
Normal file
248
frontend/app/components/documents/MiniVoteBoard.vue
Normal file
@@ -0,0 +1,248 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* MiniVoteBoard — compact inline vote status for an engagement item.
|
||||
*
|
||||
* Shows: vote bar, counts, threshold, pass/fail, and vote buttons.
|
||||
* Uses mock data when no vote session is linked (dev mode).
|
||||
*/
|
||||
import { useVoteFormula } from '~/composables/useVoteFormula'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
votesFor?: number
|
||||
votesAgainst?: number
|
||||
wotSize?: number
|
||||
isPermanent?: boolean
|
||||
inertiaPreset?: string
|
||||
startsAt?: string | null
|
||||
endsAt?: string | null
|
||||
}>(), {
|
||||
votesFor: 0,
|
||||
votesAgainst: 0,
|
||||
wotSize: 7224,
|
||||
isPermanent: true,
|
||||
inertiaPreset: 'standard',
|
||||
startsAt: null,
|
||||
endsAt: null,
|
||||
})
|
||||
|
||||
const { computeThreshold } = useVoteFormula()
|
||||
|
||||
const INERTIA_PARAMS: Record<string, { majority_pct: number; base_exponent: number; gradient_exponent: number; constant_base: number }> = {
|
||||
low: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.1, constant_base: 0 },
|
||||
standard: { majority_pct: 50, base_exponent: 0.1, gradient_exponent: 0.2, constant_base: 0 },
|
||||
high: { majority_pct: 60, base_exponent: 0.1, gradient_exponent: 0.4, constant_base: 0 },
|
||||
very_high: { majority_pct: 66, base_exponent: 0.1, gradient_exponent: 0.6, constant_base: 0 },
|
||||
}
|
||||
|
||||
const formulaParams = computed(() => INERTIA_PARAMS[props.inertiaPreset] ?? INERTIA_PARAMS.standard!)
|
||||
|
||||
const totalVotes = computed(() => props.votesFor + props.votesAgainst)
|
||||
|
||||
const threshold = computed(() => {
|
||||
if (totalVotes.value === 0) return 1
|
||||
return computeThreshold(props.wotSize, totalVotes.value, formulaParams.value)
|
||||
})
|
||||
|
||||
const isPassing = computed(() => props.votesFor >= threshold.value)
|
||||
|
||||
const forPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesFor / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
const againstPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 0
|
||||
return (props.votesAgainst / totalVotes.value) * 100
|
||||
})
|
||||
|
||||
const thresholdPct = computed(() => {
|
||||
if (totalVotes.value === 0) return 50
|
||||
return Math.min((threshold.value / totalVotes.value) * 100, 100)
|
||||
})
|
||||
|
||||
const participationRate = computed(() => {
|
||||
if (props.wotSize === 0) return 0
|
||||
return (totalVotes.value / props.wotSize) * 100
|
||||
})
|
||||
|
||||
const remaining = computed(() => {
|
||||
const diff = threshold.value - props.votesFor
|
||||
return diff > 0 ? diff : 0
|
||||
})
|
||||
|
||||
function formatDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mini-board">
|
||||
<!-- Vote type badge -->
|
||||
<div class="mini-board__header">
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge
|
||||
v-if="isPermanent"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
>
|
||||
<UIcon name="i-lucide-infinity" class="mr-1" />
|
||||
Vote permanent
|
||||
</UBadge>
|
||||
<template v-else>
|
||||
<UBadge color="info" variant="subtle" size="xs">
|
||||
<UIcon name="i-lucide-clock" class="mr-1" />
|
||||
Vote temporaire
|
||||
</UBadge>
|
||||
<span v-if="startsAt && endsAt" class="text-xs" style="color: var(--mood-text-muted)">
|
||||
{{ formatDate(startsAt) }} - {{ formatDate(endsAt) }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<UBadge
|
||||
:color="isPassing ? 'success' : 'neutral'"
|
||||
:variant="isPassing ? 'solid' : 'subtle'"
|
||||
size="xs"
|
||||
>
|
||||
{{ isPassing ? 'Adopte' : 'En attente' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="mini-board__bar">
|
||||
<div
|
||||
class="mini-board__bar-for"
|
||||
:style="{ width: `${forPct}%` }"
|
||||
/>
|
||||
<div
|
||||
class="mini-board__bar-against"
|
||||
:style="{ left: `${forPct}%`, width: `${againstPct}%` }"
|
||||
/>
|
||||
<!-- Threshold marker -->
|
||||
<div
|
||||
v-if="totalVotes > 0"
|
||||
class="mini-board__bar-threshold"
|
||||
:style="{ left: `${thresholdPct}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats row -->
|
||||
<div class="mini-board__stats">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="mini-board__stat mini-board__stat--for">
|
||||
{{ votesFor }} pour
|
||||
</span>
|
||||
<span class="mini-board__stat mini-board__stat--against">
|
||||
{{ votesAgainst }} contre
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="mini-board__stat">
|
||||
{{ votesFor }}/{{ threshold }} requis
|
||||
</span>
|
||||
<span v-if="remaining > 0" class="mini-board__stat mini-board__stat--remaining">
|
||||
{{ remaining }} manquant{{ remaining > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Participation -->
|
||||
<div class="mini-board__footer">
|
||||
<span class="text-xs" style="color: var(--mood-text-muted)">
|
||||
{{ totalVotes }} vote{{ totalVotes !== 1 ? 's' : '' }} / {{ wotSize }} membres
|
||||
({{ participationRate.toFixed(2) }}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mini-board {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--mood-accent) 3%, var(--mood-bg));
|
||||
}
|
||||
|
||||
.mini-board__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mini-board__bar {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
background: color-mix(in srgb, var(--mood-text) 10%, transparent);
|
||||
border-radius: 3px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.mini-board__bar-for {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
right: auto;
|
||||
background: #22c55e;
|
||||
border-radius: 3px 0 0 3px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.mini-board__bar-against {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
right: auto;
|
||||
background: #ef4444;
|
||||
transition: width 0.4s ease, left 0.4s ease;
|
||||
}
|
||||
|
||||
.mini-board__bar-threshold {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
width: 2px;
|
||||
background: #facc15;
|
||||
border-radius: 1px;
|
||||
transform: translateX(-50%);
|
||||
transition: left 0.4s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.mini-board__stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.mini-board__stat {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.mini-board__stat--for {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.mini-board__stat--against {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.mini-board__stat--remaining {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.mini-board__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Document detail page — full structured view with:
|
||||
* - Genesis block (source files, repos, forum synthesis, formula trigger)
|
||||
* - Sectioned items grouped by section_tag
|
||||
* - Mini vote boards per item
|
||||
* - Inertia sliders per section
|
||||
* - Permanent vote signage
|
||||
* - Tuto overlay
|
||||
*/
|
||||
import type { DocumentItem } from '~/stores/documents'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -6,7 +15,6 @@ const documents = useDocumentsStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
|
||||
const archiving = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -23,6 +31,77 @@ watch(slug, async (newSlug) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Section grouping ──────────────────────────────────────────
|
||||
|
||||
interface Section {
|
||||
tag: string
|
||||
label: string
|
||||
icon: string
|
||||
inertiaPreset: string
|
||||
items: DocumentItem[]
|
||||
}
|
||||
|
||||
const SECTION_META: Record<string, { label: string; icon: string }> = {
|
||||
introduction: { label: 'Introduction', icon: 'i-lucide-scroll-text' },
|
||||
fondamental: { label: 'Engagements fondamentaux', icon: 'i-lucide-shield-check' },
|
||||
technique: { label: 'Engagements techniques', icon: 'i-lucide-wrench' },
|
||||
conclusion: { label: 'Conclusion', icon: 'i-lucide-bookmark' },
|
||||
annexe: { label: 'Annexes', icon: 'i-lucide-paperclip' },
|
||||
formule: { label: 'Formule de vote', icon: 'i-lucide-calculator' },
|
||||
inertie: { label: 'Reglage de l\'inertie', icon: 'i-lucide-sliders-horizontal' },
|
||||
ordonnancement: { label: 'Ordonnancement', icon: 'i-lucide-list-ordered' },
|
||||
}
|
||||
|
||||
const SECTION_ORDER = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement']
|
||||
|
||||
const sections = computed((): Section[] => {
|
||||
const grouped: Record<string, DocumentItem[]> = {}
|
||||
const ungrouped: DocumentItem[] = []
|
||||
|
||||
for (const item of documents.items) {
|
||||
const tag = item.section_tag
|
||||
if (tag) {
|
||||
if (!grouped[tag]) grouped[tag] = []
|
||||
grouped[tag].push(item)
|
||||
} else {
|
||||
ungrouped.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
const result: Section[] = []
|
||||
|
||||
for (const tag of SECTION_ORDER) {
|
||||
if (grouped[tag]) {
|
||||
const meta = SECTION_META[tag] || { label: tag, icon: 'i-lucide-file-text' }
|
||||
const firstItem = grouped[tag][0]
|
||||
result.push({
|
||||
tag,
|
||||
label: meta.label,
|
||||
icon: meta.icon,
|
||||
inertiaPreset: firstItem?.inertia_preset || 'standard',
|
||||
items: grouped[tag],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Ungrouped items
|
||||
if (ungrouped.length > 0) {
|
||||
result.push({
|
||||
tag: '_other',
|
||||
label: 'Autres',
|
||||
icon: 'i-lucide-file-text',
|
||||
inertiaPreset: 'standard',
|
||||
items: ungrouped,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const totalItems = computed(() => documents.items.length)
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
const typeLabel = (docType: string) => {
|
||||
switch (docType) {
|
||||
case 'licence': return 'Licence'
|
||||
@@ -55,12 +134,24 @@ async function archiveToSanctuary() {
|
||||
archiving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Active section (scroll spy) ──────────────────────────────
|
||||
|
||||
const activeSection = ref<string | null>(null)
|
||||
|
||||
function scrollToSection(tag: string) {
|
||||
const el = document.getElementById(`section-${tag}`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
activeSection.value = tag
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="doc-page">
|
||||
<!-- Back link -->
|
||||
<div>
|
||||
<div class="doc-page__nav">
|
||||
<UButton
|
||||
to="/documents"
|
||||
variant="ghost"
|
||||
@@ -94,31 +185,36 @@ async function archiveToSanctuary() {
|
||||
|
||||
<!-- Document detail -->
|
||||
<template v-else-if="documents.current">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<div class="flex items-start justify-between">
|
||||
<!-- ═══ HEADER ═══ -->
|
||||
<div class="doc-page__header">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
<h1 class="doc-page__title">
|
||||
{{ documents.current.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<div class="flex items-center gap-3 mt-2 flex-wrap">
|
||||
<UBadge variant="subtle" color="primary">
|
||||
{{ typeLabel(documents.current.doc_type) }}
|
||||
</UBadge>
|
||||
<StatusBadge :status="documents.current.status" type="document" />
|
||||
<span class="text-sm text-gray-500 font-mono">
|
||||
<StatusBadge :status="documents.current.status" type="document" :clickable="false" />
|
||||
<span class="text-sm font-mono" style="color: var(--mood-text-muted)">
|
||||
v{{ documents.current.version }}
|
||||
</span>
|
||||
<span class="text-sm" style="color: var(--mood-text-muted)">
|
||||
{{ totalItems }} items
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archive button for authenticated users with active documents -->
|
||||
<div v-if="auth.isAuthenticated && documents.current.status === 'active'" class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<DocumentTuto />
|
||||
<UButton
|
||||
label="Archiver dans le Sanctuaire"
|
||||
v-if="auth.isAuthenticated && documents.current.status === 'active'"
|
||||
label="Archiver"
|
||||
icon="i-lucide-archive"
|
||||
color="primary"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
:loading="archiving"
|
||||
@click="archiveToSanctuary"
|
||||
/>
|
||||
@@ -126,71 +222,251 @@ async function archiveToSanctuary() {
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<p v-if="documents.current.description" class="mt-4 text-gray-600 dark:text-gray-400">
|
||||
<p v-if="documents.current.description" class="doc-page__desc">
|
||||
{{ documents.current.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Metadata -->
|
||||
<UCard>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<!-- ═══ METADATA ═══ -->
|
||||
<div class="doc-page__meta">
|
||||
<div class="doc-page__meta-grid">
|
||||
<div>
|
||||
<p class="text-gray-500">Cree le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(documents.current.created_at) }}
|
||||
</p>
|
||||
<p class="doc-page__meta-label">Cree le</p>
|
||||
<p class="doc-page__meta-value">{{ formatDate(documents.current.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Mis a jour le</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDate(documents.current.updated_at) }}
|
||||
</p>
|
||||
<p class="doc-page__meta-label">Mis a jour le</p>
|
||||
<p class="doc-page__meta-value">{{ formatDate(documents.current.updated_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Nombre d'items</p>
|
||||
<p class="font-medium text-gray-900 dark:text-white">
|
||||
{{ documents.current.items_count }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Ancrage IPFS</p>
|
||||
<p class="doc-page__meta-label">Ancrage IPFS</p>
|
||||
<div class="mt-1">
|
||||
<IPFSLink :cid="documents.current.ipfs_cid" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chain anchor info -->
|
||||
<div v-if="documents.current.chain_anchor" class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-gray-500">Ancrage on-chain :</p>
|
||||
<div v-if="documents.current.chain_anchor">
|
||||
<p class="doc-page__meta-label">Ancrage on-chain</p>
|
||||
<ChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Document items -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Contenu du document ({{ documents.items.length }} items)
|
||||
</h2>
|
||||
<!-- ═══ GENESIS BLOCK ═══ -->
|
||||
<GenesisBlock
|
||||
v-if="documents.current.genesis_json"
|
||||
:genesis-json="documents.current.genesis_json"
|
||||
/>
|
||||
|
||||
<div v-if="documents.items.length === 0" class="text-center py-8">
|
||||
<UIcon name="i-lucide-file-plus" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun item dans ce document</p>
|
||||
</div>
|
||||
<!-- ═══ SECTION NAVIGATOR ═══ -->
|
||||
<div v-if="sections.length > 1" class="doc-page__section-nav">
|
||||
<button
|
||||
v-for="section in sections"
|
||||
:key="section.tag"
|
||||
class="doc-page__section-pill"
|
||||
:class="{ 'doc-page__section-pill--active': activeSection === section.tag }"
|
||||
@click="scrollToSection(section.tag)"
|
||||
>
|
||||
<UIcon :name="section.icon" class="text-xs" />
|
||||
{{ section.label }}
|
||||
<span class="doc-page__section-count">{{ section.items.length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<ItemCard
|
||||
v-for="item in documents.items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:document-slug="slug"
|
||||
:show-actions="auth.isAuthenticated"
|
||||
@propose="handlePropose"
|
||||
/>
|
||||
<!-- ═══ SECTIONS WITH ITEMS ═══ -->
|
||||
<div class="doc-page__sections">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.tag"
|
||||
:id="`section-${section.tag}`"
|
||||
class="doc-page__section"
|
||||
>
|
||||
<!-- Section header -->
|
||||
<div class="doc-page__section-header">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon :name="section.icon" style="color: var(--mood-accent)" />
|
||||
<h2 class="doc-page__section-title">
|
||||
{{ section.label }}
|
||||
</h2>
|
||||
<UBadge variant="subtle" color="neutral" size="xs">
|
||||
{{ section.items.length }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<InertiaSlider :preset="section.inertiaPreset" compact class="max-w-48" />
|
||||
</div>
|
||||
|
||||
<!-- Items -->
|
||||
<div class="doc-page__section-items">
|
||||
<EngagementCard
|
||||
v-for="item in section.items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:document-slug="slug"
|
||||
:show-actions="auth.isAuthenticated"
|
||||
@propose="handlePropose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
max-width: 56rem;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.doc-page__nav {
|
||||
margin-bottom: -0.5rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.doc-page__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.doc-page__title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.doc-page__title {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-page__desc {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.6;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Metadata */
|
||||
.doc-page__meta {
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--mood-surface);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.doc-page__meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.doc-page__meta-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.doc-page__meta-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.doc-page__meta-value {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
/* Section navigator */
|
||||
.doc-page__section-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.doc-page__section-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doc-page__section-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: var(--mood-surface);
|
||||
color: var(--mood-text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.doc-page__section-pill:hover {
|
||||
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.doc-page__section-pill--active {
|
||||
background: var(--mood-accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.doc-page__section-count {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.doc-page__sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.doc-page__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
scroll-margin-top: 4rem;
|
||||
}
|
||||
|
||||
.doc-page__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||
}
|
||||
|
||||
.doc-page__section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.doc-page__section-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-page__section-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface DocumentItem {
|
||||
current_text: string
|
||||
voting_protocol_id: string | null
|
||||
sort_order: number
|
||||
section_tag: string | null
|
||||
inertia_preset: string
|
||||
is_permanent_vote: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
@@ -27,6 +30,7 @@ export interface Document {
|
||||
description: string | null
|
||||
ipfs_cid: string | null
|
||||
chain_anchor: string | null
|
||||
genesis_json: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
items_count: number
|
||||
|
||||
Reference in New Issue
Block a user