Refonte design : 4 humeurs, onboarding, sections avec boite a outils

- Systeme de themes adaptatifs : Peps (light chaud), Zen (light calme),
  Chagrine (dark violet), Grave (dark ambre) avec CSS custom properties
- Dashboard d'accueil orienté onboarding avec cartes-portes et teaser
  boite a outils
- SectionLayout reutilisable : liste + sidebar toolbox + status pills
  cliquables (En prepa / En vote / En vigueur / Clos)
- ToolboxVignette : vignettes Contexte / Tutos / Choisir / Demarrer
- Seed : Acte engagement certification + forgeron, Runtime Upgrade
  (decision on-chain), 3 modalites de vote (majoritaire, quadratique,
  permanent)
- Backend adapte SQLite (Uuid portable, 204 fix, pool conditionnel)
- Correction noms composants (pathPrefix: false), pinia/nuxt ^0.11

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 17:44:48 +01:00
parent 403b94fa2c
commit 77dceb49c3
49 changed files with 20628 additions and 1180 deletions

View File

@@ -10,8 +10,8 @@ class Settings(BaseSettings):
ENVIRONMENT: str = "development" # development, staging, production
LOG_LEVEL: str = "INFO"
# Database
DATABASE_URL: str = "postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision"
# Database — SQLite by default for local dev, PostgreSQL for Docker/prod
DATABASE_URL: str = "sqlite+aiosqlite:///./glibredecision.db"
DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10

View File

@@ -3,6 +3,16 @@ from sqlalchemy.orm import DeclarativeBase
from app.config import settings
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
# SQLite doesn't support pool_size/max_overflow/pool_pre_ping/pool_recycle
if _is_sqlite:
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.ENVIRONMENT == "development",
connect_args={"check_same_thread": False},
)
else:
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.ENVIRONMENT == "development",
@@ -11,6 +21,7 @@ engine = create_async_engine(
pool_pre_ping=True,
pool_recycle=3600,
)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy import String, DateTime, JSON, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -11,8 +10,8 @@ from app.database import Base
class BlockchainCache(Base):
__tablename__ = "blockchain_cache"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
cache_key: Mapped[str] = mapped_column(String(256), unique=True, nullable=False, index=True)
cache_value: Mapped[dict] = mapped_column(JSONB, nullable=False)
cache_value: Mapped[dict] = mapped_column(JSON, nullable=False)
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -11,7 +10,7 @@ from app.database import Base
class Decision(Base):
__tablename__ = "decisions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(256), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
context: Mapped[str | None] = mapped_column(Text)
@@ -28,7 +27,7 @@ class Decision(Base):
class DecisionStep(Base):
__tablename__ = "decision_steps"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
decision_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("decisions.id"), nullable=False)
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # qualification, review, vote, execution, reporting

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -11,7 +10,7 @@ from app.database import Base
class Document(Base):
__tablename__ = "documents"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
title: Mapped[str] = mapped_column(String(256), nullable=False)
doc_type: Mapped[str] = mapped_column(String(64), nullable=False) # licence, engagement, reglement, constitution
@@ -29,7 +28,7 @@ class Document(Base):
class DocumentItem(Base):
__tablename__ = "document_items"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
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
@@ -47,7 +46,7 @@ class DocumentItem(Base):
class ItemVersion(Base):
__tablename__ = "item_versions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
item_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("document_items.id"), nullable=False)
proposed_text: Mapped[str] = mapped_column(Text, nullable=False)
diff_text: Mapped[str | None] = mapped_column(Text)

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -11,7 +10,7 @@ from app.database import Base
class Mandate(Base):
__tablename__ = "mandates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
title: Mapped[str] = mapped_column(String(256), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
@@ -29,7 +28,7 @@ class Mandate(Base):
class MandateStep(Base):
__tablename__ = "mandate_steps"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # formulation, candidacy, vote, assignment, reporting, completion, revocation

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Float, Boolean, DateTime, ForeignKey, Text, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -11,7 +10,7 @@ from app.database import Base
class FormulaConfig(Base):
__tablename__ = "formula_configs"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
@@ -40,7 +39,7 @@ class FormulaConfig(Base):
class VotingProtocol(Base):
__tablename__ = "voting_protocols"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text)
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import String, Integer, Text, DateTime, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
@@ -11,9 +10,9 @@ from app.database import Base
class SanctuaryEntry(Base):
__tablename__ = "sanctuary_entries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
entry_type: Mapped[str] = mapped_column(String(64), nullable=False) # document, decision, vote_result
reference_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
reference_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
title: Mapped[str | None] = mapped_column(String(256))
content_hash: Mapped[str] = mapped_column(String(128), nullable=False) # SHA-256
ipfs_cid: Mapped[str | None] = mapped_column(String(128))

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -11,7 +10,7 @@ from app.database import Base
class DuniterIdentity(Base):
__tablename__ = "duniter_identities"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
address: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
display_name: Mapped[str | None] = mapped_column(String(128))
wot_status: Mapped[str] = mapped_column(String(32), default="unknown") # member, pending, revoked, unknown
@@ -26,7 +25,7 @@ class DuniterIdentity(Base):
class Session(Base):
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
identity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("duniter_identities.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,8 +1,7 @@
import uuid
from datetime import datetime
from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
@@ -11,7 +10,7 @@ from app.database import Base
class VoteSession(Base):
__tablename__ = "vote_sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
item_version_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("item_versions.id"))
voting_protocol_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("voting_protocols.id"), nullable=False)
@@ -49,7 +48,7 @@ class VoteSession(Base):
class Vote(Base):
__tablename__ = "votes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
session_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("vote_sessions.id"), nullable=False)
voter_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("duniter_identities.id"), nullable=False)
vote_value: Mapped[str] = mapped_column(String(32), nullable=False) # for, against, or nuanced levels

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import secrets
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -133,11 +133,11 @@ async def get_me(
return IdentityOut.model_validate(identity)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
async def logout(
db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity),
) -> None:
):
"""Invalidate the current session token.
Note: get_current_identity already validated the token, so we know it exists.

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -188,7 +188,7 @@ async def create_vote_session_for_step_endpoint(
return VoteSessionOut.model_validate(session)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
async def delete_decision(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),

View File

@@ -6,7 +6,7 @@ import difflib
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -316,7 +316,7 @@ async def update_item(
return DocumentItemOut.model_validate(item)
@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
async def delete_item(
slug: str,
item_id: uuid.UUID,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
@@ -124,7 +124,7 @@ async def update_mandate(
return MandateOut.model_validate(mandate)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
async def delete_mandate(
id: uuid.UUID,
db: AsyncSession = Depends(get_db),

View File

@@ -3,6 +3,7 @@ uvicorn[standard]==0.34.0
sqlalchemy==2.0.36
alembic==1.14.0
asyncpg==0.30.0
aiosqlite==0.22.1
pydantic==2.10.3
pydantic-settings==2.7.0
python-multipart==0.0.18

View File

@@ -101,39 +101,45 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
# ---------------------------------------------------------------------------
# Seed: VotingProtocols
# Seed: VotingProtocols (premier pack de modalites)
# ---------------------------------------------------------------------------
async def seed_voting_protocols(
session: AsyncSession,
formulas: dict[str, FormulaConfig],
) -> dict[str, VotingProtocol]:
"""Create the 4 base voting protocols."""
"""Create the first pack of voting modalities (3 protocols)."""
protocols: dict[str, dict] = {
"Standard G1": {
"description": "Protocole binaire standard pour la Licence G1.",
"Vote majoritaire": {
"description": (
"Vote binaire a majorite simple. Le seuil d'adoption "
"s'adapte dynamiquement au taux de participation via "
"la formule d'inertie WoT."
),
"vote_type": "binary",
"formula_config_id": formulas["Standard Licence G1"].id,
"mode_params": "D30M50B.1G.2",
},
"Forgeron Smith": {
"description": "Protocole binaire avec critere Smith pour les forgerons.",
"Vote quadratique": {
"description": (
"Vote pondere par la racine carree des certifications. "
"Reduit l'influence des gros certificateurs et favorise "
"une participation large et diversifiee."
),
"vote_type": "binary",
"formula_config_id": formulas["Forgeron avec Smith"].id,
"mode_params": "D30M50B.1G.2S.1",
},
"Comite Tech": {
"description": "Protocole binaire avec critere Comite Technique.",
"Vote permanent": {
"description": (
"Vote continu sans date de fin. Le resultat evolue en "
"temps reel avec chaque nouveau vote. Adapte aux documents "
"de reference sous revision permanente."
),
"vote_type": "binary",
"formula_config_id": formulas["Comite Tech"].id,
"mode_params": "D30M50B.1G.2T.1",
},
"Vote Nuance 6 niveaux": {
"description": "Protocole de vote nuance a 6 niveaux.",
"vote_type": "nuanced",
"formula_config_id": formulas["Vote Nuance"].id,
"mode_params": None,
},
}
result: dict[str, VotingProtocol] = {}
@@ -149,143 +155,134 @@ async def seed_voting_protocols(
# ---------------------------------------------------------------------------
# Seed: Document - Licence G1
# Seed: Document - Acte d'engagement certification
# ---------------------------------------------------------------------------
LICENCE_G1_ITEMS: list[dict] = [
ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
{
"position": "1",
"item_type": "preamble",
"title": "Preambule",
"title": "Objet",
"sort_order": 1,
"current_text": (
"Licence de la monnaie libre et engagement de responsabilite. "
"La monnaie libre G1 (June) est co-produite par ses membres."
"Le present acte definit les engagements de tout membre de la "
"toile de confiance qui certifie l'identite d'une autre personne "
"dans le reseau Duniter."
),
},
{
"position": "2",
"item_type": "section",
"title": "Avertissement TdC",
"item_type": "clause",
"title": "Connaissance personnelle",
"sort_order": 2,
"current_text": (
"Certifier n'est pas uniquement s'assurer de l'identite unique "
"de la personne (son unicite). C'est aussi affirmer que vous la "
"connaissez bien et que vous saurez la joindre facilement."
"Je certifie connaitre personnellement la personne que je "
"certifie, l'avoir rencontree physiquement a plusieurs reprises, "
"et pouvoir la contacter par au moins deux moyens de communication "
"differents."
),
},
{
"position": "3",
"item_type": "clause",
"title": "Conseils",
"title": "Verification d'identite",
"sort_order": 3,
"current_text": (
"Connaitre la personne par plusieurs moyens de communication differents "
"(physique, electronique, etc.). Connaitre son lieu de vie principal. "
"Avoir echange avec elle en utilisant des moyens de communication "
"susceptibles d'identifier un humain vivant."
"Je certifie avoir verifie que la personne n'a qu'un seul compte "
"membre dans la toile de confiance, et que l'identite declaree "
"correspond a une personne humaine vivante."
),
},
{
"position": "4",
"item_type": "verification",
"title": "Verifications",
"item_type": "clause",
"title": "Engagement de suivi",
"sort_order": 4,
"current_text": (
"De suffisamment bien connaitre la personne pour pouvoir la contacter, "
"echanger avec elle. De s'assurer que la personne a bien le controle "
"de son compte Duniter."
"Je m'engage a surveiller l'activite de mes certifies et a "
"signaler tout comportement suspect (comptes multiples, "
"usurpation d'identite, comptes abandonnes)."
),
},
{
"position": "5",
"item_type": "rule",
"title": "Regles TdC",
"item_type": "verification",
"title": "Delai entre certifications",
"sort_order": 5,
"current_text": (
"Chaque membre dispose de 100 certifications possibles. "
"Il est possible de certifier 1 nouveau membre tous les 5 jours. "
"Un membre doit avoir au moins 5 certifications pour devenir membre. "
"Un membre doit renouveler son adhesion tous les 2 ans."
"Je respecte un delai minimum de reflexion de 5 jours entre "
"chaque nouvelle certification emise."
),
},
{
"position": "6",
"item_type": "rule",
"title": "Production DU",
"title": "Renouvellement",
"sort_order": 6,
"current_text": (
"1 DU (Dividende Universel) est produit par personne et par jour. "
"Le DU est la monnaie de base co-produite par chaque membre."
"Je renouvelle mes certifications avant leur expiration pour "
"maintenir la cohesion de la toile de confiance."
),
},
{
"position": "7",
"item_type": "rule",
"title": "Code monetaire",
"item_type": "clause",
"title": "Responsabilite",
"sort_order": 7,
"current_text": (
"DU formule : DU(t+1) = DU(t) + c^2 * M/N. "
"c = 4.88% / an. Le DU est re-evalue chaque equinoxe."
"Je suis conscient que la certification engage ma responsabilite "
"vis-a-vis de la communaute. Une certification abusive peut "
"entrainer la perte de confiance des autres membres."
),
},
{
"position": "8",
"item_type": "clause",
"title": "Logiciels",
"item_type": "rule",
"title": "Revocation",
"sort_order": 8,
"current_text": (
"Les logiciels G1 doivent transmettre cette licence integralement "
"aux utilisateurs et developper un acces libre au code source."
),
},
{
"position": "9",
"item_type": "clause",
"title": "Modification",
"sort_order": 9,
"current_text": (
"Proposants, soutiens et votants doivent etre membres de la TdC. "
"Toute modification de cette licence doit etre soumise au vote "
"des membres selon le protocole en vigueur."
"Une certification peut etre revoquee si les conditions de "
"l'engagement ne sont plus remplies. La revocation est soumise "
"au protocole de vote en vigueur."
),
},
]
async def seed_document_licence_g1(session: AsyncSession) -> Document:
"""Create the Licence G1 document with its items."""
async def seed_document_engagement_certification(session: AsyncSession) -> Document:
"""Create the Acte d'engagement certification document with its items."""
doc, created = await get_or_create(
session,
Document,
"slug",
"licence-g1",
title="Licence G1",
doc_type="licence",
version="0.3.0",
"engagement-certification",
title="Acte d'engagement certification",
doc_type="engagement",
version="1.0.0",
status="active",
description=(
"Licence de la monnaie libre G1 (June). "
"Definit les regles de la toile de confiance et du Dividende Universel."
"Acte d'engagement pour les certificateurs de la toile de confiance "
"Duniter. Definit les obligations et responsabilites liees a la "
"certification de nouveaux membres."
),
)
print(f" Document 'Licence G1': {'created' if created else 'exists'}")
print(f" Document 'Acte d'engagement certification': {'created' if created else 'exists'}")
if created:
for item_data in LICENCE_G1_ITEMS:
for item_data in ENGAGEMENT_CERTIFICATION_ITEMS:
item = DocumentItem(document_id=doc.id, **item_data)
session.add(item)
await session.flush()
print(f" -> {len(LICENCE_G1_ITEMS)} items created")
print(f" -> {len(ENGAGEMENT_CERTIFICATION_ITEMS)} items created")
return doc
# ---------------------------------------------------------------------------
# Seed: Document - Engagement Forgeron v2.0.0
# Seed: Document - Acte d'engagement forgeron v2.0.0
# ---------------------------------------------------------------------------
FORGERON_ITEMS: list[dict] = [
ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
{
"position": "1",
"item_type": "preamble",
@@ -387,36 +384,36 @@ FORGERON_ITEMS: list[dict] = [
]
async def seed_document_forgeron(session: AsyncSession) -> Document:
"""Create the Engagement Forgeron v2.0.0 document with its items."""
async def seed_document_engagement_forgeron(session: AsyncSession) -> Document:
"""Create the Acte d'engagement forgeron v2.0.0 document with its items."""
doc, created = await get_or_create(
session,
Document,
"slug",
"engagement-forgeron",
title="Engagement Forgeron v2.0.0",
title="Acte d'engagement forgeron",
doc_type="engagement",
version="2.0.0",
status="active",
description=(
"Engagement des forgerons (validateurs) pour Duniter V2. "
"Adopte en fevrier 2026 (97 pour / 23 contre)."
"Acte d'engagement des forgerons (validateurs de blocs) pour "
"Duniter V2. Adopte en fevrier 2026 (97 pour / 23 contre)."
),
)
print(f" Document 'Engagement Forgeron v2.0.0': {'created' if created else 'exists'}")
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
if created:
for item_data in FORGERON_ITEMS:
for item_data in ENGAGEMENT_FORGERON_ITEMS:
item = DocumentItem(document_id=doc.id, **item_data)
session.add(item)
await session.flush()
print(f" -> {len(FORGERON_ITEMS)} items created")
print(f" -> {len(ENGAGEMENT_FORGERON_ITEMS)} items created")
return doc
# ---------------------------------------------------------------------------
# Seed: Decision template - Processus Runtime Upgrade
# Seed: Decision template - Runtime Upgrade
# ---------------------------------------------------------------------------
RUNTIME_UPGRADE_STEPS: list[dict] = [
@@ -474,15 +471,16 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
session,
Decision,
"title",
"Processus Runtime Upgrade",
"Runtime Upgrade",
description=(
"Template de decision pour les mises a jour du runtime Duniter V2. "
"5 etapes : qualification, revue, vote, execution, suivi."
"Processus de mise a jour du runtime Duniter V2 on-chain. "
"Chaque upgrade suit un protocole strict en 5 etapes. "
"(Decision on-chain)"
),
decision_type="runtime_upgrade",
status="draft",
)
print(f" Decision 'Processus Runtime Upgrade': {'created' if created else 'exists'}")
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
if created:
for step_data in RUNTIME_UPGRADE_STEPS:
@@ -509,16 +507,16 @@ async def run_seed():
print("\n[1/5] Formula Configs...")
formulas = await seed_formula_configs(session)
print("\n[2/5] Voting Protocols...")
print("\n[2/5] Voting Protocols (premier pack de modalites)...")
await seed_voting_protocols(session, formulas)
print("\n[3/5] Document: Licence G1...")
await seed_document_licence_g1(session)
print("\n[3/5] Document: Acte d'engagement certification...")
await seed_document_engagement_certification(session)
print("\n[4/5] Document: Engagement Forgeron v2.0.0...")
await seed_document_forgeron(session)
print("\n[4/5] Document: Acte d'engagement forgeron...")
await seed_document_engagement_forgeron(session)
print("\n[5/5] Decision: Processus Runtime Upgrade...")
print("\n[5/5] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session)
print("\n" + "=" * 60)

View File

@@ -1,37 +1,73 @@
version: "3.9"
# Dev overrides -- usage:
# docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up
# Development stack -- standalone (no Traefik needed)
# Usage: docker compose -f docker/docker-compose.dev.yml up
# Ports: frontend 3002, backend 8002, postgres 5432, IPFS API 5001, IPFS GW 8080
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-glibredecision}
POSTGRES_USER: ${POSTGRES_USER:-glibredecision}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-glibredecision-dev}
ports:
- "5432:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision}"]
interval: 5s
timeout: 3s
retries: 10
start_period: 10s
backend:
build:
context: ../
dockerfile: docker/backend.Dockerfile
target: development
depends_on:
postgres:
condition: service_healthy
volumes:
- ../backend:/app
ports:
- "8002:8002"
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-glibredecision-dev}@postgres:5432/${POSTGRES_DB:-glibredecision}
SECRET_KEY: dev-secret-key-not-for-production
DEBUG: "true"
ENVIRONMENT: development
CORS_ORIGINS: '["http://localhost:3002"]'
labels: []
DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws}
IPFS_API_URL: http://ipfs:5001
IPFS_GATEWAY_URL: http://ipfs:8080
frontend:
build:
context: ../
dockerfile: docker/frontend.Dockerfile
target: development
depends_on:
- backend
volumes:
- ../frontend:/app
- frontend-node-modules:/app/node_modules
ports:
- "3002:3002"
environment:
NUXT_PUBLIC_API_BASE: http://localhost:8002/api/v1
labels: []
ipfs:
image: ipfs/kubo:latest
restart: unless-stopped
ports:
- "5001:5001"
- "8080:8080"
volumes:
- ipfs-data:/data/ipfs
volumes:
postgres-data:
ipfs-data:
frontend-node-modules:

View File

@@ -42,4 +42,10 @@ FROM base AS development
ENV NODE_ENV=development
WORKDIR /app
ENTRYPOINT ["npm", "run", "dev"]
COPY frontend/package.json ./
RUN npm install
EXPOSE 3002
CMD ["npm", "run", "dev"]

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
const auth = useAuthStore()
const route = useRoute()
const { initMood } = useMood()
const navigationItems = [
{
@@ -43,6 +44,9 @@ const ws = useWebSocket()
const { setupWsNotifications } = useNotifications()
onMounted(async () => {
// Apply saved mood / ambiance
initMood()
// Hydrate auth from localStorage
auth.hydrateFromStorage()
if (auth.token) {
@@ -68,7 +72,13 @@ onUnmounted(() => {
<!-- Offline detection banner -->
<OfflineBanner />
<div class="min-h-screen flex flex-col">
<div
class="min-h-screen flex flex-col"
:style="{
backgroundColor: 'var(--mood-bg)',
color: 'var(--mood-text)',
}"
>
<!-- Header -->
<header class="sticky top-0 z-30 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -92,6 +102,9 @@ onUnmounted(() => {
</NuxtLink>
</div>
<!-- Center: Mood switcher -->
<MoodSwitcher class="hidden sm:flex" />
<!-- Right: Auth controls -->
<div class="flex items-center gap-2 sm:gap-4">
<template v-if="auth.isAuthenticated">

View File

@@ -0,0 +1,167 @@
/* ==========================================================================
Glibredecision — Mood / Ambiance System
4 moods: Peps (light), Zen (light), Chagrine (dark), Grave (dark)
========================================================================== */
/* --------------------------------------------------------------------------
Peps — Energique et chaleureux (Light)
-------------------------------------------------------------------------- */
.mood-peps {
--mood-bg: #ffffff;
--mood-surface: #fffbf5;
--mood-surface-hover: #fff5ea;
--mood-text: #1a1a1a;
--mood-text-muted: #6b6b6b;
--mood-accent: #e85d26;
--mood-accent-soft: #fff3ed;
--mood-accent-text: #ffffff;
--mood-border: #fde8d8;
--mood-success: #22c55e;
--mood-warning: #f59e0b;
--mood-error: #ef4444;
--mood-gradient: linear-gradient(135deg, #fff8f0 0%, #ffffff 100%);
--mood-shadow: rgba(232, 93, 38, 0.08);
--mood-status-prepa: #fed7aa;
--mood-status-prepa-text: #9a3412;
--mood-status-vote: #bfdbfe;
--mood-status-vote-text: #1e40af;
--mood-status-vigueur: #bbf7d0;
--mood-status-vigueur-text: #166534;
--mood-status-clos: #e5e7eb;
--mood-status-clos-text: #374151;
}
/* --------------------------------------------------------------------------
Zen — Calme et serein (Light)
-------------------------------------------------------------------------- */
.mood-zen {
--mood-bg: #f8faf8;
--mood-surface: #ffffff;
--mood-surface-hover: #f0f7f2;
--mood-text: #1a2e1a;
--mood-text-muted: #5f7a5f;
--mood-accent: #4a9e6f;
--mood-accent-soft: #ecf5ef;
--mood-accent-text: #ffffff;
--mood-border: #d4e7d9;
--mood-success: #34d399;
--mood-warning: #fbbf24;
--mood-error: #f87171;
--mood-gradient: linear-gradient(135deg, #f0f7f2 0%, #f8faf8 100%);
--mood-shadow: rgba(74, 158, 111, 0.08);
--mood-status-prepa: #fde68a;
--mood-status-prepa-text: #78350f;
--mood-status-vote: #a7f3d0;
--mood-status-vote-text: #065f46;
--mood-status-vigueur: #bbf7d0;
--mood-status-vigueur-text: #166534;
--mood-status-clos: #d1d5db;
--mood-status-clos-text: #374151;
}
/* --------------------------------------------------------------------------
Chagrine — Profond et subtil (Dark)
-------------------------------------------------------------------------- */
.mood-chagrine {
--mood-bg: #1a1625;
--mood-surface: #231e30;
--mood-surface-hover: #2d2640;
--mood-text: #e8e0f0;
--mood-text-muted: #9b8fb5;
--mood-accent: #9b7fd4;
--mood-accent-soft: #2d2640;
--mood-accent-text: #ffffff;
--mood-border: #342d45;
--mood-success: #6ee7b7;
--mood-warning: #fcd34d;
--mood-error: #fca5a5;
--mood-gradient: linear-gradient(135deg, #1a1625 0%, #231e30 100%);
--mood-shadow: rgba(155, 127, 212, 0.12);
--mood-status-prepa: #4c1d95;
--mood-status-prepa-text: #ddd6fe;
--mood-status-vote: #312e81;
--mood-status-vote-text: #c7d2fe;
--mood-status-vigueur: #064e3b;
--mood-status-vigueur-text: #a7f3d0;
--mood-status-clos: #2d2640;
--mood-status-clos-text: #9b8fb5;
}
/* --------------------------------------------------------------------------
Grave — Serieux et solennel (Dark)
-------------------------------------------------------------------------- */
.mood-grave {
--mood-bg: #141518;
--mood-surface: #1c1d21;
--mood-surface-hover: #262420;
--mood-text: #e5e5e0;
--mood-text-muted: #8a8a85;
--mood-accent: #d4a545;
--mood-accent-soft: #262420;
--mood-accent-text: #141518;
--mood-border: #2a2b30;
--mood-success: #86efac;
--mood-warning: #fde68a;
--mood-error: #fca5a5;
--mood-gradient: linear-gradient(135deg, #141518 0%, #1c1d21 100%);
--mood-shadow: rgba(212, 165, 69, 0.10);
--mood-status-prepa: #78350f;
--mood-status-prepa-text: #fde68a;
--mood-status-vote: #1e3a5f;
--mood-status-vote-text: #93c5fd;
--mood-status-vigueur: #14532d;
--mood-status-vigueur-text: #86efac;
--mood-status-clos: #27272a;
--mood-status-clos-text: #a1a1aa;
}
/* ==========================================================================
Base Utilities
========================================================================== */
/* Transition all mood changes smoothly */
body {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Status labels — clickable pill style */
.status-pill {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
user-select: none;
}
.status-pill:hover {
filter: brightness(0.92);
transform: scale(1.05);
}
.status-pill.active {
ring: 2px;
ring-offset: 2px;
}
.status-prepa {
background: var(--mood-status-prepa);
color: var(--mood-status-prepa-text);
}
.status-vote {
background: var(--mood-status-vote);
color: var(--mood-status-vote-text);
}
.status-vigueur {
background: var(--mood-status-vigueur);
color: var(--mood-status-vigueur-text);
}
.status-clos {
background: var(--mood-status-clos);
color: var(--mood-status-clos-text);
}

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
const { currentMood, moods, setMood } = useMood()
</script>
<template>
<div class="flex items-center gap-1" role="radiogroup" aria-label="Ambiance visuelle">
<UTooltip
v-for="mood in moods"
:key="mood.id"
:text="`${mood.label} — ${mood.description}`"
>
<button
type="button"
role="radio"
:aria-checked="currentMood === mood.id"
:aria-label="`Ambiance ${mood.label}`"
class="mood-btn"
:class="{ 'mood-btn--active': currentMood === mood.id }"
@click="setMood(mood.id)"
>
<UIcon :name="mood.icon" class="text-base" />
</button>
</UTooltip>
</div>
</template>
<style scoped>
.mood-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 9999px;
border: 2px solid transparent;
background: var(--mood-surface, #f3f4f6);
color: var(--mood-text-muted, #6b7280);
cursor: pointer;
transition: all 0.2s ease;
}
.mood-btn:hover {
background: var(--mood-surface-hover, #e5e7eb);
color: var(--mood-accent, #4b5563);
transform: scale(1.1);
}
.mood-btn--active {
border-color: var(--mood-accent, #3b82f6);
color: var(--mood-accent, #3b82f6);
background: var(--mood-accent-soft, #eff6ff);
box-shadow: 0 0 0 2px var(--mood-shadow, rgba(59, 130, 246, 0.15));
}
</style>

View File

@@ -0,0 +1,278 @@
<script setup lang="ts">
/**
* SectionLayout — Mise en page reutilisable pour les sections (documents, decisions, mandats).
*
* Structure : titre + status filter pills en haut,
* puis une grille principale (contenu) + barre laterale "Boite a outils".
* Responsive : sur mobile la boite a outils passe sous le contenu principal.
*/
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
}>(),
{
subtitle: undefined,
toolboxItems: undefined,
activeStatus: null,
},
)
const emit = defineEmits<{
'update:activeStatus': [status: string | null]
}>()
/** Map status id to CSS class for status pills. */
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-layout">
<!-- Header: title + status pills -->
<div class="section-layout__header">
<div class="section-layout__title-block">
<h1 class="section-layout__title">
{{ title }}
</h1>
<p v-if="subtitle" class="section-layout__subtitle">
{{ subtitle }}
</p>
</div>
<div v-if="statuses.length > 0" class="section-layout__status-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="status-pill__count">
{{ status.count }}
</span>
</button>
</div>
</div>
<!-- Main content area: list + toolbox sidebar -->
<div class="section-layout__body">
<!-- Left: search + list -->
<div class="section-layout__main">
<!-- Search / sort bar slot -->
<div v-if="$slots.search" class="section-layout__search">
<slot name="search" />
</div>
<!-- Main list content -->
<div class="section-layout__content">
<slot />
</div>
<!-- Empty state slot -->
<div v-if="$slots.empty" class="section-layout__empty">
<slot name="empty" />
</div>
</div>
<!-- Right: toolbox sidebar -->
<aside class="section-layout__toolbox">
<div class="section-layout__toolbox-header">
<UIcon name="i-lucide-wrench" class="text-sm" />
<span>Boite a outils</span>
</div>
<!-- Custom toolbox slot or default vignettes -->
<div v-if="$slots.toolbox" class="section-layout__toolbox-content">
<slot name="toolbox" />
</div>
<div v-else-if="toolboxItems && toolboxItems.length > 0" class="section-layout__toolbox-content">
<ToolboxVignette
v-for="(item, idx) in toolboxItems"
:key="idx"
:title="item.title"
:description="item.description"
/>
</div>
<div v-else class="section-layout__toolbox-empty">
<p>Aucun outil disponible</p>
</div>
</aside>
</div>
</div>
</template>
<style scoped>
.section-layout {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.section-layout__header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.section-layout__title-block {
flex: 1;
min-width: 0;
}
.section-layout__title {
font-size: 1.5rem;
font-weight: 700;
color: var(--mood-text);
line-height: 1.2;
}
.section-layout__subtitle {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.section-layout__status-pills {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
/* Active state ring for pills */
.status-pill.active {
outline: 2px solid var(--mood-accent);
outline-offset: 2px;
}
.status-pill__count {
margin-left: 0.375rem;
font-size: 0.625rem;
font-weight: 700;
opacity: 0.8;
}
.section-layout__body {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1.5rem;
align-items: start;
}
.section-layout__main {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
.section-layout__search {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
}
.section-layout__content {
min-height: 200px;
}
.section-layout__toolbox {
position: sticky;
top: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.75rem;
padding: 1rem;
}
.section-layout__toolbox-header {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 700;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.section-layout__toolbox-content {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.section-layout__toolbox-empty {
font-size: 0.75rem;
color: var(--mood-text-muted);
text-align: center;
padding: 1rem 0;
}
/* Responsive: on mobile, toolbox goes below */
@media (max-width: 1023px) {
.section-layout__body {
grid-template-columns: 1fr;
}
.section-layout__toolbox {
position: static;
order: 2;
}
}
</style>

View File

@@ -1,61 +1,150 @@
<script setup lang="ts">
const props = defineProps<{
const props = withDefaults(defineProps<{
status: string
type?: 'document' | 'decision' | 'mandate' | 'version' | 'vote'
type?: 'document' | 'decision' | 'mandate' | 'vote' | 'version'
clickable?: boolean
active?: boolean
}>(), {
clickable: true,
active: false,
})
const emit = defineEmits<{
click: []
}>()
const statusConfig: Record<string, Record<string, { color: string; label: string }>> = {
document: {
draft: { color: 'warning', label: 'Brouillon' },
active: { color: 'success', label: 'Actif' },
archived: { color: 'neutral', label: 'Archive' },
},
version: {
proposed: { color: 'info', label: 'Propose' },
voting: { color: 'warning', label: 'En vote' },
accepted: { color: 'success', label: 'Accepte' },
rejected: { color: 'error', label: 'Rejete' },
},
decision: {
draft: { color: 'warning', label: 'Brouillon' },
qualification: { color: 'info', label: 'Qualification' },
review: { color: 'info', label: 'Revue' },
voting: { color: 'primary', label: 'En vote' },
executed: { color: 'success', label: 'Execute' },
closed: { color: 'neutral', label: 'Clos' },
},
mandate: {
draft: { color: 'warning', label: 'Brouillon' },
candidacy: { color: 'info', label: 'Candidature' },
voting: { color: 'primary', label: 'En vote' },
active: { color: 'success', label: 'Actif' },
reporting: { color: 'info', label: 'Rapport' },
completed: { color: 'neutral', label: 'Termine' },
revoked: { color: 'error', label: 'Revoque' },
},
vote: {
open: { color: 'success', label: 'Ouvert' },
closed: { color: 'warning', label: 'Ferme' },
tallied: { color: 'neutral', label: 'Depouille' },
},
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' },
investiture: { label: 'En vote', cssClass: 'status-vote' },
revoked: { label: 'Clos', cssClass: 'status-clos' },
completed: { label: 'Clos', cssClass: 'status-clos' },
}
const resolved = computed(() => {
const typeKey = props.type || 'document'
const typeMap = statusConfig[typeKey]
if (typeMap && typeMap[props.status]) {
return typeMap[props.status]
}
return { color: 'neutral', label: props.status }
return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
})
function handleClick() {
if (props.clickable) {
emit('click')
}
}
</script>
<template>
<UBadge
:color="(resolved.color as any)"
variant="subtle"
size="xs"
<button
v-if="clickable"
type="button"
class="status-pill"
:class="[resolved.cssClass, { 'status-pill--active': active }]"
@click="handleClick"
>
{{ resolved.label }}
</UBadge>
</button>
<span
v-else
class="status-pill"
:class="[resolved.cssClass]"
>
{{ resolved.label }}
</span>
</template>
<style scoped>
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
border: 1px solid transparent;
cursor: default;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
button.status-pill {
cursor: pointer;
}
button.status-pill:hover {
opacity: 0.85;
}
.status-pill--active {
box-shadow: 0 0 0 2px currentColor;
}
/* --- En prepa (amber/warning) --- */
.status-prepa {
background-color: var(--ui-color-amber-50, #fffbeb);
color: var(--ui-color-amber-700, #b45309);
border-color: var(--ui-color-amber-200, #fde68a);
}
/* --- En vigueur (green/success) --- */
.status-vigueur {
background-color: var(--ui-color-green-50, #f0fdf4);
color: var(--ui-color-green-700, #15803d);
border-color: var(--ui-color-green-200, #bbf7d0);
}
/* --- En vote (blue/primary) --- */
.status-vote {
background-color: var(--ui-color-blue-50, #eff6ff);
color: var(--ui-color-blue-700, #1d4ed8);
border-color: var(--ui-color-blue-200, #bfdbfe);
}
/* --- Clos (gray/neutral) --- */
.status-clos {
background-color: var(--ui-color-gray-50, #f9fafb);
color: var(--ui-color-gray-500, #6b7280);
border-color: var(--ui-color-gray-200, #e5e7eb);
}
/* Dark mode overrides */
.dark .status-prepa {
background-color: var(--ui-color-amber-950, #451a03);
color: var(--ui-color-amber-300, #fcd34d);
border-color: var(--ui-color-amber-800, #92400e);
}
.dark .status-vigueur {
background-color: var(--ui-color-green-950, #052e16);
color: var(--ui-color-green-300, #86efac);
border-color: var(--ui-color-green-800, #166534);
}
.dark .status-vote {
background-color: var(--ui-color-blue-950, #172554);
color: var(--ui-color-blue-300, #93c5fd);
border-color: var(--ui-color-blue-800, #1e40af);
}
.dark .status-clos {
background-color: var(--ui-color-gray-900, #111827);
color: var(--ui-color-gray-400, #9ca3af);
border-color: var(--ui-color-gray-700, #374151);
}
</style>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
/**
* ToolboxVignette — Carte compacte pour la barre laterale "Boite a outils".
*
* Affiche un protocole ou outil avec titre, description, contexte et actions.
* Utilise les variables mood pour le theming.
*/
export interface ToolboxAction {
label: string
icon?: string
to?: string
emit?: string
}
const props = withDefaults(
defineProps<{
title: string
description?: string
contextLabel?: string
actions?: ToolboxAction[]
}>(),
{
description: undefined,
contextLabel: undefined,
actions: undefined,
},
)
const emit = defineEmits<{
action: [actionEmit: string]
}>()
const defaultActions: ToolboxAction[] = [
{ label: 'Contexte', icon: 'i-lucide-info', emit: 'context' },
{ label: 'Tutos', icon: 'i-lucide-graduation-cap', emit: 'tutos' },
{ label: 'Choisir', icon: 'i-lucide-check-circle', emit: 'choisir' },
{ label: 'Demarrer', icon: 'i-lucide-play', emit: 'demarrer' },
]
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="toolbox-vignette">
<h4 class="toolbox-vignette__title">
{{ title }}
</h4>
<p v-if="description" class="toolbox-vignette__description">
{{ description }}
</p>
<div v-if="contextLabel" class="toolbox-vignette__context">
<UIcon name="i-lucide-tag" class="text-xs" />
<span>Contexte : {{ contextLabel }}</span>
</div>
<div class="toolbox-vignette__actions">
<UButton
v-for="action in resolvedActions"
:key="action.label"
:icon="action.icon"
:label="action.label"
size="xs"
variant="soft"
class="toolbox-vignette__action-btn"
@click="handleAction(action)"
/>
</div>
</div>
</template>
<style scoped>
.toolbox-vignette {
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.5rem;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.toolbox-vignette:hover {
border-color: var(--mood-accent);
box-shadow: 0 1px 4px var(--mood-shadow);
}
.toolbox-vignette__title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-text);
line-height: 1.3;
}
.toolbox-vignette__description {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.4;
}
.toolbox-vignette__context {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.toolbox-vignette__actions {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.25rem;
}
.toolbox-vignette__action-btn {
--btn-bg: var(--mood-accent-soft);
--btn-color: var(--mood-accent);
}
</style>

View File

@@ -99,7 +99,7 @@ function onSubmit() {
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Protocole de vote
</label>
<ProtocolsProtocolPicker
<ProtocolPicker
:model-value="modelValue.voting_protocol_id ?? null"
@update:model-value="updateField('voting_protocol_id', $event)"
/>

View File

@@ -48,7 +48,7 @@ function navigate() {
{{ decision.title }}
</h3>
</div>
<CommonStatusBadge :status="decision.status" type="decision" />
<StatusBadge :status="decision.status" type="decision" />
</div>
<p v-if="decision.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">

View File

@@ -96,7 +96,7 @@ function formatDate(dateStr: string): string {
{{ stepTypeLabel(step.step_type) }}
</UBadge>
</div>
<CommonStatusBadge :status="step.status" type="decision" />
<StatusBadge :status="step.status" type="decision" />
</div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">

View File

@@ -55,7 +55,7 @@ function formatDate(dateStr: string): string {
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
{{ doc.title }}
</h3>
<CommonStatusBadge :status="doc.status" type="document" />
<StatusBadge :status="doc.status" type="document" />
</div>
<!-- Type + Version -->

View File

@@ -70,7 +70,7 @@ function navigateToItem() {
<!-- Item text -->
<div class="pl-2">
<CommonMarkdownRenderer :content="item.current_text" />
<MarkdownRenderer :content="item.current_text" />
</div>
<!-- Actions -->

View File

@@ -35,7 +35,7 @@ function truncateAddress(address: string | null): string {
<!-- Header -->
<div class="flex items-start justify-between">
<div class="flex items-center gap-3">
<CommonStatusBadge :status="version.status" type="version" />
<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">
@@ -57,14 +57,14 @@ function truncateAddress(address: string | null): string {
<!-- Diff view -->
<div v-if="version.diff">
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
<CommonDiffView :diff="version.diff" />
<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">
<CommonMarkdownRenderer :content="version.proposed_text" />
<MarkdownRenderer :content="version.proposed_text" />
</div>
</div>

View File

@@ -47,7 +47,7 @@ function navigate() {
{{ mandate.title }}
</h3>
</div>
<CommonStatusBadge :status="mandate.status" type="mandate" />
<StatusBadge :status="mandate.status" type="mandate" />
</div>
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">

View File

@@ -98,7 +98,7 @@ function formatDate(dateStr: string): string {
{{ stepTypeLabel(step.step_type) }}
</UBadge>
</div>
<CommonStatusBadge :status="step.status" type="mandate" />
<StatusBadge :status="step.status" type="mandate" />
</div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">

View File

@@ -121,7 +121,7 @@ async function copyHash() {
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
</div>
<div @click.stop>
<SanctuaryIPFSLink :cid="entry.ipfs_cid" />
<IPFSLink :cid="entry.ipfs_cid" />
</div>
</div>
@@ -131,7 +131,7 @@ async function copyHash() {
<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>
<SanctuaryChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
<ChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
</div>
</div>

View File

@@ -0,0 +1,65 @@
import type { Ref } from 'vue'
export interface Mood {
id: string
label: string
description: string
icon: string
isDark: boolean
}
const STORAGE_KEY = 'glibredecision_mood'
const moods: Mood[] = [
{ id: 'peps', label: 'Peps', description: 'Energique et chaleureux', icon: 'i-lucide-sun', isDark: false },
{ id: 'zen', label: 'Zen', description: 'Calme et serein', icon: 'i-lucide-leaf', isDark: false },
{ id: 'chagrine', label: 'Chagrine', description: 'Profond et subtil', icon: 'i-lucide-moon', isDark: true },
{ id: 'grave', label: 'Grave', description: 'Serieux et solennel', icon: 'i-lucide-shield', isDark: true },
]
const currentMood: Ref<string> = ref('peps')
function applyMood(moodId: string) {
if (import.meta.server) return
const mood = moods.find(m => m.id === moodId)
if (!mood) return
const html = document.documentElement
// Remove all existing mood classes
moods.forEach(m => html.classList.remove(`mood-${m.id}`))
// Add the new mood class
html.classList.add(`mood-${moodId}`)
// Sync color-mode (light/dark) via Nuxt's useColorMode
const colorMode = useColorMode()
colorMode.preference = mood.isDark ? 'dark' : 'light'
// Persist choice
localStorage.setItem(STORAGE_KEY, moodId)
currentMood.value = moodId
}
function setMood(moodId: string) {
applyMood(moodId)
}
function initMood() {
if (import.meta.server) return
const saved = localStorage.getItem(STORAGE_KEY)
const moodId = saved && moods.some(m => m.id === saved) ? saved : 'peps'
applyMood(moodId)
}
export function useMood() {
return {
currentMood: readonly(currentMood),
moods,
setMood,
initMood,
}
}

View File

@@ -334,7 +334,7 @@ async function handleAddStep() {
/>
</div>
<DecisionsDecisionWorkflow
<DecisionWorkflow
:steps="decisions.current.steps"
:current-status="decisions.current.status"
@create-vote-session="handleCreateVoteSession"

View File

@@ -1,67 +1,80 @@
<script setup lang="ts">
/**
* Decisions — page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boite a outils" affichant les protocoles de vote.
*/
const decisions = useDecisionsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const filterType = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const typeOptions = [
{ label: 'Tous les types', value: undefined },
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
{ label: 'Modification de document', value: 'document_change' },
{ label: 'Vote de mandat', value: 'mandate_vote' },
{ label: 'Changement de parametre', value: 'parameter_change' },
{ label: 'Autre', value: 'other' },
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
const statusOptions = [
{ label: 'Tous les statuts', value: undefined },
{ label: 'Brouillon', value: 'draft' },
{ label: 'Qualification', value: 'qualification' },
{ label: 'Revue', value: 'review' },
{ label: 'En vote', value: 'voting' },
{ label: 'Execute', value: 'executed' },
{ label: 'Clos', value: 'closed' },
]
async function loadDecisions() {
await decisions.fetchAll({
decision_type: filterType.value,
status: filterStatus.value,
})
}
onMounted(() => {
loadDecisions()
onMounted(async () => {
await Promise.all([
decisions.fetchAll(),
protocols.fetchProtocols(),
])
})
watch([filterType, filterStatus], () => {
loadDecisions()
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prepa', count: decisions.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: decisions.list.filter(d => d.status === 'voting' || d.status === 'qualification' || d.status === 'review').length },
{ id: 'executed', label: 'En vigueur', count: decisions.list.filter(d => d.status === 'executed').length },
{ id: 'closed', label: 'Clos', count: decisions.list.filter(d => d.status === 'closed').length },
])
/** Map for the voting pill — include qualification/review under "En vote". */
const statusGroupMap: Record<string, string[]> = {
draft: ['draft'],
voting: ['qualification', 'review', 'voting'],
executed: ['executed'],
closed: ['closed'],
}
/** Filtered and sorted decisions. */
const filteredDecisions = computed(() => {
let list = [...decisions.list]
// Filter by status group
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
const statuses = statusGroupMap[activeStatus.value]
list = list.filter(d => statuses.includes(d.status))
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(d => d.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
const statusColor = (status: string) => {
switch (status) {
case 'draft': return 'warning'
case 'qualification': return 'info'
case 'review': return 'info'
case 'voting': return 'primary'
case 'executed': return 'success'
case 'closed': return 'neutral'
default: return 'neutral'
}
}
const statusLabel = (status: string) => {
switch (status) {
case 'draft': return 'Brouillon'
case 'qualification': return 'Qualification'
case 'review': return 'Revue'
case 'voting': return 'En vote'
case 'executed': return 'Execute'
case 'closed': return 'Clos'
default: return status
}
}
const typeLabel = (decisionType: string) => {
switch (decisionType) {
case 'runtime_upgrade': return 'Runtime upgrade'
@@ -83,121 +96,204 @@ function formatDate(dateStr: string): string {
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Decisions
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Processus de decision collectifs de la communaute
</p>
</div>
<SectionLayout
title="Decisions"
subtitle="Processus de decision collectifs"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<UInput
v-model="searchQuery"
placeholder="Rechercher une decision..."
icon="i-lucide-search"
size="sm"
class="w-full sm:w-64"
/>
<USelect
v-model="sortBy"
:items="sortOptions"
size="sm"
class="w-36"
/>
<UButton
v-if="auth.isAuthenticated"
to="/decisions/new"
label="Nouvelle"
icon="i-lucide-plus"
label="Nouvelle decision"
color="primary"
size="sm"
/>
</div>
</template>
<!-- Filters -->
<div class="flex flex-wrap gap-4">
<USelect
v-model="filterType"
:items="typeOptions"
placeholder="Type de decision"
class="w-56"
/>
<USelect
v-model="filterStatus"
:items="statusOptions"
placeholder="Statut"
class="w-48"
/>
<!-- Main content: decision list -->
<template #default>
<!-- Error state -->
<div v-if="decisions.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ decisions.error }}</p>
</div>
<!-- Loading state -->
<template v-if="decisions.loading">
<div class="space-y-3">
<USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
<div v-else-if="decisions.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 5" :key="i" :lines="2" card />
</div>
</template>
<!-- Error state -->
<template v-else-if="decisions.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ decisions.error }}</p>
</div>
</UCard>
</template>
<!-- Empty state -->
<template v-else-if="decisions.list.length === 0">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-scale" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune decision pour le moment</p>
<div
v-else-if="filteredDecisions.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-scale" class="text-4xl mb-3 block mx-auto" />
<p>Aucune decision trouvee</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
</UCard>
</template>
<!-- Decisions table -->
<template v-else>
<UCard>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Titre</th>
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Type</th>
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Statut</th>
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Etapes</th>
<th class="text-left px-4 py-3 font-medium text-gray-500 dark:text-gray-400">Date</th>
</tr>
</thead>
<tbody>
<tr
v-for="decision in decisions.list"
<!-- Decision cards -->
<div v-else class="space-y-3">
<div
v-for="decision in filteredDecisions"
:key="decision.id"
class="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50 cursor-pointer"
class="decision-card"
@click="navigateTo(`/decisions/${decision.id}`)"
>
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-scale" class="text-gray-400" />
<div>
<span class="font-medium text-gray-900 dark:text-white">{{ decision.title }}</span>
<p v-if="decision.description" class="text-xs text-gray-500 mt-0.5 line-clamp-1">
<div class="decision-card__header">
<div class="decision-card__title-block">
<h3 class="decision-card__title">
{{ decision.title }}
</h3>
<p v-if="decision.description" class="decision-card__description">
{{ decision.description }}
</p>
</div>
<StatusBadge :status="decision.status" type="decision" />
</div>
</td>
<td class="px-4 py-3">
<div class="decision-card__meta">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(decision.decision_type) }}
</UBadge>
</td>
<td class="px-4 py-3">
<UBadge :color="statusColor(decision.status)" variant="subtle" size="xs">
{{ statusLabel(decision.status) }}
</UBadge>
</td>
<td class="px-4 py-3 text-gray-600 dark:text-gray-400">
{{ decision.steps.length }}
</td>
<td class="px-4 py-3 text-gray-500 text-xs">
<span class="decision-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ decision.steps.length }} etape{{ decision.steps.length !== 1 ? 's' : '' }}
</span>
<span class="decision-card__date">
{{ formatDate(decision.created_at) }}
</td>
</tr>
</tbody>
</table>
</span>
</div>
</div>
</UCard>
</template>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<div class="toolbox-section-title">
Modalites de vote
</div>
<template v-if="protocols.protocols.length > 0">
<ToolboxVignette
v-for="protocol in protocols.protocols"
:key="protocol.id"
:title="protocol.name"
:description="protocol.description || undefined"
context-label="Decisions"
:actions="[
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
]"
/>
</template>
<p v-else class="toolbox-empty-text">
Aucun protocole configure
</p>
</template>
</SectionLayout>
</template>
<style scoped>
.decision-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem 1rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.decision-card:hover {
border-color: var(--mood-accent);
box-shadow: 0 2px 8px var(--mood-shadow);
}
.decision-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.decision-card__title-block {
flex: 1;
min-width: 0;
}
.decision-card__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
line-height: 1.3;
}
.decision-card__description {
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.decision-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.decision-card__steps {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.decision-card__date {
font-size: 0.6875rem;
color: var(--mood-text-muted);
margin-left: auto;
}
.toolbox-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.toolbox-empty-text {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
</style>

View File

@@ -62,7 +62,7 @@ async function onSubmit() {
<!-- Form -->
<UCard>
<DecisionsDecisionCadrage
<DecisionCadrage
v-model="formData"
:submitting="submitting"
@submit="onSubmit"

View File

@@ -105,7 +105,7 @@ async function archiveToSanctuary() {
<UBadge variant="subtle" color="primary">
{{ typeLabel(documents.current.doc_type) }}
</UBadge>
<CommonStatusBadge :status="documents.current.status" type="document" />
<StatusBadge :status="documents.current.status" type="document" />
<span class="text-sm text-gray-500 font-mono">
v{{ documents.current.version }}
</span>
@@ -155,7 +155,7 @@ async function archiveToSanctuary() {
<div>
<p class="text-gray-500">Ancrage IPFS</p>
<div class="mt-1">
<SanctuaryIPFSLink :cid="documents.current.ipfs_cid" />
<IPFSLink :cid="documents.current.ipfs_cid" />
</div>
</div>
</div>
@@ -164,7 +164,7 @@ async function archiveToSanctuary() {
<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>
<SanctuaryChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
<ChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
</div>
</div>
</UCard>
@@ -181,7 +181,7 @@ async function archiveToSanctuary() {
</div>
<div v-else class="space-y-4">
<DocumentsItemCard
<ItemCard
v-for="item in documents.items"
:key="item.id"
:item="item"

View File

@@ -218,7 +218,7 @@ function formatDate(dateStr: string): string {
<UIcon name="i-lucide-file-text" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Texte en vigueur</h2>
</div>
<CommonMarkdownRenderer :content="currentItem.current_text" />
<MarkdownRenderer :content="currentItem.current_text" />
<div class="flex items-center gap-4 pt-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400">
<span>Cree le {{ formatDate(currentItem.created_at) }}</span>
<span>Mis a jour le {{ formatDate(currentItem.updated_at) }}</span>
@@ -259,7 +259,7 @@ function formatDate(dateStr: string): string {
</template>
<div v-else class="space-y-4">
<DocumentsItemVersionDiff
<ItemVersionDiff
v-for="version in documents.versions"
:key="version.id"
:version="version"

View File

@@ -1,26 +1,19 @@
<script setup lang="ts">
/**
* Documents de reference — page index.
*
* Utilise SectionLayout avec status filters, recherche, tri,
* et sidebar "Boite a outils" affichant les protocoles de vote.
*/
import type { DocumentCreate } from '~/stores/documents'
const documents = useDocumentsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const filterType = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
const docTypeOptions = [
{ label: 'Tous les types', value: undefined },
{ label: 'Licence', value: 'licence' },
{ label: 'Engagement', value: 'engagement' },
{ label: 'Reglement', value: 'reglement' },
{ label: 'Constitution', value: 'constitution' },
]
const statusOptions = [
{ label: 'Tous les statuts', value: undefined },
{ label: 'Brouillon', value: 'draft' },
{ label: 'Actif', value: 'active' },
{ label: 'Archive', value: 'archived' },
]
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
// New document modal state
const showNewDocModal = ref(false)
@@ -40,30 +33,78 @@ const newDocTypeOptions = [
{ label: 'Constitution', value: 'constitution' },
]
async function loadDocuments() {
await documents.fetchAll({
doc_type: filterType.value,
status: filterStatus.value,
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
onMounted(async () => {
await Promise.all([
documents.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prepa', count: documents.list.filter(d => d.status === 'draft').length },
{ id: 'voting', label: 'En vote', count: documents.list.filter(d => d.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: documents.list.filter(d => d.status === 'active').length },
{ id: 'archived', label: 'Clos', count: documents.list.filter(d => d.status === 'archived').length },
])
/** Filtered and sorted documents. */
const filteredDocuments = computed(() => {
let list = [...documents.list]
// Filter by status
if (activeStatus.value) {
list = list.filter(d => d.status === activeStatus.value)
}
onMounted(() => {
loadDocuments()
})
watch([filterType, filterStatus], () => {
loadDocuments()
})
function openNewDocModal() {
newDoc.value = {
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(d => d.title.toLowerCase().includes(q))
}
showNewDocModal.value = true
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
/** Toolbox vignettes from protocols. */
const toolboxTitle = 'Modalites de vote'
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',
})
}
function generateSlug(title: string): string {
@@ -83,6 +124,17 @@ watch(() => newDoc.value.title, (title) => {
}
})
function openNewDocModal() {
newDoc.value = {
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
}
showNewDocModal.value = true
}
async function createDocument() {
creating.value = true
try {
@@ -91,80 +143,145 @@ async function createDocument() {
if (doc) {
navigateTo(`/documents/${doc.slug}`)
}
} catch {
}
catch {
// Error handled in store
} finally {
}
finally {
creating.value = false
}
}
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Documents de reference
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Documents fondateurs de la communaute Duniter/G1 sous vote permanent
<SectionLayout
title="Documents de reference"
subtitle="Textes fondateurs sous vote permanent de la communaute"
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<UInput
v-model="searchQuery"
placeholder="Rechercher un document..."
icon="i-lucide-search"
size="sm"
class="w-full sm:w-64"
/>
<USelect
v-model="sortBy"
:items="sortOptions"
size="sm"
class="w-36"
/>
<UButton
v-if="auth.isAuthenticated"
label="Nouveau"
icon="i-lucide-plus"
color="primary"
size="sm"
@click="openNewDocModal"
/>
</template>
<!-- Main content: document list -->
<template #default>
<!-- Error state -->
<div v-if="documents.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ documents.error }}</p>
</div>
<!-- Loading state -->
<div v-else-if="documents.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
</div>
<!-- Empty state -->
<div
v-else-if="filteredDocuments.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-book-open" class="text-4xl mb-3 block mx-auto" />
<p>Aucun document trouve</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<!-- New document button for authenticated users -->
<UButton
v-if="auth.isAuthenticated"
label="Nouveau document"
icon="i-lucide-plus"
color="primary"
@click="openNewDocModal"
/>
<!-- Document cards -->
<div v-else class="space-y-3">
<div
v-for="doc in filteredDocuments"
:key="doc.id"
class="doc-card"
@click="navigateTo(`/documents/${doc.slug}`)"
>
<div class="doc-card__header">
<h3 class="doc-card__title">
{{ doc.title }}
</h3>
<StatusBadge :status="doc.status" type="document" />
</div>
<!-- Filters -->
<div class="flex flex-wrap gap-4">
<USelect
v-model="filterType"
:items="docTypeOptions"
placeholder="Type de document"
class="w-48"
/>
<USelect
v-model="filterStatus"
:items="statusOptions"
placeholder="Statut"
class="w-48"
/>
<div class="doc-card__meta">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(doc.doc_type) }}
</UBadge>
<span class="doc-card__version">v{{ doc.version }}</span>
<span class="doc-card__items">
<UIcon name="i-lucide-list" class="text-xs" />
{{ doc.items_count }} item{{ doc.items_count !== 1 ? 's' : '' }}
</span>
<span class="doc-card__date">
{{ formatDate(doc.updated_at) }}
</span>
</div>
<!-- Error state -->
<template v-if="documents.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
<p v-if="doc.description" class="doc-card__description">
{{ doc.description }}
</p>
</div>
</div>
</UCard>
</template>
<!-- Document list component -->
<DocumentsDocumentList
:documents="documents.list"
:loading="documents.loading"
<!-- Toolbox sidebar -->
<template #toolbox>
<div class="toolbox-section-title">
{{ toolboxTitle }}
</div>
<template v-if="protocols.protocols.length > 0">
<ToolboxVignette
v-for="protocol in protocols.protocols"
:key="protocol.id"
:title="protocol.name"
:description="protocol.description || undefined"
context-label="Documents"
:actions="[
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
]"
/>
</template>
<p v-else class="toolbox-empty-text">
Aucun protocole configure
</p>
</template>
</SectionLayout>
<!-- New document modal -->
<UModal v-model:open="showNewDocModal">
<template #content>
<div class="p-6 space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<h3 class="text-lg font-semibold" style="color: var(--mood-text);">
Nouveau document de reference
</h3>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Titre
</label>
<UInput
@@ -175,7 +292,7 @@ async function createDocument() {
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Slug (identifiant URL)
</label>
<UInput
@@ -186,7 +303,7 @@ async function createDocument() {
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Type de document
</label>
<USelect
@@ -197,7 +314,7 @@ async function createDocument() {
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Version
</label>
<UInput
@@ -208,7 +325,7 @@ async function createDocument() {
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Description (optionnelle)
</label>
<UTextarea
@@ -239,5 +356,88 @@ async function createDocument() {
</div>
</template>
</UModal>
</div>
</template>
<style scoped>
.doc-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem 1rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.doc-card:hover {
border-color: var(--mood-accent);
box-shadow: 0 2px 8px var(--mood-shadow);
}
.doc-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.doc-card__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
line-height: 1.3;
}
.doc-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.doc-card__version {
font-size: 0.6875rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text-muted);
}
.doc-card__items {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.doc-card__date {
font-size: 0.6875rem;
color: var(--mood-text-muted);
margin-left: auto;
}
.doc-card__description {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.toolbox-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.toolbox-empty-text {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
</style>

View File

@@ -1,66 +1,65 @@
<script setup lang="ts">
/**
* Dashboard / Page d'accueil — Glibredecision.
*
* Accueil chaleureux avec onboarding : cartes d'entree vers les sections,
* banniere de connexion, apercu de la boite a outils et activite recente.
*/
const documents = useDocumentsStore()
const decisions = useDecisionsStore()
const mandates = useMandatesStore()
const votes = useVotesStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const loading = ref(true)
const formulaOpen = ref(false)
onMounted(async () => {
try {
await Promise.all([
documents.fetchAll(),
decisions.fetchAll(),
mandates.fetchAll(),
votes.fetchSessions(),
protocols.fetchProtocols(),
])
} finally {
}
finally {
loading.value = false
}
})
/** Summary stats for the dashboard cards. */
const stats = computed(() => [
/** Entry cards — the 3 main doors. */
const entryCards = computed(() => [
{
label: 'Documents actifs',
value: documents.activeDocuments.length,
total: documents.list.length,
key: 'documents',
title: 'Documents',
icon: 'i-lucide-book-open',
color: 'primary' as const,
to: '/documents',
count: documents.activeDocuments.length,
countLabel: `${documents.activeDocuments.length} actif${documents.activeDocuments.length > 1 ? 's' : ''}`,
totalLabel: `${documents.list.length} au total`,
description: 'Textes fondateurs sous vote permanent',
},
{
label: 'Decisions en cours',
value: decisions.activeDecisions.length,
total: decisions.list.length,
key: 'decisions',
title: 'Decisions',
icon: 'i-lucide-scale',
color: 'success' as const,
to: '/decisions',
count: decisions.activeDecisions.length,
countLabel: `${decisions.activeDecisions.length} en cours`,
totalLabel: `${decisions.list.length} au total`,
description: 'Processus de decision collectifs',
},
{
label: 'Votes ouverts',
value: openVoteSessions.value.length,
total: votes.sessions.length,
icon: 'i-lucide-vote',
color: 'warning' as const,
to: '/decisions',
},
{
label: 'Mandats actifs',
value: mandates.activeMandates.length,
total: mandates.list.length,
key: 'mandats',
title: 'Mandats',
icon: 'i-lucide-user-check',
color: 'info' as const,
to: '/mandates',
count: null,
countLabel: null,
totalLabel: null,
description: 'Un contexte, un objectif, une duree, une ou plusieurs nominations ; par defaut : nomination d\'un binome.',
},
])
/** Open vote sessions. */
const openVoteSessions = computed(() => {
return votes.sessions.filter(s => s.status === 'open')
})
/** Last 5 decisions sorted by most recent. */
const recentDecisions = computed(() => {
return [...decisions.list]
@@ -68,13 +67,6 @@ const recentDecisions = computed(() => {
.slice(0, 5)
})
/** Last 5 vote sessions sorted by most recent. */
const recentVotes = computed(() => {
return [...votes.sessions]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 5)
})
/** Format a date string to a localized relative or absolute string. */
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
@@ -94,279 +86,179 @@ function formatDate(dateStr: string): string {
}
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
}
/** Section cards for the "Domaines" grid. */
const sections = [
{
title: 'Documents de reference',
description: 'Licence G1, engagements forgerons, reglement du comite technique et autres documents fondateurs sous vote permanent.',
icon: 'i-lucide-book-open',
to: '/documents',
color: 'primary' as const,
},
{
title: 'Decisions',
description: 'Processus de decision collectifs: runtime upgrades, modifications de documents, votes de mandats.',
icon: 'i-lucide-scale',
to: '/decisions',
color: 'success' as const,
},
{
title: 'Mandats',
description: 'Gestion des mandats du comite technique, des forgerons et autres roles de gouvernance.',
icon: 'i-lucide-user-check',
to: '/mandates',
color: 'warning' as const,
},
{
title: 'Protocoles de vote',
description: 'Configuration des formules de seuil WoT, criteres Smith et TechComm, parametres de vote nuance.',
icon: 'i-lucide-settings',
to: '/protocols',
color: 'info' as const,
},
{
title: 'Sanctuaire',
description: 'Archive immuable: documents ancres sur IPFS avec preuve on-chain via system.remark.',
icon: 'i-lucide-archive',
to: '/sanctuary',
color: 'error' as const,
},
]
</script>
<template>
<div class="space-y-8">
<!-- Title -->
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
Glibredecision
<div class="dashboard" :style="{ background: 'var(--mood-gradient)' }">
<!-- Welcome banner -->
<div class="dashboard__welcome">
<h1 class="dashboard__welcome-title">
Bienvenue sur Glibredecision
</h1>
<p class="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-400">
Decisions collectives pour la communaute Duniter/G1
<p class="dashboard__welcome-subtitle">
Plateforme de decisions collectives pour la communaute Duniter / G1.
Explorez les documents de reference, participez aux decisions, suivez les mandats.
</p>
</div>
<!-- Quick actions (authenticated users only) -->
<div v-if="auth.isAuthenticated" class="flex flex-wrap gap-2 sm:gap-3">
<UButton
to="/decisions"
icon="i-lucide-plus"
label="Nouvelle decision"
variant="soft"
color="primary"
size="sm"
/>
<UButton
to="/documents"
icon="i-lucide-book-open"
label="Voir les documents"
variant="soft"
color="neutral"
size="sm"
/>
<UButton
to="/mandates"
icon="i-lucide-user-check"
label="Mandats"
variant="soft"
color="neutral"
size="sm"
/>
</div>
<!-- Stats cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<!-- Entry cards grid -->
<div class="dashboard__entries">
<template v-if="loading">
<LoadingSkeleton
v-for="i in 4"
v-for="i in 3"
:key="i"
:lines="2"
:lines="3"
card
/>
</template>
<template v-else>
<NuxtLink v-for="stat in stats" :key="stat.label" :to="stat.to">
<UCard class="hover:shadow-md transition-shadow h-full">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate">
{{ stat.label }}
</p>
<p class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mt-1">
{{ stat.value }}
</p>
<p class="text-xs text-gray-400 mt-1">
{{ stat.total }} au total
</p>
<NuxtLink
v-for="card in entryCards"
:key="card.key"
:to="card.to"
class="dashboard__entry-card"
>
<div class="dashboard__entry-icon">
<UIcon :name="card.icon" class="text-2xl" />
</div>
<UIcon
:name="stat.icon"
class="text-2xl sm:text-3xl text-gray-400 flex-shrink-0"
<h2 class="dashboard__entry-title">
{{ card.title }}
</h2>
<!-- Count badge for documents and decisions -->
<template v-if="card.count !== null">
<p class="dashboard__entry-count">
{{ card.countLabel }}
</p>
<p class="dashboard__entry-total">
{{ card.totalLabel }}
</p>
</template>
<!-- Special onboarding text for mandats -->
<template v-else>
<p class="dashboard__entry-onboard">
{{ card.description }}
</p>
</template>
<UButton
label="Entrer"
variant="soft"
size="xs"
trailing-icon="i-lucide-arrow-right"
class="dashboard__entry-btn"
/>
</div>
</UCard>
</NuxtLink>
</template>
</div>
<!-- Recent activity: decisions + votes side-by-side -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<!-- Recent decisions -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-scale" class="text-lg text-primary" />
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
Decisions recentes
</h3>
<!-- Onboarding banner for unauthenticated users -->
<div v-if="!auth.isAuthenticated" class="dashboard__onboarding">
<div class="dashboard__onboarding-content">
<UIcon name="i-lucide-key-round" class="text-xl" />
<div>
<p class="dashboard__onboarding-text">
Connectez-vous avec votre identite Duniter pour participer aux decisions collectives.
</p>
<p class="dashboard__onboarding-hint">
Authentification par signature Ed25519 aucun mot de passe.
</p>
</div>
</div>
<UButton
to="/decisions"
variant="ghost"
size="xs"
color="neutral"
label="Tout voir"
trailing-icon="i-lucide-chevron-right"
to="/login"
label="Se connecter"
icon="i-lucide-log-in"
variant="soft"
size="sm"
/>
</div>
</template>
<div v-if="loading" class="space-y-3">
<LoadingSkeleton :lines="5" />
<!-- Boite a outils teaser -->
<div class="dashboard__toolbox-teaser">
<div class="dashboard__toolbox-teaser-header">
<UIcon name="i-lucide-wrench" class="text-lg" />
<h3>Boite a outils</h3>
<UBadge variant="subtle" size="xs">
{{ protocols.protocols.length }} modalite{{ protocols.protocols.length > 1 ? 's' : '' }}
</UBadge>
</div>
<div v-else-if="recentDecisions.length === 0" class="text-center py-6">
<UIcon name="i-lucide-inbox" class="text-3xl text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm text-gray-500 mt-2">Aucune decision pour le moment</p>
<p class="dashboard__toolbox-teaser-description">
Protocoles de vote configurables avec formule de seuil WoT adaptative.
</p>
<div class="dashboard__toolbox-teaser-tags">
<template v-if="protocols.protocols.length > 0">
<NuxtLink
v-for="protocol in protocols.protocols"
:key="protocol.id"
:to="`/protocols/${protocol.id}`"
class="dashboard__toolbox-tag"
>
{{ protocol.name }}
</NuxtLink>
</template>
<template v-else>
<span class="dashboard__toolbox-tag">Vote majoritaire</span>
<span class="dashboard__toolbox-tag">Vote nuance</span>
<span class="dashboard__toolbox-tag">Vote permanent</span>
</template>
</div>
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
<UButton
to="/protocols"
label="Voir la boite a outils"
variant="ghost"
size="xs"
trailing-icon="i-lucide-chevron-right"
class="mt-1"
/>
</div>
<!-- Recent activity -->
<div v-if="recentDecisions.length > 0" class="dashboard__activity">
<div class="dashboard__activity-header">
<UIcon name="i-lucide-activity" class="text-lg" />
<h3>Activite recente</h3>
</div>
<div class="dashboard__activity-list">
<NuxtLink
v-for="decision in recentDecisions"
:key="decision.id"
:to="`/decisions/${decision.id}`"
class="flex items-center justify-between py-3 first:pt-0 last:pb-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 -mx-2 px-2 rounded transition-colors"
class="dashboard__activity-item"
>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ decision.title }}
</p>
<p class="text-xs text-gray-500 mt-0.5">
{{ formatDate(decision.updated_at) }}
</p>
<div class="dashboard__activity-item-main">
<span class="dashboard__activity-item-title">{{ decision.title }}</span>
<span class="dashboard__activity-item-date">{{ formatDate(decision.updated_at) }}</span>
</div>
<StatusBadge :status="decision.status" type="decision" />
</NuxtLink>
</div>
</UCard>
<!-- Recent votes -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-vote" class="text-lg text-warning" />
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
Sessions de vote recentes
</h3>
</div>
<!-- Formula explainer collapsible -->
<UCollapsible v-model:open="formulaOpen">
<UButton
to="/decisions"
variant="ghost"
size="xs"
color="neutral"
label="Tout voir"
trailing-icon="i-lucide-chevron-right"
/>
</div>
</template>
<div v-if="loading" class="space-y-3">
<LoadingSkeleton :lines="5" />
</div>
<div v-else-if="recentVotes.length === 0" class="text-center py-6">
<UIcon name="i-lucide-inbox" class="text-3xl text-gray-300 dark:text-gray-600 mx-auto" />
<p class="text-sm text-gray-500 mt-2">Aucune session de vote pour le moment</p>
</div>
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
<div
v-for="session in recentVotes"
:key="session.id"
class="py-3 first:pt-0 last:pb-0"
size="sm"
:icon="formulaOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="dashboard__formula-trigger"
>
<div class="flex items-center justify-between">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<StatusBadge :status="session.status" type="vote" />
<span class="text-xs text-gray-500">
{{ session.votes_total }} vote{{ session.votes_total !== 1 ? 's' : '' }}
</span>
<UIcon name="i-lucide-calculator" />
<span>Formule de seuil WoT</span>
</div>
<div class="mt-1.5">
<!-- Mini progress bar for vote results -->
<div class="flex items-center gap-2">
<div class="flex-1 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
class="h-full bg-success-500 rounded-full transition-all"
:style="{ width: session.votes_total > 0 ? `${(session.votes_for / session.votes_total) * 100}%` : '0%' }"
/>
</div>
<span class="text-xs text-gray-500 flex-shrink-0">
{{ session.votes_for }}/{{ session.votes_total }}
</span>
</div>
</div>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">
{{ formatDate(session.created_at) }}
</p>
</div>
</div>
</UCard>
</div>
<!-- Section cards (Domaines) -->
<div>
<h2 class="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white mb-4">
Domaines
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<NuxtLink v-for="section in sections" :key="section.title" :to="section.to">
<UCard class="h-full hover:shadow-md transition-shadow">
<div class="space-y-3">
<div class="flex items-center gap-3">
<UIcon :name="section.icon" class="text-2xl text-primary flex-shrink-0" />
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
{{ section.title }}
</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ section.description }}
</p>
</div>
</UCard>
</NuxtLink>
</div>
</div>
<!-- Formula explainer -->
<UCard>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-calculator" class="text-xl text-primary" />
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
Formule de seuil WoT
</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400">
</UButton>
<template #content>
<div class="dashboard__formula-content">
<p class="dashboard__formula-description">
Le seuil d'adoption s'adapte dynamiquement a la participation :
faible participation = quasi-unanimite requise ; forte participation = majorite simple suffisante.
</p>
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-xs sm:text-sm font-mono overflow-x-auto">
<code class="dashboard__formula-code">
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
</code>
<div class="flex flex-wrap gap-3 sm:gap-4 text-xs text-gray-500">
<div class="dashboard__formula-params">
<span>C = constante de base</span>
<span>B = exposant de base</span>
<span>W = taille WoT</span>
@@ -374,7 +266,324 @@ const sections = [
<span>M = majorite</span>
<span>G = gradient</span>
</div>
</div>
</UCard>
<UButton
to="/protocols/formulas"
label="Ouvrir le simulateur"
variant="outline"
size="xs"
icon="i-lucide-calculator"
class="mt-2"
/>
</div>
</template>
</UCollapsible>
</div>
</template>
<style scoped>
.dashboard {
display: flex;
flex-direction: column;
gap: 2rem;
min-height: 100%;
padding-bottom: 2rem;
}
/* --- Welcome --- */
.dashboard__welcome {
text-align: center;
padding: 1rem 0;
}
.dashboard__welcome-title {
font-size: 1.75rem;
font-weight: 800;
color: var(--mood-accent);
line-height: 1.2;
}
@media (min-width: 640px) {
.dashboard__welcome-title {
font-size: 2.25rem;
}
}
.dashboard__welcome-subtitle {
margin-top: 0.5rem;
font-size: 1rem;
color: var(--mood-text-muted);
max-width: 42rem;
margin-left: auto;
margin-right: auto;
line-height: 1.5;
}
/* --- Entry cards --- */
.dashboard__entries {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.dashboard__entry-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem 1rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.75rem;
text-align: center;
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
}
.dashboard__entry-card:hover {
border-color: var(--mood-accent);
box-shadow: 0 4px 12px var(--mood-shadow);
transform: translateY(-2px);
}
.dashboard__entry-icon {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 0.75rem;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.dashboard__entry-title {
font-size: 1.125rem;
font-weight: 700;
color: var(--mood-text);
}
.dashboard__entry-count {
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-accent);
line-height: 1;
}
.dashboard__entry-total {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.dashboard__entry-onboard {
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.4;
max-width: 18rem;
}
.dashboard__entry-btn {
margin-top: 0.5rem;
}
/* --- Onboarding banner --- */
.dashboard__onboarding {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: var(--mood-accent-soft);
border: 1px solid var(--mood-border);
border-radius: 0.75rem;
}
.dashboard__onboarding-content {
display: flex;
align-items: flex-start;
gap: 0.75rem;
color: var(--mood-accent);
}
.dashboard__onboarding-text {
font-size: 0.875rem;
color: var(--mood-text);
font-weight: 500;
}
.dashboard__onboarding-hint {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin-top: 0.125rem;
}
/* --- Toolbox teaser --- */
.dashboard__toolbox-teaser {
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
}
.dashboard__toolbox-teaser-header {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mood-accent);
font-weight: 700;
font-size: 0.9375rem;
}
.dashboard__toolbox-teaser-header h3 {
margin: 0;
}
.dashboard__toolbox-teaser-description {
margin-top: 0.375rem;
font-size: 0.8125rem;
color: var(--mood-text-muted);
}
.dashboard__toolbox-teaser-tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.625rem;
}
.dashboard__toolbox-tag {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border: 1px solid var(--mood-border);
border-radius: 9999px;
text-decoration: none;
transition: background 0.15s ease;
}
.dashboard__toolbox-tag:hover {
background: var(--mood-surface-hover);
}
/* --- Recent activity --- */
.dashboard__activity {
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.75rem;
padding: 1rem 1.25rem;
}
.dashboard__activity-header {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--mood-text);
font-weight: 700;
font-size: 0.9375rem;
margin-bottom: 0.75rem;
}
.dashboard__activity-header h3 {
margin: 0;
}
.dashboard__activity-list {
display: flex;
flex-direction: column;
}
.dashboard__activity-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--mood-border);
text-decoration: none;
transition: background 0.15s ease;
}
.dashboard__activity-item:last-child {
border-bottom: none;
}
.dashboard__activity-item:hover {
background: var(--mood-surface-hover);
margin-left: -0.5rem;
margin-right: -0.5rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
border-radius: 0.375rem;
}
.dashboard__activity-item-main {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.dashboard__activity-item-title {
font-size: 0.8125rem;
font-weight: 500;
color: var(--mood-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dashboard__activity-item-date {
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
/* --- Formula explainer --- */
.dashboard__formula-trigger {
width: 100%;
justify-content: space-between;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.75rem;
}
.dashboard__formula-content {
padding: 0 1.25rem 1.25rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-top: none;
border-radius: 0 0 0.75rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dashboard__formula-description {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
}
.dashboard__formula-code {
display: block;
padding: 0.75rem;
background: var(--mood-accent-soft);
border-radius: 0.375rem;
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text);
overflow-x: auto;
}
.dashboard__formula-params {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
</style>

View File

@@ -200,7 +200,7 @@ async function handleDelete() {
<UBadge variant="subtle" color="primary">
{{ typeLabel(mandates.current.mandate_type) }}
</UBadge>
<CommonStatusBadge :status="mandates.current.status" type="mandate" />
<StatusBadge :status="mandates.current.status" type="mandate" />
</div>
</div>
@@ -323,7 +323,7 @@ async function handleDelete() {
Etapes du mandat
</h2>
<MandatesMandateTimeline
<MandateTimeline
:steps="mandates.current.steps"
:current-status="mandates.current.status"
/>

View File

@@ -1,46 +1,28 @@
<script setup lang="ts">
/**
* Mandats — page index.
*
* Utilise SectionLayout avec status filters, recherche,
* et sidebar "Boite a outils" affichant les protocoles de vote.
* Etat vide enrichi avec onboarding expliquant le concept de mandat.
*/
import type { MandateCreate } from '~/stores/mandates'
const mandates = useMandatesStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const filterType = ref<string | undefined>(undefined)
const filterStatus = ref<string | undefined>(undefined)
const activeStatus = ref<string | null>(null)
const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const typeOptions = [
{ label: 'Tous les types', value: undefined },
{ label: 'Comite technique', value: 'techcomm' },
{ label: 'Forgeron', value: 'smith' },
{ label: 'Personnalise', value: 'custom' },
const sortOptions = [
{ label: 'Date', value: 'date' },
{ label: 'Titre', value: 'title' },
{ label: 'Statut', value: 'status' },
]
const statusOptions = [
{ label: 'Tous les statuts', value: undefined },
{ label: 'Brouillon', value: 'draft' },
{ label: 'Candidature', value: 'candidacy' },
{ label: 'En vote', value: 'voting' },
{ label: 'Actif', value: 'active' },
{ label: 'Rapport', value: 'reporting' },
{ label: 'Termine', value: 'completed' },
{ label: 'Revoque', value: 'revoked' },
]
async function loadMandates() {
await mandates.fetchAll({
mandate_type: filterType.value,
status: filterStatus.value,
})
}
onMounted(() => {
loadMandates()
})
watch([filterType, filterStatus], () => {
loadMandates()
})
// --- Create mandate modal ---
// Create mandate modal state
const showCreateModal = ref(false)
const mandateTypeOptions = [
{ label: 'Comite technique', value: 'techcomm' },
@@ -55,6 +37,80 @@ const newMandate = ref<MandateCreate>({
})
const creating = ref(false)
onMounted(async () => {
await Promise.all([
mandates.fetchAll(),
protocols.fetchProtocols(),
])
})
/** Status filter pills with counts. */
const statuses = computed(() => [
{ id: 'draft', label: 'En prepa', count: mandates.list.filter(m => m.status === 'draft' || m.status === 'candidacy').length },
{ id: 'voting', label: 'En vote', count: mandates.list.filter(m => m.status === 'voting').length },
{ id: 'active', label: 'En vigueur', count: mandates.list.filter(m => m.status === 'active' || m.status === 'reporting').length },
{ id: 'closed', label: 'Clos', count: mandates.list.filter(m => m.status === 'completed' || m.status === 'revoked').length },
])
/** Map for status group filtering. */
const statusGroupMap: Record<string, string[]> = {
draft: ['draft', 'candidacy'],
voting: ['voting'],
active: ['active', 'reporting'],
closed: ['completed', 'revoked'],
}
/** Filtered and sorted mandates. */
const filteredMandates = computed(() => {
let list = [...mandates.list]
// Filter by status group
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
const allowedStatuses = statusGroupMap[activeStatus.value]
list = list.filter(m => allowedStatuses.includes(m.status))
}
// Filter by search query (client-side)
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
list = list.filter(m => m.title.toLowerCase().includes(q))
}
// Sort
switch (sortBy.value) {
case 'title':
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
break
case 'status':
list.sort((a, b) => a.status.localeCompare(b.status))
break
case 'date':
default:
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
break
}
return list
})
const typeLabel = (mandateType: string) => {
switch (mandateType) {
case 'techcomm': return 'Comite technique'
case 'smith': return 'Forgeron'
case 'custom': return 'Personnalise'
default: return mandateType
}
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
async function handleCreate() {
creating.value = true
try {
@@ -64,99 +120,189 @@ async function handleCreate() {
if (mandate) {
navigateTo(`/mandates/${mandate.id}`)
}
} catch {
}
catch {
// Error handled by store
} finally {
}
finally {
creating.value = false
}
}
</script>
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Mandats
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Mandats de gouvernance : comite technique, forgerons et roles specifiques
</p>
</div>
<SectionLayout
title="Mandats"
subtitle="Un contexte, un objectif, une duree, une ou plusieurs nominations ; par defaut : nomination d'un binome."
:statuses="statuses"
:active-status="activeStatus"
@update:active-status="activeStatus = $event"
>
<!-- Search / sort bar -->
<template #search>
<UInput
v-model="searchQuery"
placeholder="Rechercher un mandat..."
icon="i-lucide-search"
size="sm"
class="w-full sm:w-64"
/>
<USelect
v-model="sortBy"
:items="sortOptions"
size="sm"
class="w-36"
/>
<UButton
v-if="auth.isAuthenticated"
label="Nouveau"
icon="i-lucide-plus"
label="Nouveau mandat"
color="primary"
size="sm"
@click="showCreateModal = true"
/>
</div>
</template>
<!-- Filters -->
<div class="flex flex-wrap gap-4">
<USelect
v-model="filterType"
:items="typeOptions"
placeholder="Type de mandat"
class="w-56"
/>
<USelect
v-model="filterStatus"
:items="statusOptions"
placeholder="Statut"
class="w-48"
/>
<!-- Main content: mandates list -->
<template #default>
<!-- Error state -->
<div v-if="mandates.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ mandates.error }}</p>
</div>
<!-- Loading state -->
<template v-if="mandates.loading">
<div class="space-y-3">
<USkeleton v-for="i in 4" :key="i" class="h-12 w-full" />
<div v-else-if="mandates.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
</div>
</template>
<!-- Error state -->
<template v-else-if="mandates.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ mandates.error }}</p>
<!-- Onboarding empty state -->
<div
v-else-if="mandates.list.length === 0 && !activeStatus && !searchQuery"
class="mandate-onboarding"
>
<div class="mandate-onboarding__icon">
<UIcon name="i-lucide-user-check" class="text-3xl" />
</div>
</UCard>
</template>
<!-- Empty state -->
<template v-else-if="mandates.list.length === 0">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-user-check" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun mandat pour le moment</p>
</div>
</UCard>
</template>
<!-- Mandates list -->
<template v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<MandatesMandateCard
v-for="mandate in mandates.list"
:key="mandate.id"
:mandate="mandate"
<h3 class="mandate-onboarding__title">
Qu'est-ce qu'un mandat ?
</h3>
<p class="mandate-onboarding__text">
Un mandat definit un contexte, un objectif et une duree pour une mission de gouvernance.
Il peut porter sur le comite technique, les forgerons, ou tout role specifique de la communaute.
</p>
<p class="mandate-onboarding__text">
Par defaut, un mandat nomme un binome pour assurer la continuite.
Le processus comprend : candidature, vote communautaire, periode active et rapport final.
</p>
<div class="mandate-onboarding__actions">
<UButton
v-if="auth.isAuthenticated"
label="Creer un premier mandat"
icon="i-lucide-plus"
color="primary"
size="sm"
@click="showCreateModal = true"
/>
<UButton
to="/protocols"
label="Decouvrir les protocoles"
variant="outline"
size="sm"
icon="i-lucide-wrench"
/>
</div>
</div>
<!-- Filtered empty state -->
<div
v-else-if="filteredMandates.length === 0"
class="text-center py-12"
style="color: var(--mood-text-muted);"
>
<UIcon name="i-lucide-user-check" class="text-4xl mb-3 block mx-auto" />
<p>Aucun mandat trouve</p>
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
Essayez de modifier vos filtres
</p>
</div>
<!-- Mandate cards -->
<div v-else class="space-y-3">
<div
v-for="mandate in filteredMandates"
:key="mandate.id"
class="mandate-card"
@click="navigateTo(`/mandates/${mandate.id}`)"
>
<div class="mandate-card__header">
<div class="mandate-card__title-block">
<h3 class="mandate-card__title">
{{ mandate.title }}
</h3>
<p v-if="mandate.description" class="mandate-card__description">
{{ mandate.description }}
</p>
</div>
<StatusBadge :status="mandate.status" type="mandate" />
</div>
<div class="mandate-card__meta">
<UBadge variant="subtle" color="primary" size="xs">
{{ typeLabel(mandate.mandate_type) }}
</UBadge>
<span class="mandate-card__steps">
<UIcon name="i-lucide-layers" class="text-xs" />
{{ mandate.steps.length }} etape{{ mandate.steps.length !== 1 ? 's' : '' }}
</span>
<span v-if="mandate.mandatee_id" class="mandate-card__mandatee">
<UIcon name="i-lucide-user" class="text-xs" />
{{ mandate.mandatee_id.slice(0, 8) }}...
</span>
</div>
<div class="mandate-card__dates">
<span>Debut : {{ formatDate(mandate.starts_at) }}</span>
<span>Fin : {{ formatDate(mandate.ends_at) }}</span>
</div>
</div>
</div>
</template>
<!-- Toolbox sidebar -->
<template #toolbox>
<div class="toolbox-section-title">
Modalites de vote
</div>
<template v-if="protocols.protocols.length > 0">
<ToolboxVignette
v-for="protocol in protocols.protocols"
:key="protocol.id"
:title="protocol.name"
:description="protocol.description || undefined"
context-label="Mandats"
:actions="[
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
]"
/>
</template>
<p v-else class="toolbox-empty-text">
Aucun protocole configure
</p>
</template>
</SectionLayout>
<!-- Create mandate modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<form class="p-6 space-y-4" @submit.prevent="handleCreate">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<h3 class="text-lg font-semibold" style="color: var(--mood-text);">
Nouveau mandat
</h3>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Titre <span class="text-red-500">*</span>
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Titre <span style="color: var(--mood-error);">*</span>
</label>
<UInput
v-model="newMandate.title"
@@ -166,7 +312,7 @@ async function handleCreate() {
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Description
</label>
<UTextarea
@@ -177,8 +323,8 @@ async function handleCreate() {
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Type de mandat <span class="text-red-500">*</span>
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Type de mandat <span style="color: var(--mood-error);">*</span>
</label>
<USelect
v-model="newMandate.mandate_type"
@@ -186,7 +332,7 @@ async function handleCreate() {
/>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<div class="flex justify-end gap-2 pt-4" style="border-top: 1px solid var(--mood-border);">
<UButton
label="Annuler"
variant="ghost"
@@ -205,5 +351,141 @@ async function handleCreate() {
</form>
</template>
</UModal>
</div>
</template>
<style scoped>
.mandate-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.875rem 1rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
}
.mandate-card:hover {
border-color: var(--mood-accent);
box-shadow: 0 2px 8px var(--mood-shadow);
}
.mandate-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.mandate-card__title-block {
flex: 1;
min-width: 0;
}
.mandate-card__title {
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
line-height: 1.3;
}
.mandate-card__description {
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.mandate-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.mandate-card__steps {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.mandate-card__mandatee {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
.mandate-card__dates {
display: flex;
gap: 1rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
}
/* Onboarding empty state */
.mandate-onboarding {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 2.5rem 1.5rem;
background: var(--mood-surface);
border: 1px dashed var(--mood-border);
border-radius: 0.75rem;
}
.mandate-onboarding__icon {
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
background: var(--mood-accent-soft);
color: var(--mood-accent);
}
.mandate-onboarding__title {
font-size: 1.125rem;
font-weight: 700;
color: var(--mood-text);
}
.mandate-onboarding__text {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
max-width: 32rem;
}
.mandate-onboarding__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.toolbox-section-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.toolbox-empty-text {
font-size: 0.75rem;
color: var(--mood-text-muted);
}
</style>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
/**
* Protocols index page.
* Boite a outils — page principale des protocoles de vote.
*
* Lists all voting protocols with ModeParamsDisplay component,
* links to protocol detail pages, and provides creation modal
* and simulator link.
* Liste les protocoles avec leurs details complets,
* les configurations de formule, et le simulateur / explainer.
*/
const protocols = useProtocolsStore()
const auth = useAuthStore()
const showCreateModal = ref(false)
const creating = ref(false)
const formulaOpen = ref(false)
/** Creation form state. */
const newProtocol = reactive({
@@ -84,25 +84,27 @@ async function createProtocol() {
})
showCreateModal.value = false
await protocols.fetchProtocols()
} finally {
}
finally {
creating.value = false
}
}
</script>
<template>
<div class="space-y-8">
<div class="toolbox-page">
<!-- Header -->
<div class="flex items-start justify-between">
<div class="toolbox-page__header">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Protocoles de vote
<h1 class="toolbox-page__title">
<UIcon name="i-lucide-wrench" class="toolbox-page__title-icon" />
Boite a outils
</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Configuration des protocoles de vote et formules de seuil WoT
<p class="toolbox-page__subtitle">
Modalites de vote et formules configurables
</p>
</div>
<div class="flex items-center gap-3">
<div class="toolbox-page__actions">
<NuxtLink to="/protocols/formulas">
<UButton variant="outline" icon="i-lucide-calculator" size="sm">
Simulateur de formules
@@ -122,56 +124,46 @@ async function createProtocol() {
<!-- Loading state -->
<template v-if="protocols.loading">
<div class="space-y-3">
<USkeleton v-for="i in 4" :key="i" class="h-32 w-full" />
<LoadingSkeleton v-for="i in 4" :key="i" :lines="4" card />
</div>
</template>
<!-- Error state -->
<template v-else-if="protocols.error">
<UCard>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ protocols.error }}</p>
<div class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<p style="color: var(--mood-text);">{{ protocols.error }}</p>
</div>
</UCard>
</template>
<template v-else>
<!-- Voting Protocols -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Protocoles ({{ protocols.protocols.length }})
<!-- Protocols section -->
<div class="toolbox-section">
<h2 class="toolbox-section__title">
Protocoles de vote
<UBadge variant="subtle" size="xs">
{{ protocols.protocols.length }}
</UBadge>
</h2>
<div v-if="protocols.protocols.length === 0">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-settings" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucun protocole de vote configure</p>
</div>
</UCard>
<div v-if="protocols.protocols.length === 0" class="toolbox-empty">
<UIcon name="i-lucide-settings" class="text-3xl" />
<p>Aucun protocole de vote configure</p>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div v-else class="toolbox-protocol-grid">
<NuxtLink
v-for="protocol in protocols.protocols"
:key="protocol.id"
:to="`/protocols/${protocol.id}`"
class="block"
class="protocol-card"
>
<UCard class="hover:ring-2 hover:ring-primary-300 dark:hover:ring-primary-700 transition-all cursor-pointer">
<div class="space-y-4">
<!-- Protocol header -->
<div class="flex items-start justify-between">
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
<div class="protocol-card__header">
<h3 class="protocol-card__name">
{{ protocol.name }}
</h3>
<p v-if="protocol.description" class="text-sm text-gray-500 mt-0.5">
{{ protocol.description }}
</p>
</div>
<div class="flex items-center gap-2">
<div class="protocol-card__badges">
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle" size="xs">
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
@@ -181,134 +173,116 @@ async function createProtocol() {
</div>
</div>
<!-- Mode params with component -->
<div v-if="protocol.mode_params">
<p v-if="protocol.description" class="protocol-card__description">
{{ protocol.description }}
</p>
<!-- Mode params -->
<div v-if="protocol.mode_params" class="protocol-card__mode-params">
<ModeParamsDisplay :mode-params="protocol.mode_params" />
</div>
<!-- Formula config summary -->
<div class="border-t border-gray-100 dark:border-gray-800 pt-3">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
<div class="protocol-card__formula">
<h4 class="protocol-card__formula-title">
Formule : {{ protocol.formula_config.name }}
</h4>
<div class="grid grid-cols-3 gap-2 text-xs">
<div class="protocol-card__formula-grid">
<div>
<span class="text-gray-400 block">Duree</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.duration_days }}j
</span>
<span class="protocol-card__formula-label">Duree</span>
<span class="protocol-card__formula-value">{{ protocol.formula_config.duration_days }}j</span>
</div>
<div>
<span class="text-gray-400 block">Majorite</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.majority_pct }}%
</span>
<span class="protocol-card__formula-label">Majorite</span>
<span class="protocol-card__formula-value">{{ protocol.formula_config.majority_pct }}%</span>
</div>
<div>
<span class="text-gray-400 block">Base</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.base_exponent }}
</span>
<span class="protocol-card__formula-label">Base</span>
<span class="protocol-card__formula-value">{{ protocol.formula_config.base_exponent }}</span>
</div>
<div>
<span class="text-gray-400 block">Gradient</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.gradient_exponent }}
</span>
<span class="protocol-card__formula-label">Gradient</span>
<span class="protocol-card__formula-value">{{ protocol.formula_config.gradient_exponent }}</span>
</div>
<div v-if="protocol.formula_config.smith_exponent !== null">
<span class="text-gray-400 block">Smith</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.smith_exponent }}
</span>
<span class="protocol-card__formula-label">Smith</span>
<span class="protocol-card__formula-value">{{ protocol.formula_config.smith_exponent }}</span>
</div>
<div v-if="protocol.formula_config.techcomm_exponent !== null">
<span class="text-gray-400 block">TechComm</span>
<span class="font-medium text-gray-900 dark:text-white">
{{ protocol.formula_config.techcomm_exponent }}
</span>
<span class="protocol-card__formula-label">TechComm</span>
<span class="protocol-card__formula-value">{{ protocol.formula_config.techcomm_exponent }}</span>
</div>
</div>
</div>
</div>
</UCard>
</NuxtLink>
</div>
</div>
<!-- Formula Configurations -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Configurations de formule ({{ protocols.formulas.length }})
<!-- Formula configurations section -->
<div class="toolbox-section">
<h2 class="toolbox-section__title">
Configurations de formule
<UBadge variant="subtle" size="xs">
{{ protocols.formulas.length }}
</UBadge>
</h2>
<div v-if="protocols.formulas.length === 0">
<UCard>
<div class="text-center py-8">
<UIcon name="i-lucide-calculator" class="text-4xl text-gray-400 mb-3" />
<p class="text-gray-500">Aucune configuration de formule</p>
</div>
</UCard>
<div v-if="protocols.formulas.length === 0" class="toolbox-empty">
<UIcon name="i-lucide-calculator" class="text-3xl" />
<p>Aucune configuration de formule</p>
</div>
<div v-else>
<UCard>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<div v-else class="formula-table-wrapper">
<table class="formula-table">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="text-left px-4 py-3 font-medium text-gray-500">Nom</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Duree</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Majorite</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">B</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">G</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">C</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Smith</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">TechComm</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th>
<tr>
<th>Nom</th>
<th>Duree</th>
<th>Majorite</th>
<th>B</th>
<th>G</th>
<th>C</th>
<th>Smith</th>
<th>TechComm</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr
v-for="formula in protocols.formulas"
:key="formula.id"
class="border-b border-gray-100 dark:border-gray-800"
>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ formula.name }}
</td>
<td class="px-4 py-3 text-gray-600">{{ formula.duration_days }}j</td>
<td class="px-4 py-3 text-gray-600">{{ formula.majority_pct }}%</td>
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.base_exponent }}</td>
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.gradient_exponent }}</td>
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.constant_base }}</td>
<td class="px-4 py-3 font-mono text-gray-600">
{{ formula.smith_exponent ?? '-' }}
</td>
<td class="px-4 py-3 font-mono text-gray-600">
{{ formula.techcomm_exponent ?? '-' }}
</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{{ formatDate(formula.created_at) }}
</td>
<tr v-for="formula in protocols.formulas" :key="formula.id">
<td class="formula-table__name">{{ formula.name }}</td>
<td>{{ formula.duration_days }}j</td>
<td>{{ formula.majority_pct }}%</td>
<td class="font-mono">{{ formula.base_exponent }}</td>
<td class="font-mono">{{ formula.gradient_exponent }}</td>
<td class="font-mono">{{ formula.constant_base }}</td>
<td class="font-mono">{{ formula.smith_exponent ?? '-' }}</td>
<td class="font-mono">{{ formula.techcomm_exponent ?? '-' }}</td>
<td class="formula-table__date">{{ formatDate(formula.created_at) }}</td>
</tr>
</tbody>
</table>
</div>
</UCard>
</div>
</div>
<!-- Formula explainer -->
<UCard>
<div class="space-y-3">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">
Reference : Formule de seuil WoT
</h3>
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono">
<!-- Formula explainer collapsible -->
<UCollapsible v-model:open="formulaOpen">
<UButton
variant="ghost"
size="sm"
:icon="formulaOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="formula-explainer-trigger"
>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-book-open" />
<span>Reference : Formule de seuil WoT</span>
</div>
</UButton>
<template #content>
<div class="formula-explainer-content">
<code class="formula-explainer-code">
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
</code>
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 text-xs text-gray-500">
<div class="formula-explainer-params">
<div><strong>C</strong> = constante de base (plancher fixe)</div>
<div><strong>B</strong> = exposant de base (B^W tend vers 0 si B &lt; 1)</div>
<div><strong>W</strong> = taille du corpus WoT</div>
@@ -316,35 +290,50 @@ async function createProtocol() {
<div><strong>M</strong> = ratio de majorite (M = majorite_pct / 100)</div>
<div><strong>G</strong> = exposant du gradient d'inertie</div>
</div>
<USeparator />
<p class="formula-explainer-note">
L'inertie fonctionne ainsi : faible participation implique quasi-unanimite requise ;
forte participation permet une majorite simple suffisante.
Cela protege contre les votes precipites tout en permettant l'efficacite
lorsque la communaute est mobilisee.
</p>
<UButton
to="/protocols/formulas"
label="Ouvrir le simulateur"
variant="outline"
size="xs"
icon="i-lucide-calculator"
/>
</div>
</UCard>
</template>
</UCollapsible>
</template>
<!-- Create protocol modal -->
<UModal v-model:open="showCreateModal">
<template #content>
<div class="p-6 space-y-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<h3 class="text-lg font-semibold" style="color: var(--mood-text);">
Nouveau protocole de vote
</h3>
<div class="space-y-4">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Nom du protocole
</label>
<UInput v-model="newProtocol.name" placeholder="Ex: Vote standard G1" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Description (optionnel)
</label>
<UTextarea v-model="newProtocol.description" placeholder="Description du protocole..." :rows="2" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Type de vote
</label>
<USelect
@@ -355,7 +344,7 @@ async function createProtocol() {
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Configuration de formule
</label>
<USelect
@@ -384,3 +373,248 @@ async function createProtocol() {
</UModal>
</div>
</template>
<style scoped>
.toolbox-page {
display: flex;
flex-direction: column;
gap: 2rem;
}
/* --- Header --- */
.toolbox-page__header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.toolbox-page__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-text);
}
.toolbox-page__title-icon {
color: var(--mood-accent);
}
.toolbox-page__subtitle {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--mood-text-muted);
}
.toolbox-page__actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
/* --- Section --- */
.toolbox-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.toolbox-section__title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--mood-text);
}
.toolbox-empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
color: var(--mood-text-muted);
background: var(--mood-surface);
border: 1px dashed var(--mood-border);
border-radius: 0.75rem;
}
/* --- Protocol cards --- */
.toolbox-protocol-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 1rem;
}
.protocol-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.75rem;
text-decoration: none;
transition: all 0.2s ease;
cursor: pointer;
}
.protocol-card:hover {
border-color: var(--mood-accent);
box-shadow: 0 4px 12px var(--mood-shadow);
}
.protocol-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.protocol-card__name {
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-text);
}
.protocol-card__badges {
display: flex;
gap: 0.375rem;
flex-shrink: 0;
}
.protocol-card__description {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.4;
}
.protocol-card__mode-params {
padding-top: 0.25rem;
}
.protocol-card__formula {
padding-top: 0.75rem;
border-top: 1px solid var(--mood-border);
}
.protocol-card__formula-title {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.5rem;
}
.protocol-card__formula-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
font-size: 0.75rem;
}
.protocol-card__formula-label {
display: block;
color: var(--mood-text-muted);
font-size: 0.625rem;
}
.protocol-card__formula-value {
font-weight: 600;
color: var(--mood-text);
}
/* --- Formula table --- */
.formula-table-wrapper {
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.75rem;
overflow-x: auto;
}
.formula-table {
width: 100%;
font-size: 0.8125rem;
border-collapse: collapse;
}
.formula-table th {
text-align: left;
padding: 0.75rem 1rem;
font-weight: 600;
font-size: 0.75rem;
color: var(--mood-text-muted);
border-bottom: 1px solid var(--mood-border);
}
.formula-table td {
padding: 0.625rem 1rem;
color: var(--mood-text-muted);
border-bottom: 1px solid var(--mood-border);
}
.formula-table tr:last-child td {
border-bottom: none;
}
.formula-table__name {
font-weight: 600;
color: var(--mood-text) !important;
}
.formula-table__date {
font-size: 0.6875rem;
}
/* --- Formula explainer --- */
.formula-explainer-trigger {
width: 100%;
justify-content: space-between;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 0.75rem;
}
.formula-explainer-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0 1.25rem 1.25rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-top: none;
border-radius: 0 0 0.75rem 0.75rem;
}
.formula-explainer-code {
display: block;
padding: 0.75rem;
background: var(--mood-accent-soft);
border-radius: 0.375rem;
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text);
overflow-x: auto;
}
.formula-explainer-params {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.375rem;
font-size: 0.75rem;
color: var(--mood-text-muted);
}
.formula-explainer-note {
font-size: 0.8125rem;
color: var(--mood-text-muted);
line-height: 1.5;
font-style: italic;
}
</style>

View File

@@ -146,7 +146,7 @@ watch(filterType, () => {
<template v-else>
<div class="space-y-4">
<div v-for="entry in entries" :key="entry.id" class="relative">
<SanctuarySanctuaryEntry
<SanctuaryEntry
:entry="entry"
@verify="handleVerify"
/>

View File

@@ -4,6 +4,7 @@ export default defineNuxtConfig({
devtools: { enabled: true },
devServer: { port: 3002 },
components: [{ path: '~/components', pathPrefix: false }],
css: ['~/assets/css/moods.css'],
modules: [
'@nuxt/ui',
'@pinia/nuxt',

17579
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,7 @@
"dependencies": {
"@nuxt/content": "^3.11.2",
"@nuxt/ui": "^3.1.0",
"@pinia/nuxt": "^0.9.0",
"@pinia/nuxt": "^0.11.0",
"@unocss/nuxt": "^66.6.0",
"@vueuse/nuxt": "^14.2.1",
"nuxt": "^4.3.1",

5
frontend/uno.config.ts Normal file
View File

@@ -0,0 +1,5 @@
import { defineConfig } from 'unocss'
export default defineConfig({
// UnoCSS config - Nuxt UI v3 handles most styling
})