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 ENVIRONMENT: str = "development" # development, staging, production
LOG_LEVEL: str = "INFO" LOG_LEVEL: str = "INFO"
# Database # Database — SQLite by default for local dev, PostgreSQL for Docker/prod
DATABASE_URL: str = "postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision" DATABASE_URL: str = "sqlite+aiosqlite:///./glibredecision.db"
DATABASE_POOL_SIZE: int = 20 DATABASE_POOL_SIZE: int = 20
DATABASE_MAX_OVERFLOW: int = 10 DATABASE_MAX_OVERFLOW: int = 10

View File

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

View File

@@ -1,8 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import String, DateTime, func from sqlalchemy import String, DateTime, JSON, Uuid, func
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base from app.database import Base
@@ -11,8 +10,8 @@ from app.database import Base
class BlockchainCache(Base): class BlockchainCache(Base):
__tablename__ = "blockchain_cache" __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_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()) fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -11,7 +10,7 @@ from app.database import Base
class Mandate(Base): class Mandate(Base):
__tablename__ = "mandates" __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) title: Mapped[str] = mapped_column(String(256), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
@@ -29,7 +28,7 @@ class Mandate(Base):
class MandateStep(Base): class MandateStep(Base):
__tablename__ = "mandate_steps" __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) mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
step_order: Mapped[int] = mapped_column(Integer, 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 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 import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import String, Integer, Float, Boolean, DateTime, ForeignKey, Text, func from sqlalchemy import String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Uuid, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -11,7 +10,7 @@ from app.database import Base
class FormulaConfig(Base): class FormulaConfig(Base):
__tablename__ = "formula_configs" __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) name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
@@ -40,7 +39,7 @@ class FormulaConfig(Base):
class VotingProtocol(Base): class VotingProtocol(Base):
__tablename__ = "voting_protocols" __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) name: Mapped[str] = mapped_column(String(128), nullable=False)
description: Mapped[str | None] = mapped_column(Text) description: Mapped[str | None] = mapped_column(Text)
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced

View File

@@ -1,8 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, func from sqlalchemy import String, Integer, Text, DateTime, Uuid, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base from app.database import Base
@@ -11,9 +10,9 @@ from app.database import Base
class SanctuaryEntry(Base): class SanctuaryEntry(Base):
__tablename__ = "sanctuary_entries" __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 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)) title: Mapped[str | None] = mapped_column(String(256))
content_hash: Mapped[str] = mapped_column(String(128), nullable=False) # SHA-256 content_hash: Mapped[str] = mapped_column(String(128), nullable=False) # SHA-256
ipfs_cid: Mapped[str | None] = mapped_column(String(128)) ipfs_cid: Mapped[str | None] = mapped_column(String(128))

View File

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

View File

@@ -1,8 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey, func from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey, Uuid, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -11,7 +10,7 @@ from app.database import Base
class VoteSession(Base): class VoteSession(Base):
__tablename__ = "vote_sessions" __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")) decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
item_version_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("item_versions.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) voting_protocol_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("voting_protocols.id"), nullable=False)
@@ -49,7 +48,7 @@ class VoteSession(Base):
class Vote(Base): class Vote(Base):
__tablename__ = "votes" __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) 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) 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 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 import secrets
from datetime import datetime, timedelta, timezone 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 sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings from app.config import settings
@@ -133,11 +133,11 @@ async def get_me(
return IdentityOut.model_validate(identity) 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( async def logout(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
identity: DuniterIdentity = Depends(get_current_identity), identity: DuniterIdentity = Depends(get_current_identity),
) -> None: ):
"""Invalidate the current session token. """Invalidate the current session token.
Note: get_current_identity already validated the token, so we know it exists. 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 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -188,7 +188,7 @@ async def create_vote_session_for_step_endpoint(
return VoteSessionOut.model_validate(session) 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( async def delete_decision(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),

View File

@@ -6,7 +6,7 @@ import difflib
import logging import logging
import uuid 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 import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -316,7 +316,7 @@ async def update_item(
return DocumentItemOut.model_validate(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( async def delete_item(
slug: str, slug: str,
item_id: uuid.UUID, item_id: uuid.UUID,

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import uuid 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -124,7 +124,7 @@ async def update_mandate(
return MandateOut.model_validate(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( async def delete_mandate(
id: uuid.UUID, id: uuid.UUID,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),

View File

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

View File

@@ -1,37 +1,73 @@
version: "3.9" # Development stack -- standalone (no Traefik needed)
# Usage: docker compose -f docker/docker-compose.dev.yml up
# Dev overrides -- usage: # Ports: frontend 3002, backend 8002, postgres 5432, IPFS API 5001, IPFS GW 8080
# docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up
services: services:
postgres: 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: ports:
- "5432:5432" - "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: backend:
build: build:
context: ../
dockerfile: docker/backend.Dockerfile
target: development target: development
depends_on:
postgres:
condition: service_healthy
volumes: volumes:
- ../backend:/app - ../backend:/app
ports: ports:
- "8002:8002" - "8002:8002"
environment: 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" DEBUG: "true"
ENVIRONMENT: development
CORS_ORIGINS: '["http://localhost:3002"]' 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: frontend:
build: build:
context: ../
dockerfile: docker/frontend.Dockerfile
target: development target: development
depends_on:
- backend
volumes: volumes:
- ../frontend:/app - ../frontend:/app
- frontend-node-modules:/app/node_modules
ports: ports:
- "3002:3002" - "3002:3002"
environment: environment:
NUXT_PUBLIC_API_BASE: http://localhost:8002/api/v1 NUXT_PUBLIC_API_BASE: http://localhost:8002/api/v1
labels: []
ipfs: ipfs:
image: ipfs/kubo:latest
restart: unless-stopped
ports: ports:
- "5001:5001" - "5001:5001"
- "8080:8080" - "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 ENV NODE_ENV=development
WORKDIR /app 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"> <script setup lang="ts">
const auth = useAuthStore() const auth = useAuthStore()
const route = useRoute() const route = useRoute()
const { initMood } = useMood()
const navigationItems = [ const navigationItems = [
{ {
@@ -43,6 +44,9 @@ const ws = useWebSocket()
const { setupWsNotifications } = useNotifications() const { setupWsNotifications } = useNotifications()
onMounted(async () => { onMounted(async () => {
// Apply saved mood / ambiance
initMood()
// Hydrate auth from localStorage // Hydrate auth from localStorage
auth.hydrateFromStorage() auth.hydrateFromStorage()
if (auth.token) { if (auth.token) {
@@ -68,7 +72,13 @@ onUnmounted(() => {
<!-- Offline detection banner --> <!-- Offline detection banner -->
<OfflineBanner /> <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 -->
<header class="sticky top-0 z-30 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900"> <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"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
@@ -92,6 +102,9 @@ onUnmounted(() => {
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Center: Mood switcher -->
<MoodSwitcher class="hidden sm:flex" />
<!-- Right: Auth controls --> <!-- Right: Auth controls -->
<div class="flex items-center gap-2 sm:gap-4"> <div class="flex items-center gap-2 sm:gap-4">
<template v-if="auth.isAuthenticated"> <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"> <script setup lang="ts">
const props = defineProps<{ const props = withDefaults(defineProps<{
status: string 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 }>> = { const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
document: { // Universal statuses
draft: { color: 'warning', label: 'Brouillon' }, draft: { label: 'En prepa', cssClass: 'status-prepa' },
active: { color: 'success', label: 'Actif' }, active: { label: 'En vigueur', cssClass: 'status-vigueur' },
archived: { color: 'neutral', label: 'Archive' }, closed: { label: 'Clos', cssClass: 'status-clos' },
},
version: { // Decision/vote specific
proposed: { color: 'info', label: 'Propose' }, qualification: { label: 'En prepa', cssClass: 'status-prepa' },
voting: { color: 'warning', label: 'En vote' }, review: { label: 'En prepa', cssClass: 'status-prepa' },
accepted: { color: 'success', label: 'Accepte' }, voting: { label: 'En vote', cssClass: 'status-vote' },
rejected: { color: 'error', label: 'Rejete' }, open: { label: 'En vote', cssClass: 'status-vote' },
}, executed: { label: 'En vigueur', cssClass: 'status-vigueur' },
decision: {
draft: { color: 'warning', label: 'Brouillon' }, // Version specific
qualification: { color: 'info', label: 'Qualification' }, pending: { label: 'En prepa', cssClass: 'status-prepa' },
review: { color: 'info', label: 'Revue' }, accepted: { label: 'En vigueur', cssClass: 'status-vigueur' },
voting: { color: 'primary', label: 'En vote' }, rejected: { label: 'Clos', cssClass: 'status-clos' },
executed: { color: 'success', label: 'Execute' },
closed: { color: 'neutral', label: 'Clos' }, // Mandate specific
}, formulation: { label: 'En prepa', cssClass: 'status-prepa' },
mandate: { candidature: { label: 'En prepa', cssClass: 'status-prepa' },
draft: { color: 'warning', label: 'Brouillon' }, investiture: { label: 'En vote', cssClass: 'status-vote' },
candidacy: { color: 'info', label: 'Candidature' }, revoked: { label: 'Clos', cssClass: 'status-clos' },
voting: { color: 'primary', label: 'En vote' }, completed: { label: 'Clos', cssClass: 'status-clos' },
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 resolved = computed(() => { const resolved = computed(() => {
const typeKey = props.type || 'document' return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
const typeMap = statusConfig[typeKey]
if (typeMap && typeMap[props.status]) {
return typeMap[props.status]
}
return { color: 'neutral', label: props.status }
}) })
function handleClick() {
if (props.clickable) {
emit('click')
}
}
</script> </script>
<template> <template>
<UBadge <button
:color="(resolved.color as any)" v-if="clickable"
variant="subtle" type="button"
size="xs" class="status-pill"
:class="[resolved.cssClass, { 'status-pill--active': active }]"
@click="handleClick"
> >
{{ resolved.label }} {{ resolved.label }}
</UBadge> </button>
<span
v-else
class="status-pill"
:class="[resolved.cssClass]"
>
{{ resolved.label }}
</span>
</template> </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"> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Protocole de vote Protocole de vote
</label> </label>
<ProtocolsProtocolPicker <ProtocolPicker
:model-value="modelValue.voting_protocol_id ?? null" :model-value="modelValue.voting_protocol_id ?? null"
@update:model-value="updateField('voting_protocol_id', $event)" @update:model-value="updateField('voting_protocol_id', $event)"
/> />

View File

@@ -48,7 +48,7 @@ function navigate() {
{{ decision.title }} {{ decision.title }}
</h3> </h3>
</div> </div>
<CommonStatusBadge :status="decision.status" type="decision" /> <StatusBadge :status="decision.status" type="decision" />
</div> </div>
<p v-if="decision.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"> <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) }} {{ stepTypeLabel(step.step_type) }}
</UBadge> </UBadge>
</div> </div>
<CommonStatusBadge :status="step.status" type="decision" /> <StatusBadge :status="step.status" type="decision" />
</div> </div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white"> <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"> <h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
{{ doc.title }} {{ doc.title }}
</h3> </h3>
<CommonStatusBadge :status="doc.status" type="document" /> <StatusBadge :status="doc.status" type="document" />
</div> </div>
<!-- Type + Version --> <!-- Type + Version -->

View File

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

View File

@@ -35,7 +35,7 @@ function truncateAddress(address: string | null): string {
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex items-center gap-3"> <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"> <span class="text-sm text-gray-500">
Propose par Propose par
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs"> <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 --> <!-- Diff view -->
<div v-if="version.diff"> <div v-if="version.diff">
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p> <p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
<CommonDiffView :diff="version.diff" /> <DiffView :diff="version.diff" />
</div> </div>
<!-- Proposed text (fallback if no diff) --> <!-- Proposed text (fallback if no diff) -->
<div v-else-if="version.proposed_text"> <div v-else-if="version.proposed_text">
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p> <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"> <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>
</div> </div>

View File

@@ -47,7 +47,7 @@ function navigate() {
{{ mandate.title }} {{ mandate.title }}
</h3> </h3>
</div> </div>
<CommonStatusBadge :status="mandate.status" type="mandate" /> <StatusBadge :status="mandate.status" type="mandate" />
</div> </div>
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"> <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) }} {{ stepTypeLabel(step.step_type) }}
</UBadge> </UBadge>
</div> </div>
<CommonStatusBadge :status="step.status" type="mandate" /> <StatusBadge :status="step.status" type="mandate" />
</div> </div>
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white"> <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> <span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
</div> </div>
<div @click.stop> <div @click.stop>
<SanctuaryIPFSLink :cid="entry.ipfs_cid" /> <IPFSLink :cid="entry.ipfs_cid" />
</div> </div>
</div> </div>
@@ -131,7 +131,7 @@ async function copyHash() {
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" /> <UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span> <span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
</div> </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>
</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> </div>
<DecisionsDecisionWorkflow <DecisionWorkflow
:steps="decisions.current.steps" :steps="decisions.current.steps"
:current-status="decisions.current.status" :current-status="decisions.current.status"
@create-vote-session="handleCreateVoteSession" @create-vote-session="handleCreateVoteSession"

View File

@@ -1,67 +1,80 @@
<script setup lang="ts"> <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 decisions = useDecisionsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const filterType = ref<string | undefined>(undefined) const activeStatus = ref<string | null>(null)
const filterStatus = ref<string | undefined>(undefined) const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const typeOptions = [ const sortOptions = [
{ label: 'Tous les types', value: undefined }, { label: 'Date', value: 'date' },
{ label: 'Runtime upgrade', value: 'runtime_upgrade' }, { label: 'Titre', value: 'title' },
{ label: 'Modification de document', value: 'document_change' }, { label: 'Statut', value: 'status' },
{ label: 'Vote de mandat', value: 'mandate_vote' },
{ label: 'Changement de parametre', value: 'parameter_change' },
{ label: 'Autre', value: 'other' },
] ]
const statusOptions = [ onMounted(async () => {
{ label: 'Tous les statuts', value: undefined }, await Promise.all([
{ label: 'Brouillon', value: 'draft' }, decisions.fetchAll(),
{ label: 'Qualification', value: 'qualification' }, protocols.fetchProtocols(),
{ 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()
}) })
watch([filterType, filterStatus], () => { /** Status filter pills with counts. */
loadDecisions() 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) => { const typeLabel = (decisionType: string) => {
switch (decisionType) { switch (decisionType) {
case 'runtime_upgrade': return 'Runtime upgrade' case 'runtime_upgrade': return 'Runtime upgrade'
@@ -83,121 +96,204 @@ function formatDate(dateStr: string): string {
</script> </script>
<template> <template>
<div class="space-y-6"> <SectionLayout
<!-- Header --> title="Decisions"
<div class="flex items-start justify-between"> subtitle="Processus de decision collectifs"
<div> :statuses="statuses"
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> :active-status="activeStatus"
Decisions @update:active-status="activeStatus = $event"
</h1> >
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"> <!-- Search / sort bar -->
Processus de decision collectifs de la communaute <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"
color="primary"
size="sm"
/>
</template>
<!-- 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 -->
<div v-else-if="decisions.loading" class="space-y-3">
<LoadingSkeleton v-for="i in 5" :key="i" :lines="2" card />
</div>
<!-- Empty state -->
<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> </p>
</div> </div>
<UButton
to="/decisions/new"
icon="i-lucide-plus"
label="Nouvelle decision"
color="primary"
/>
</div>
<!-- Filters --> <!-- Decision cards -->
<div class="flex flex-wrap gap-4"> <div v-else class="space-y-3">
<USelect <div
v-model="filterType" v-for="decision in filteredDecisions"
:items="typeOptions" :key="decision.id"
placeholder="Type de decision" class="decision-card"
class="w-56" @click="navigateTo(`/decisions/${decision.id}`)"
/> >
<USelect <div class="decision-card__header">
v-model="filterStatus" <div class="decision-card__title-block">
:items="statusOptions" <h3 class="decision-card__title">
placeholder="Statut" {{ decision.title }}
class="w-48" </h3>
/> <p v-if="decision.description" class="decision-card__description">
</div> {{ decision.description }}
</p>
</div>
<StatusBadge :status="decision.status" type="decision" />
</div>
<!-- Loading state --> <div class="decision-card__meta">
<template v-if="decisions.loading"> <UBadge variant="subtle" color="primary" size="xs">
<div class="space-y-3"> {{ typeLabel(decision.decision_type) }}
<USkeleton v-for="i in 5" :key="i" class="h-12 w-full" /> </UBadge>
<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) }}
</span>
</div>
</div>
</div> </div>
</template> </template>
<!-- Error state --> <!-- Toolbox sidebar -->
<template v-else-if="decisions.error"> <template #toolbox>
<UCard> <div class="toolbox-section-title">
<div class="flex items-center gap-3 text-red-500"> Modalites de vote
<UIcon name="i-lucide-alert-circle" class="text-xl" /> </div>
<p>{{ decisions.error }}</p> <template v-if="protocols.protocols.length > 0">
</div> <ToolboxVignette
</UCard> 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> </template>
</SectionLayout>
<!-- 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>
</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"
: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"
@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">
{{ decision.description }}
</p>
</div>
</div>
</td>
<td class="px-4 py-3">
<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">
{{ formatDate(decision.created_at) }}
</td>
</tr>
</tbody>
</table>
</div>
</UCard>
</template>
</div>
</template> </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 --> <!-- Form -->
<UCard> <UCard>
<DecisionsDecisionCadrage <DecisionCadrage
v-model="formData" v-model="formData"
:submitting="submitting" :submitting="submitting"
@submit="onSubmit" @submit="onSubmit"

View File

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

View File

@@ -218,7 +218,7 @@ function formatDate(dateStr: string): string {
<UIcon name="i-lucide-file-text" class="text-gray-400" /> <UIcon name="i-lucide-file-text" class="text-gray-400" />
<h2 class="text-sm font-semibold text-gray-500 uppercase">Texte en vigueur</h2> <h2 class="text-sm font-semibold text-gray-500 uppercase">Texte en vigueur</h2>
</div> </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"> <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>Cree le {{ formatDate(currentItem.created_at) }}</span>
<span>Mis a jour le {{ formatDate(currentItem.updated_at) }}</span> <span>Mis a jour le {{ formatDate(currentItem.updated_at) }}</span>
@@ -259,7 +259,7 @@ function formatDate(dateStr: string): string {
</template> </template>
<div v-else class="space-y-4"> <div v-else class="space-y-4">
<DocumentsItemVersionDiff <ItemVersionDiff
v-for="version in documents.versions" v-for="version in documents.versions"
:key="version.id" :key="version.id"
:version="version" :version="version"

View File

@@ -1,26 +1,19 @@
<script setup lang="ts"> <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' import type { DocumentCreate } from '~/stores/documents'
const documents = useDocumentsStore() const documents = useDocumentsStore()
const protocols = useProtocolsStore()
const auth = useAuthStore() const auth = useAuthStore()
const filterType = ref<string | undefined>(undefined) const activeStatus = ref<string | null>(null)
const filterStatus = ref<string | undefined>(undefined) const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
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' },
]
// New document modal state // New document modal state
const showNewDocModal = ref(false) const showNewDocModal = ref(false)
@@ -40,30 +33,78 @@ const newDocTypeOptions = [
{ label: 'Constitution', value: 'constitution' }, { label: 'Constitution', value: 'constitution' },
] ]
async function loadDocuments() { const sortOptions = [
await documents.fetchAll({ { label: 'Date', value: 'date' },
doc_type: filterType.value, { label: 'Titre', value: 'title' },
status: filterStatus.value, { 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)
}
// 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
})
/** 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
}
} }
onMounted(() => { function formatDate(dateStr: string): string {
loadDocuments() return new Date(dateStr).toLocaleDateString('fr-FR', {
}) day: 'numeric',
month: 'short',
watch([filterType, filterStatus], () => { year: 'numeric',
loadDocuments() })
})
function openNewDocModal() {
newDoc.value = {
slug: '',
title: '',
doc_type: 'licence',
description: null,
version: '1.0.0',
}
showNewDocModal.value = true
} }
function generateSlug(title: string): string { 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() { async function createDocument() {
creating.value = true creating.value = true
try { try {
@@ -91,153 +143,301 @@ async function createDocument() {
if (doc) { if (doc) {
navigateTo(`/documents/${doc.slug}`) navigateTo(`/documents/${doc.slug}`)
} }
} catch { }
catch {
// Error handled in store // Error handled in store
} finally { }
finally {
creating.value = false creating.value = false
} }
} }
</script> </script>
<template> <template>
<div class="space-y-6"> <SectionLayout
<!-- Header --> title="Documents de reference"
<div class="flex items-center justify-between"> subtitle="Textes fondateurs sous vote permanent de la communaute"
<div> :statuses="statuses"
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> :active-status="activeStatus"
Documents de reference @update:active-status="activeStatus = $event"
</h1> >
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"> <!-- Search / sort bar -->
Documents fondateurs de la communaute Duniter/G1 sous vote permanent <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> </p>
</div> </div>
<!-- New document button for authenticated users --> <!-- Document cards -->
<UButton <div v-else class="space-y-3">
v-if="auth.isAuthenticated" <div
label="Nouveau document" v-for="doc in filteredDocuments"
icon="i-lucide-plus" :key="doc.id"
color="primary" class="doc-card"
@click="openNewDocModal" @click="navigateTo(`/documents/${doc.slug}`)"
/> >
</div> <div class="doc-card__header">
<h3 class="doc-card__title">
{{ doc.title }}
</h3>
<StatusBadge :status="doc.status" type="document" />
</div>
<!-- Filters --> <div class="doc-card__meta">
<div class="flex flex-wrap gap-4"> <UBadge variant="subtle" color="primary" size="xs">
<USelect {{ typeLabel(doc.doc_type) }}
v-model="filterType" </UBadge>
:items="docTypeOptions" <span class="doc-card__version">v{{ doc.version }}</span>
placeholder="Type de document" <span class="doc-card__items">
class="w-48" <UIcon name="i-lucide-list" class="text-xs" />
/> {{ doc.items_count }} item{{ doc.items_count !== 1 ? 's' : '' }}
<USelect </span>
v-model="filterStatus" <span class="doc-card__date">
:items="statusOptions" {{ formatDate(doc.updated_at) }}
placeholder="Statut" </span>
class="w-48" </div>
/>
</div>
<!-- Error state --> <p v-if="doc.description" class="doc-card__description">
<template v-if="documents.error"> {{ doc.description }}
<UCard> </p>
<div class="flex items-center gap-3 text-red-500">
<UIcon name="i-lucide-alert-circle" class="text-xl" />
<p>{{ documents.error }}</p>
</div> </div>
</UCard> </div>
</template> </template>
<!-- Document list component --> <!-- Toolbox sidebar -->
<DocumentsDocumentList <template #toolbox>
:documents="documents.list" <div class="toolbox-section-title">
:loading="documents.loading" {{ 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 --> <!-- New document modal -->
<UModal v-model:open="showNewDocModal"> <UModal v-model:open="showNewDocModal">
<template #content> <template #content>
<div class="p-6 space-y-4"> <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 Nouveau document de reference
</h3> </h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <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 Titre
</label> </label>
<UInput <UInput
v-model="newDoc.title" v-model="newDoc.title"
placeholder="Ex: Licence G1" placeholder="Ex: Licence G1"
class="w-full" class="w-full"
/> />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Slug (identifiant URL)
</label>
<UInput
v-model="newDoc.slug"
placeholder="Ex: licence-g1"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Type de document
</label>
<USelect
v-model="newDoc.doc_type"
:items="newDocTypeOptions"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Version
</label>
<UInput
v-model="newDoc.version"
placeholder="1.0.0"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
Description (optionnelle)
</label>
<UTextarea
v-model="newDoc.description"
:rows="3"
placeholder="Decrivez brievement ce document..."
class="w-full"
/>
</div>
</div> </div>
<div class="flex items-center justify-end gap-3 pt-2"> <div class="space-y-2">
<UButton <label class="text-sm font-medium" style="color: var(--mood-text-muted);">
label="Annuler" Slug (identifiant URL)
variant="ghost" </label>
color="neutral" <UInput
@click="showNewDocModal = false" v-model="newDoc.slug"
placeholder="Ex: licence-g1"
class="w-full font-mono text-sm"
/> />
<UButton </div>
label="Creer le document"
icon="i-lucide-plus" <div class="space-y-2">
color="primary" <label class="text-sm font-medium" style="color: var(--mood-text-muted);">
:loading="creating" Type de document
:disabled="!newDoc.title.trim() || !newDoc.slug.trim()" </label>
@click="createDocument" <USelect
v-model="newDoc.doc_type"
:items="newDocTypeOptions"
class="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Version
</label>
<UInput
v-model="newDoc.version"
placeholder="1.0.0"
class="w-full font-mono text-sm"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
Description (optionnelle)
</label>
<UTextarea
v-model="newDoc.description"
:rows="3"
placeholder="Decrivez brievement ce document..."
class="w-full"
/> />
</div> </div>
</div> </div>
</template>
</UModal> <div class="flex items-center justify-end gap-3 pt-2">
</div> <UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showNewDocModal = false"
/>
<UButton
label="Creer le document"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newDoc.title.trim() || !newDoc.slug.trim()"
@click="createDocument"
/>
</div>
</div>
</template>
</UModal>
</template> </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"> <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 documents = useDocumentsStore()
const decisions = useDecisionsStore() const decisions = useDecisionsStore()
const mandates = useMandatesStore() const protocols = useProtocolsStore()
const votes = useVotesStore()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(true) const loading = ref(true)
const formulaOpen = ref(false)
onMounted(async () => { onMounted(async () => {
try { try {
await Promise.all([ await Promise.all([
documents.fetchAll(), documents.fetchAll(),
decisions.fetchAll(), decisions.fetchAll(),
mandates.fetchAll(), protocols.fetchProtocols(),
votes.fetchSessions(),
]) ])
} finally { }
finally {
loading.value = false loading.value = false
} }
}) })
/** Summary stats for the dashboard cards. */ /** Entry cards — the 3 main doors. */
const stats = computed(() => [ const entryCards = computed(() => [
{ {
label: 'Documents actifs', key: 'documents',
value: documents.activeDocuments.length, title: 'Documents',
total: documents.list.length,
icon: 'i-lucide-book-open', icon: 'i-lucide-book-open',
color: 'primary' as const,
to: '/documents', 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', key: 'decisions',
value: decisions.activeDecisions.length, title: 'Decisions',
total: decisions.list.length,
icon: 'i-lucide-scale', icon: 'i-lucide-scale',
color: 'success' as const,
to: '/decisions', 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', key: 'mandats',
value: openVoteSessions.value.length, title: 'Mandats',
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,
icon: 'i-lucide-user-check', icon: 'i-lucide-user-check',
color: 'info' as const,
to: '/mandates', 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. */ /** Last 5 decisions sorted by most recent. */
const recentDecisions = computed(() => { const recentDecisions = computed(() => {
return [...decisions.list] return [...decisions.list]
@@ -68,13 +67,6 @@ const recentDecisions = computed(() => {
.slice(0, 5) .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. */ /** Format a date string to a localized relative or absolute string. */
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
const date = new Date(dateStr) const date = new Date(dateStr)
@@ -94,287 +86,504 @@ function formatDate(dateStr: string): string {
} }
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' }) 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> </script>
<template> <template>
<div class="space-y-8"> <div class="dashboard" :style="{ background: 'var(--mood-gradient)' }">
<!-- Title --> <!-- Welcome banner -->
<div> <div class="dashboard__welcome">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white"> <h1 class="dashboard__welcome-title">
Glibredecision Bienvenue sur Glibredecision
</h1> </h1>
<p class="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-400"> <p class="dashboard__welcome-subtitle">
Decisions collectives pour la communaute Duniter/G1 Plateforme de decisions collectives pour la communaute Duniter / G1.
Explorez les documents de reference, participez aux decisions, suivez les mandats.
</p> </p>
</div> </div>
<!-- Quick actions (authenticated users only) --> <!-- Entry cards grid -->
<div v-if="auth.isAuthenticated" class="flex flex-wrap gap-2 sm:gap-3"> <div class="dashboard__entries">
<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">
<template v-if="loading"> <template v-if="loading">
<LoadingSkeleton <LoadingSkeleton
v-for="i in 4" v-for="i in 3"
:key="i" :key="i"
:lines="2" :lines="3"
card card
/> />
</template> </template>
<template v-else> <template v-else>
<NuxtLink v-for="stat in stats" :key="stat.label" :to="stat.to"> <NuxtLink
<UCard class="hover:shadow-md transition-shadow h-full"> v-for="card in entryCards"
<div class="flex items-start justify-between gap-2"> :key="card.key"
<div class="min-w-0"> :to="card.to"
<p class="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate"> class="dashboard__entry-card"
{{ stat.label }} >
</p> <div class="dashboard__entry-icon">
<p class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mt-1"> <UIcon :name="card.icon" class="text-2xl" />
{{ stat.value }} </div>
</p> <h2 class="dashboard__entry-title">
<p class="text-xs text-gray-400 mt-1"> {{ card.title }}
{{ stat.total }} au total </h2>
</p>
</div> <!-- Count badge for documents and decisions -->
<UIcon <template v-if="card.count !== null">
:name="stat.icon" <p class="dashboard__entry-count">
class="text-2xl sm:text-3xl text-gray-400 flex-shrink-0" {{ card.countLabel }}
/> </p>
</div> <p class="dashboard__entry-total">
</UCard> {{ 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"
/>
</NuxtLink> </NuxtLink>
</template> </template>
</div> </div>
<!-- Recent activity: decisions + votes side-by-side --> <!-- Onboarding banner for unauthenticated users -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6"> <div v-if="!auth.isAuthenticated" class="dashboard__onboarding">
<!-- Recent decisions --> <div class="dashboard__onboarding-content">
<UCard> <UIcon name="i-lucide-key-round" class="text-xl" />
<template #header> <div>
<div class="flex items-center justify-between"> <p class="dashboard__onboarding-text">
<div class="flex items-center gap-2"> Connectez-vous avec votre identite Duniter pour participer aux decisions collectives.
<UIcon name="i-lucide-scale" class="text-lg text-primary" /> </p>
<h3 class="text-base font-semibold text-gray-900 dark:text-white"> <p class="dashboard__onboarding-hint">
Decisions recentes Authentification par signature Ed25519 aucun mot de passe.
</h3> </p>
</div>
<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>
<div v-else-if="recentDecisions.length === 0" class="text-center py-6"> </div>
<UIcon name="i-lucide-inbox" class="text-3xl text-gray-300 dark:text-gray-600 mx-auto" /> <UButton
<p class="text-sm text-gray-500 mt-2">Aucune decision pour le moment</p> to="/login"
</div> label="Se connecter"
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800"> icon="i-lucide-log-in"
<NuxtLink variant="soft"
v-for="decision in recentDecisions" size="sm"
: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"
>
<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>
<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>
<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"
>
<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>
</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> </div>
<!-- Section cards (Domaines) --> <!-- Boite a outils teaser -->
<div> <div class="dashboard__toolbox-teaser">
<h2 class="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white mb-4"> <div class="dashboard__toolbox-teaser-header">
Domaines <UIcon name="i-lucide-wrench" class="text-lg" />
</h2> <h3>Boite a outils</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4"> <UBadge variant="subtle" size="xs">
<NuxtLink v-for="section in sections" :key="section.title" :to="section.to"> {{ protocols.protocols.length }} modalite{{ protocols.protocols.length > 1 ? 's' : '' }}
<UCard class="h-full hover:shadow-md transition-shadow"> </UBadge>
<div class="space-y-3"> </div>
<div class="flex items-center gap-3"> <p class="dashboard__toolbox-teaser-description">
<UIcon :name="section.icon" class="text-2xl text-primary flex-shrink-0" /> Protocoles de vote configurables avec formule de seuil WoT adaptative.
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white"> </p>
{{ section.title }} <div class="dashboard__toolbox-teaser-tags">
</h3> <template v-if="protocols.protocols.length > 0">
</div> <NuxtLink
<p class="text-sm text-gray-600 dark:text-gray-400"> v-for="protocol in protocols.protocols"
{{ section.description }} :key="protocol.id"
</p> :to="`/protocols/${protocol.id}`"
</div> class="dashboard__toolbox-tag"
</UCard> >
{{ 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>
<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="dashboard__activity-item"
>
<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> </NuxtLink>
</div> </div>
</div> </div>
<!-- Formula explainer --> <!-- Formula explainer collapsible -->
<UCard> <UCollapsible v-model:open="formulaOpen">
<div class="space-y-3"> <UButton
variant="ghost"
size="sm"
:icon="formulaOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="dashboard__formula-trigger"
>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<UIcon name="i-lucide-calculator" class="text-xl text-primary" /> <UIcon name="i-lucide-calculator" />
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white"> <span>Formule de seuil WoT</span>
Formule de seuil WoT
</h3>
</div> </div>
<p class="text-sm text-gray-600 dark:text-gray-400"> </UButton>
Le seuil d'adoption s'adapte dynamiquement a la participation : <template #content>
faible participation = quasi-unanimite requise ; forte participation = majorite simple suffisante. <div class="dashboard__formula-content">
</p> <p class="dashboard__formula-description">
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-xs sm:text-sm font-mono overflow-x-auto"> Le seuil d'adoption s'adapte dynamiquement a la participation :
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C) faible participation = quasi-unanimite requise ; forte participation = majorite simple suffisante.
</code> </p>
<div class="flex flex-wrap gap-3 sm:gap-4 text-xs text-gray-500"> <code class="dashboard__formula-code">
<span>C = constante de base</span> Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
<span>B = exposant de base</span> </code>
<span>W = taille WoT</span> <div class="dashboard__formula-params">
<span>T = votes totaux</span> <span>C = constante de base</span>
<span>M = majorite</span> <span>B = exposant de base</span>
<span>G = gradient</span> <span>W = taille WoT</span>
<span>T = votes totaux</span>
<span>M = majorite</span>
<span>G = gradient</span>
</div>
<UButton
to="/protocols/formulas"
label="Ouvrir le simulateur"
variant="outline"
size="xs"
icon="i-lucide-calculator"
class="mt-2"
/>
</div> </div>
</div> </template>
</UCard> </UCollapsible>
</div> </div>
</template> </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"> <UBadge variant="subtle" color="primary">
{{ typeLabel(mandates.current.mandate_type) }} {{ typeLabel(mandates.current.mandate_type) }}
</UBadge> </UBadge>
<CommonStatusBadge :status="mandates.current.status" type="mandate" /> <StatusBadge :status="mandates.current.status" type="mandate" />
</div> </div>
</div> </div>
@@ -323,7 +323,7 @@ async function handleDelete() {
Etapes du mandat Etapes du mandat
</h2> </h2>
<MandatesMandateTimeline <MandateTimeline
:steps="mandates.current.steps" :steps="mandates.current.steps"
:current-status="mandates.current.status" :current-status="mandates.current.status"
/> />

View File

@@ -1,46 +1,28 @@
<script setup lang="ts"> <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' import type { MandateCreate } from '~/stores/mandates'
const mandates = useMandatesStore() const mandates = useMandatesStore()
const protocols = useProtocolsStore()
const auth = useAuthStore()
const filterType = ref<string | undefined>(undefined) const activeStatus = ref<string | null>(null)
const filterStatus = ref<string | undefined>(undefined) const searchQuery = ref('')
const sortBy = ref<'date' | 'title' | 'status'>('date')
const typeOptions = [ const sortOptions = [
{ label: 'Tous les types', value: undefined }, { label: 'Date', value: 'date' },
{ label: 'Comite technique', value: 'techcomm' }, { label: 'Titre', value: 'title' },
{ label: 'Forgeron', value: 'smith' }, { label: 'Statut', value: 'status' },
{ label: 'Personnalise', value: 'custom' },
] ]
const statusOptions = [ // Create mandate modal state
{ 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 ---
const showCreateModal = ref(false) const showCreateModal = ref(false)
const mandateTypeOptions = [ const mandateTypeOptions = [
{ label: 'Comite technique', value: 'techcomm' }, { label: 'Comite technique', value: 'techcomm' },
@@ -55,6 +37,80 @@ const newMandate = ref<MandateCreate>({
}) })
const creating = ref(false) 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() { async function handleCreate() {
creating.value = true creating.value = true
try { try {
@@ -64,146 +120,372 @@ async function handleCreate() {
if (mandate) { if (mandate) {
navigateTo(`/mandates/${mandate.id}`) navigateTo(`/mandates/${mandate.id}`)
} }
} catch { }
catch {
// Error handled by store // Error handled by store
} finally { }
finally {
creating.value = false creating.value = false
} }
} }
</script> </script>
<template> <template>
<div class="space-y-6"> <SectionLayout
<!-- Header --> title="Mandats"
<div class="flex items-start justify-between"> subtitle="Un contexte, un objectif, une duree, une ou plusieurs nominations ; par defaut : nomination d'un binome."
<div> :statuses="statuses"
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> :active-status="activeStatus"
Mandats @update:active-status="activeStatus = $event"
</h1> >
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"> <!-- Search / sort bar -->
Mandats de gouvernance : comite technique, forgerons et roles specifiques <template #search>
</p> <UInput
</div> 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 <UButton
v-if="auth.isAuthenticated"
label="Nouveau"
icon="i-lucide-plus" icon="i-lucide-plus"
label="Nouveau mandat"
color="primary" color="primary"
size="sm"
@click="showCreateModal = true" @click="showCreateModal = true"
/> />
</div> </template>
<!-- Filters --> <!-- Main content: mandates list -->
<div class="flex flex-wrap gap-4"> <template #default>
<USelect <!-- Error state -->
v-model="filterType" <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);">
:items="typeOptions" <UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
placeholder="Type de mandat" <p style="color: var(--mood-text);">{{ mandates.error }}</p>
class="w-56"
/>
<USelect
v-model="filterStatus"
:items="statusOptions"
placeholder="Statut"
class="w-48"
/>
</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> </div>
</template>
<!-- Error state --> <!-- Loading state -->
<template v-else-if="mandates.error"> <div v-else-if="mandates.loading" class="space-y-3">
<UCard> <LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
<div class="flex items-center gap-3 text-red-500"> </div>
<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> </div>
</UCard> <h3 class="mandate-onboarding__title">
</template> Qu'est-ce qu'un mandat ?
</h3>
<!-- Empty state --> <p class="mandate-onboarding__text">
<template v-else-if="mandates.list.length === 0"> Un mandat definit un contexte, un objectif et une duree pour une mission de gouvernance.
<UCard> Il peut porter sur le comite technique, les forgerons, ou tout role specifique de la communaute.
<div class="text-center py-8"> </p>
<UIcon name="i-lucide-user-check" class="text-4xl text-gray-400 mb-3" /> <p class="mandate-onboarding__text">
<p class="text-gray-500">Aucun mandat pour le moment</p> 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>
</UCard> </div>
</template>
<!-- Mandates list --> <!-- Filtered empty state -->
<template v-else> <div
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> v-else-if="filteredMandates.length === 0"
<MandatesMandateCard class="text-center py-12"
v-for="mandate in mandates.list" 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" :key="mandate.id"
:mandate="mandate" 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> </div>
</template> </template>
<!-- Create mandate modal --> <!-- Toolbox sidebar -->
<UModal v-model:open="showCreateModal"> <template #toolbox>
<template #content> <div class="toolbox-section-title">
<form class="p-6 space-y-4" @submit.prevent="handleCreate"> Modalites de vote
<h3 class="text-lg font-semibold text-gray-900 dark:text-white"> </div>
Nouveau mandat <template v-if="protocols.protocols.length > 0">
</h3> <ToolboxVignette
v-for="protocol in protocols.protocols"
<div class="space-y-1"> :key="protocol.id"
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300"> :title="protocol.name"
Titre <span class="text-red-500">*</span> :description="protocol.description || undefined"
</label> context-label="Mandats"
<UInput :actions="[
v-model="newMandate.title" { label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
placeholder="Titre du mandat..." ]"
required />
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<UTextarea
v-model="newMandate.description"
placeholder="Description du mandat..."
:rows="3"
/>
</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>
<USelect
v-model="newMandate.mandate_type"
:items="mandateTypeOptions"
/>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-gray-200 dark:border-gray-700">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showCreateModal = false"
/>
<UButton
type="submit"
label="Creer"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newMandate.title?.trim()"
/>
</div>
</form>
</template> </template>
</UModal> <p v-else class="toolbox-empty-text">
</div> 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" style="color: var(--mood-text);">
Nouveau mandat
</h3>
<div class="space-y-1">
<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"
placeholder="Titre du mandat..."
required
/>
</div>
<div class="space-y-1">
<label class="block text-sm font-medium" style="color: var(--mood-text-muted);">
Description
</label>
<UTextarea
v-model="newMandate.description"
placeholder="Description du mandat..."
:rows="3"
/>
</div>
<div class="space-y-1">
<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"
:items="mandateTypeOptions"
/>
</div>
<div class="flex justify-end gap-2 pt-4" style="border-top: 1px solid var(--mood-border);">
<UButton
label="Annuler"
variant="ghost"
color="neutral"
@click="showCreateModal = false"
/>
<UButton
type="submit"
label="Creer"
icon="i-lucide-plus"
color="primary"
:loading="creating"
:disabled="!newMandate.title?.trim()"
/>
</div>
</form>
</template>
</UModal>
</template> </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"> <script setup lang="ts">
/** /**
* Protocols index page. * Boite a outils — page principale des protocoles de vote.
* *
* Lists all voting protocols with ModeParamsDisplay component, * Liste les protocoles avec leurs details complets,
* links to protocol detail pages, and provides creation modal * les configurations de formule, et le simulateur / explainer.
* and simulator link.
*/ */
const protocols = useProtocolsStore() const protocols = useProtocolsStore()
const auth = useAuthStore() const auth = useAuthStore()
const showCreateModal = ref(false) const showCreateModal = ref(false)
const creating = ref(false) const creating = ref(false)
const formulaOpen = ref(false)
/** Creation form state. */ /** Creation form state. */
const newProtocol = reactive({ const newProtocol = reactive({
@@ -84,25 +84,27 @@ async function createProtocol() {
}) })
showCreateModal.value = false showCreateModal.value = false
await protocols.fetchProtocols() await protocols.fetchProtocols()
} finally { }
finally {
creating.value = false creating.value = false
} }
} }
</script> </script>
<template> <template>
<div class="space-y-8"> <div class="toolbox-page">
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between"> <div class="toolbox-page__header">
<div> <div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> <h1 class="toolbox-page__title">
Protocoles de vote <UIcon name="i-lucide-wrench" class="toolbox-page__title-icon" />
Boite a outils
</h1> </h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400"> <p class="toolbox-page__subtitle">
Configuration des protocoles de vote et formules de seuil WoT Modalites de vote et formules configurables
</p> </p>
</div> </div>
<div class="flex items-center gap-3"> <div class="toolbox-page__actions">
<NuxtLink to="/protocols/formulas"> <NuxtLink to="/protocols/formulas">
<UButton variant="outline" icon="i-lucide-calculator" size="sm"> <UButton variant="outline" icon="i-lucide-calculator" size="sm">
Simulateur de formules Simulateur de formules
@@ -122,229 +124,216 @@ async function createProtocol() {
<!-- Loading state --> <!-- Loading state -->
<template v-if="protocols.loading"> <template v-if="protocols.loading">
<div class="space-y-3"> <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> </div>
</template> </template>
<!-- Error state --> <!-- Error state -->
<template v-else-if="protocols.error"> <template v-else-if="protocols.error">
<UCard> <div class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
<div class="flex items-center gap-3 text-red-500"> <UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
<UIcon name="i-lucide-alert-circle" class="text-xl" /> <p style="color: var(--mood-text);">{{ protocols.error }}</p>
<p>{{ protocols.error }}</p> </div>
</div>
</UCard>
</template> </template>
<template v-else> <template v-else>
<!-- Voting Protocols --> <!-- Protocols section -->
<div> <div class="toolbox-section">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4"> <h2 class="toolbox-section__title">
Protocoles ({{ protocols.protocols.length }}) Protocoles de vote
<UBadge variant="subtle" size="xs">
{{ protocols.protocols.length }}
</UBadge>
</h2> </h2>
<div v-if="protocols.protocols.length === 0"> <div v-if="protocols.protocols.length === 0" class="toolbox-empty">
<UCard> <UIcon name="i-lucide-settings" class="text-3xl" />
<div class="text-center py-8"> <p>Aucun protocole de vote configure</p>
<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> </div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div v-else class="toolbox-protocol-grid">
<NuxtLink <NuxtLink
v-for="protocol in protocols.protocols" v-for="protocol in protocols.protocols"
:key="protocol.id" :key="protocol.id"
:to="`/protocols/${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"> <!-- Protocol header -->
<div class="space-y-4"> <div class="protocol-card__header">
<!-- Protocol header --> <h3 class="protocol-card__name">
<div class="flex items-start justify-between"> {{ protocol.name }}
<div> </h3>
<h3 class="font-semibold text-gray-900 dark:text-white"> <div class="protocol-card__badges">
{{ protocol.name }} <UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle" size="xs">
</h3> {{ voteTypeLabel(protocol.vote_type) }}
<p v-if="protocol.description" class="text-sm text-gray-500 mt-0.5"> </UBadge>
{{ protocol.description }} <UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle" size="xs">
</p> Meta-gouverne
</div> </UBadge>
<div class="flex items-center gap-2"> </div>
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle" size="xs"> </div>
{{ voteTypeLabel(protocol.vote_type) }}
</UBadge>
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle" size="xs">
Meta-gouverne
</UBadge>
</div>
</div>
<!-- Mode params with component --> <p v-if="protocol.description" class="protocol-card__description">
<div v-if="protocol.mode_params"> {{ protocol.description }}
<ModeParamsDisplay :mode-params="protocol.mode_params" /> </p>
</div>
<!-- Formula config summary --> <!-- Mode params -->
<div class="border-t border-gray-100 dark:border-gray-800 pt-3"> <div v-if="protocol.mode_params" class="protocol-card__mode-params">
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2"> <ModeParamsDisplay :mode-params="protocol.mode_params" />
Formule : {{ protocol.formula_config.name }} </div>
</h4>
<div class="grid grid-cols-3 gap-2 text-xs"> <!-- Formula config summary -->
<div> <div class="protocol-card__formula">
<span class="text-gray-400 block">Duree</span> <h4 class="protocol-card__formula-title">
<span class="font-medium text-gray-900 dark:text-white"> Formule : {{ protocol.formula_config.name }}
{{ protocol.formula_config.duration_days }}j </h4>
</span> <div class="protocol-card__formula-grid">
</div> <div>
<div> <span class="protocol-card__formula-label">Duree</span>
<span class="text-gray-400 block">Majorite</span> <span class="protocol-card__formula-value">{{ protocol.formula_config.duration_days }}j</span>
<span class="font-medium text-gray-900 dark:text-white"> </div>
{{ protocol.formula_config.majority_pct }}% <div>
</span> <span class="protocol-card__formula-label">Majorite</span>
</div> <span class="protocol-card__formula-value">{{ protocol.formula_config.majority_pct }}%</span>
<div> </div>
<span class="text-gray-400 block">Base</span> <div>
<span class="font-medium text-gray-900 dark:text-white"> <span class="protocol-card__formula-label">Base</span>
{{ protocol.formula_config.base_exponent }} <span class="protocol-card__formula-value">{{ protocol.formula_config.base_exponent }}</span>
</span> </div>
</div> <div>
<div> <span class="protocol-card__formula-label">Gradient</span>
<span class="text-gray-400 block">Gradient</span> <span class="protocol-card__formula-value">{{ protocol.formula_config.gradient_exponent }}</span>
<span class="font-medium text-gray-900 dark:text-white"> </div>
{{ protocol.formula_config.gradient_exponent }} <div v-if="protocol.formula_config.smith_exponent !== null">
</span> <span class="protocol-card__formula-label">Smith</span>
</div> <span class="protocol-card__formula-value">{{ protocol.formula_config.smith_exponent }}</span>
<div v-if="protocol.formula_config.smith_exponent !== null"> </div>
<span class="text-gray-400 block">Smith</span> <div v-if="protocol.formula_config.techcomm_exponent !== null">
<span class="font-medium text-gray-900 dark:text-white"> <span class="protocol-card__formula-label">TechComm</span>
{{ protocol.formula_config.smith_exponent }} <span class="protocol-card__formula-value">{{ protocol.formula_config.techcomm_exponent }}</span>
</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>
</div>
</div>
</div> </div>
</div> </div>
</UCard> </div>
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
<!-- Formula Configurations --> <!-- Formula configurations section -->
<div> <div class="toolbox-section">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4"> <h2 class="toolbox-section__title">
Configurations de formule ({{ protocols.formulas.length }}) Configurations de formule
<UBadge variant="subtle" size="xs">
{{ protocols.formulas.length }}
</UBadge>
</h2> </h2>
<div v-if="protocols.formulas.length === 0"> <div v-if="protocols.formulas.length === 0" class="toolbox-empty">
<UCard> <UIcon name="i-lucide-calculator" class="text-3xl" />
<div class="text-center py-8"> <p>Aucune configuration de formule</p>
<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> </div>
<div v-else> <div v-else class="formula-table-wrapper">
<UCard> <table class="formula-table">
<div class="overflow-x-auto"> <thead>
<table class="w-full text-sm"> <tr>
<thead> <th>Nom</th>
<tr class="border-b border-gray-200 dark:border-gray-700"> <th>Duree</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Nom</th> <th>Majorite</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Duree</th> <th>B</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Majorite</th> <th>G</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">B</th> <th>C</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">G</th> <th>Smith</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">C</th> <th>TechComm</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">Smith</th> <th>Date</th>
<th class="text-left px-4 py-3 font-medium text-gray-500">TechComm</th> </tr>
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th> </thead>
</tr> <tbody>
</thead> <tr v-for="formula in protocols.formulas" :key="formula.id">
<tbody> <td class="formula-table__name">{{ formula.name }}</td>
<tr <td>{{ formula.duration_days }}j</td>
v-for="formula in protocols.formulas" <td>{{ formula.majority_pct }}%</td>
:key="formula.id" <td class="font-mono">{{ formula.base_exponent }}</td>
class="border-b border-gray-100 dark:border-gray-800" <td class="font-mono">{{ formula.gradient_exponent }}</td>
> <td class="font-mono">{{ formula.constant_base }}</td>
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white"> <td class="font-mono">{{ formula.smith_exponent ?? '-' }}</td>
{{ formula.name }} <td class="font-mono">{{ formula.techcomm_exponent ?? '-' }}</td>
</td> <td class="formula-table__date">{{ formatDate(formula.created_at) }}</td>
<td class="px-4 py-3 text-gray-600">{{ formula.duration_days }}j</td> </tr>
<td class="px-4 py-3 text-gray-600">{{ formula.majority_pct }}%</td> </tbody>
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.base_exponent }}</td> </table>
<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>
</tbody>
</table>
</div>
</UCard>
</div> </div>
</div> </div>
<!-- Formula explainer --> <!-- Formula explainer collapsible -->
<UCard> <UCollapsible v-model:open="formulaOpen">
<div class="space-y-3"> <UButton
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide"> variant="ghost"
Reference : Formule de seuil WoT size="sm"
</h3> :icon="formulaOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono"> class="formula-explainer-trigger"
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C) >
</code> <div class="flex items-center gap-2">
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 text-xs text-gray-500"> <UIcon name="i-lucide-book-open" />
<div><strong>C</strong> = constante de base (plancher fixe)</div> <span>Reference : Formule de seuil WoT</span>
<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>
<div><strong>T</strong> = nombre total de votes</div>
<div><strong>M</strong> = ratio de majorite (M = majorite_pct / 100)</div>
<div><strong>G</strong> = exposant du gradient d'inertie</div>
</div> </div>
</div> </UButton>
</UCard> <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="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>
<div><strong>T</strong> = nombre total de votes</div>
<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>
</template>
</UCollapsible>
</template> </template>
<!-- Create protocol modal --> <!-- Create protocol modal -->
<UModal v-model:open="showCreateModal"> <UModal v-model:open="showCreateModal">
<template #content> <template #content>
<div class="p-6 space-y-6"> <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 Nouveau protocole de vote
</h3> </h3>
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <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 Nom du protocole
</label> </label>
<UInput v-model="newProtocol.name" placeholder="Ex: Vote standard G1" /> <UInput v-model="newProtocol.name" placeholder="Ex: Vote standard G1" />
</div> </div>
<div class="space-y-2"> <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) Description (optionnel)
</label> </label>
<UTextarea v-model="newProtocol.description" placeholder="Description du protocole..." :rows="2" /> <UTextarea v-model="newProtocol.description" placeholder="Description du protocole..." :rows="2" />
</div> </div>
<div class="space-y-2"> <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 Type de vote
</label> </label>
<USelect <USelect
@@ -355,7 +344,7 @@ async function createProtocol() {
</div> </div>
<div class="space-y-2"> <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 Configuration de formule
</label> </label>
<USelect <USelect
@@ -384,3 +373,248 @@ async function createProtocol() {
</UModal> </UModal>
</div> </div>
</template> </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> <template v-else>
<div class="space-y-4"> <div class="space-y-4">
<div v-for="entry in entries" :key="entry.id" class="relative"> <div v-for="entry in entries" :key="entry.id" class="relative">
<SanctuarySanctuaryEntry <SanctuaryEntry
:entry="entry" :entry="entry"
@verify="handleVerify" @verify="handleVerify"
/> />

View File

@@ -4,6 +4,7 @@ export default defineNuxtConfig({
devtools: { enabled: true }, devtools: { enabled: true },
devServer: { port: 3002 }, devServer: { port: 3002 },
components: [{ path: '~/components', pathPrefix: false }], components: [{ path: '~/components', pathPrefix: false }],
css: ['~/assets/css/moods.css'],
modules: [ modules: [
'@nuxt/ui', '@nuxt/ui',
'@pinia/nuxt', '@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": { "dependencies": {
"@nuxt/content": "^3.11.2", "@nuxt/content": "^3.11.2",
"@nuxt/ui": "^3.1.0", "@nuxt/ui": "^3.1.0",
"@pinia/nuxt": "^0.9.0", "@pinia/nuxt": "^0.11.0",
"@unocss/nuxt": "^66.6.0", "@unocss/nuxt": "^66.6.0",
"@vueuse/nuxt": "^14.2.1", "@vueuse/nuxt": "^14.2.1",
"nuxt": "^4.3.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
})