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:
@@ -10,8 +10,8 @@ class Settings(BaseSettings):
|
||||
ENVIRONMENT: str = "development" # development, staging, production
|
||||
LOG_LEVEL: str = "INFO"
|
||||
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision"
|
||||
# Database — SQLite by default for local dev, PostgreSQL for Docker/prod
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./glibredecision.db"
|
||||
DATABASE_POOL_SIZE: int = 20
|
||||
DATABASE_MAX_OVERFLOW: int = 10
|
||||
|
||||
|
||||
@@ -3,14 +3,25 @@ from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
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,
|
||||
)
|
||||
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
|
||||
|
||||
# SQLite doesn't support pool_size/max_overflow/pool_pre_ping/pool_recycle
|
||||
if _is_sqlite:
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.ENVIRONMENT == "development",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
else:
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.ENVIRONMENT == "development",
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy import String, DateTime, JSON, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
@@ -11,8 +10,8 @@ from app.database import Base
|
||||
class BlockchainCache(Base):
|
||||
__tablename__ = "blockchain_cache"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
cache_key: Mapped[str] = mapped_column(String(256), unique=True, nullable=False, index=True)
|
||||
cache_value: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
cache_value: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -11,7 +10,7 @@ from app.database import Base
|
||||
class Decision(Base):
|
||||
__tablename__ = "decisions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
context: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -28,7 +27,7 @@ class Decision(Base):
|
||||
class DecisionStep(Base):
|
||||
__tablename__ = "decision_steps"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
decision_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("decisions.id"), nullable=False)
|
||||
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # qualification, review, vote, execution, reporting
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -11,7 +10,7 @@ from app.database import Base
|
||||
class Document(Base):
|
||||
__tablename__ = "documents"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
slug: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
doc_type: Mapped[str] = mapped_column(String(64), nullable=False) # licence, engagement, reglement, constitution
|
||||
@@ -29,7 +28,7 @@ class Document(Base):
|
||||
class DocumentItem(Base):
|
||||
__tablename__ = "document_items"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
document_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("documents.id"), nullable=False)
|
||||
position: Mapped[str] = mapped_column(String(16), nullable=False) # "1", "1.1", "3.2"
|
||||
item_type: Mapped[str] = mapped_column(String(32), default="clause") # clause, rule, verification, preamble, section
|
||||
@@ -47,7 +46,7 @@ class DocumentItem(Base):
|
||||
class ItemVersion(Base):
|
||||
__tablename__ = "item_versions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
item_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("document_items.id"), nullable=False)
|
||||
proposed_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
diff_text: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -11,7 +10,7 @@ from app.database import Base
|
||||
class Mandate(Base):
|
||||
__tablename__ = "mandates"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
mandate_type: Mapped[str] = mapped_column(String(64), nullable=False) # techcomm, smith, custom
|
||||
@@ -29,7 +28,7 @@ class Mandate(Base):
|
||||
class MandateStep(Base):
|
||||
__tablename__ = "mandate_steps"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
mandate_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("mandates.id"), nullable=False)
|
||||
step_order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
step_type: Mapped[str] = mapped_column(String(32), nullable=False) # formulation, candidacy, vote, assignment, reporting, completion, revocation
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Float, Boolean, DateTime, ForeignKey, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import String, Integer, Float, Boolean, DateTime, ForeignKey, Text, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -11,7 +10,7 @@ from app.database import Base
|
||||
class FormulaConfig(Base):
|
||||
__tablename__ = "formula_configs"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
@@ -40,7 +39,7 @@ class FormulaConfig(Base):
|
||||
class VotingProtocol(Base):
|
||||
__tablename__ = "voting_protocols"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
vote_type: Mapped[str] = mapped_column(String(32), nullable=False) # binary, nuanced
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Text, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import String, Integer, Text, DateTime, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
@@ -11,9 +10,9 @@ from app.database import Base
|
||||
class SanctuaryEntry(Base):
|
||||
__tablename__ = "sanctuary_entries"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
entry_type: Mapped[str] = mapped_column(String(64), nullable=False) # document, decision, vote_result
|
||||
reference_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
|
||||
reference_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
title: Mapped[str | None] = mapped_column(String(256))
|
||||
content_hash: Mapped[str] = mapped_column(String(128), nullable=False) # SHA-256
|
||||
ipfs_cid: Mapped[str | None] = mapped_column(String(128))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -11,7 +10,7 @@ from app.database import Base
|
||||
class DuniterIdentity(Base):
|
||||
__tablename__ = "duniter_identities"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
address: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(128))
|
||||
wot_status: Mapped[str] = mapped_column(String(32), default="unknown") # member, pending, revoked, unknown
|
||||
@@ -26,7 +25,7 @@ class DuniterIdentity(Base):
|
||||
class Session(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
|
||||
identity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("duniter_identities.id"), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy import String, Integer, Float, Boolean, Text, DateTime, ForeignKey, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
@@ -11,7 +10,7 @@ from app.database import Base
|
||||
class VoteSession(Base):
|
||||
__tablename__ = "vote_sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
decision_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("decisions.id"))
|
||||
item_version_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("item_versions.id"))
|
||||
voting_protocol_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("voting_protocols.id"), nullable=False)
|
||||
@@ -49,7 +48,7 @@ class VoteSession(Base):
|
||||
class Vote(Base):
|
||||
__tablename__ = "votes"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("vote_sessions.id"), nullable=False)
|
||||
voter_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("duniter_identities.id"), nullable=False)
|
||||
vote_value: Mapped[str] = mapped_column(String(32), nullable=False) # for, against, or nuanced levels
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
@@ -133,11 +133,11 @@ async def get_me(
|
||||
return IdentityOut.model_validate(identity)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||
async def logout(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
identity: DuniterIdentity = Depends(get_current_identity),
|
||||
) -> None:
|
||||
):
|
||||
"""Invalidate the current session token.
|
||||
|
||||
Note: get_current_identity already validated the token, so we know it exists.
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -188,7 +188,7 @@ async def create_vote_session_for_step_endpoint(
|
||||
return VoteSessionOut.model_validate(session)
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||
async def delete_decision(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -6,7 +6,7 @@ import difflib
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -316,7 +316,7 @@ async def update_item(
|
||||
return DocumentItemOut.model_validate(item)
|
||||
|
||||
|
||||
@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{slug}/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||
async def delete_item(
|
||||
slug: str,
|
||||
item_id: uuid.UUID,
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -124,7 +124,7 @@ async def update_mandate(
|
||||
return MandateOut.model_validate(mandate)
|
||||
|
||||
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response, response_model=None)
|
||||
async def delete_mandate(
|
||||
id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
|
||||
@@ -3,6 +3,7 @@ uvicorn[standard]==0.34.0
|
||||
sqlalchemy==2.0.36
|
||||
alembic==1.14.0
|
||||
asyncpg==0.30.0
|
||||
aiosqlite==0.22.1
|
||||
pydantic==2.10.3
|
||||
pydantic-settings==2.7.0
|
||||
python-multipart==0.0.18
|
||||
|
||||
186
backend/seed.py
186
backend/seed.py
@@ -101,39 +101,45 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed: VotingProtocols
|
||||
# Seed: VotingProtocols (premier pack de modalites)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def seed_voting_protocols(
|
||||
session: AsyncSession,
|
||||
formulas: dict[str, FormulaConfig],
|
||||
) -> dict[str, VotingProtocol]:
|
||||
"""Create the 4 base voting protocols."""
|
||||
"""Create the first pack of voting modalities (3 protocols)."""
|
||||
protocols: dict[str, dict] = {
|
||||
"Standard G1": {
|
||||
"description": "Protocole binaire standard pour la Licence G1.",
|
||||
"Vote majoritaire": {
|
||||
"description": (
|
||||
"Vote binaire a majorite simple. Le seuil d'adoption "
|
||||
"s'adapte dynamiquement au taux de participation via "
|
||||
"la formule d'inertie WoT."
|
||||
),
|
||||
"vote_type": "binary",
|
||||
"formula_config_id": formulas["Standard Licence G1"].id,
|
||||
"mode_params": "D30M50B.1G.2",
|
||||
},
|
||||
"Forgeron Smith": {
|
||||
"description": "Protocole binaire avec critere Smith pour les forgerons.",
|
||||
"Vote quadratique": {
|
||||
"description": (
|
||||
"Vote pondere par la racine carree des certifications. "
|
||||
"Reduit l'influence des gros certificateurs et favorise "
|
||||
"une participation large et diversifiee."
|
||||
),
|
||||
"vote_type": "binary",
|
||||
"formula_config_id": formulas["Forgeron avec Smith"].id,
|
||||
"mode_params": "D30M50B.1G.2S.1",
|
||||
},
|
||||
"Comite Tech": {
|
||||
"description": "Protocole binaire avec critere Comite Technique.",
|
||||
"Vote permanent": {
|
||||
"description": (
|
||||
"Vote continu sans date de fin. Le resultat evolue en "
|
||||
"temps reel avec chaque nouveau vote. Adapte aux documents "
|
||||
"de reference sous revision permanente."
|
||||
),
|
||||
"vote_type": "binary",
|
||||
"formula_config_id": formulas["Comite Tech"].id,
|
||||
"mode_params": "D30M50B.1G.2T.1",
|
||||
},
|
||||
"Vote Nuance 6 niveaux": {
|
||||
"description": "Protocole de vote nuance a 6 niveaux.",
|
||||
"vote_type": "nuanced",
|
||||
"formula_config_id": formulas["Vote Nuance"].id,
|
||||
"mode_params": None,
|
||||
},
|
||||
}
|
||||
|
||||
result: dict[str, VotingProtocol] = {}
|
||||
@@ -149,143 +155,134 @@ async def seed_voting_protocols(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed: Document - Licence G1
|
||||
# Seed: Document - Acte d'engagement certification
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LICENCE_G1_ITEMS: list[dict] = [
|
||||
ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
|
||||
{
|
||||
"position": "1",
|
||||
"item_type": "preamble",
|
||||
"title": "Preambule",
|
||||
"title": "Objet",
|
||||
"sort_order": 1,
|
||||
"current_text": (
|
||||
"Licence de la monnaie libre et engagement de responsabilite. "
|
||||
"La monnaie libre G1 (June) est co-produite par ses membres."
|
||||
"Le present acte definit les engagements de tout membre de la "
|
||||
"toile de confiance qui certifie l'identite d'une autre personne "
|
||||
"dans le reseau Duniter."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "2",
|
||||
"item_type": "section",
|
||||
"title": "Avertissement TdC",
|
||||
"item_type": "clause",
|
||||
"title": "Connaissance personnelle",
|
||||
"sort_order": 2,
|
||||
"current_text": (
|
||||
"Certifier n'est pas uniquement s'assurer de l'identite unique "
|
||||
"de la personne (son unicite). C'est aussi affirmer que vous la "
|
||||
"connaissez bien et que vous saurez la joindre facilement."
|
||||
"Je certifie connaitre personnellement la personne que je "
|
||||
"certifie, l'avoir rencontree physiquement a plusieurs reprises, "
|
||||
"et pouvoir la contacter par au moins deux moyens de communication "
|
||||
"differents."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "3",
|
||||
"item_type": "clause",
|
||||
"title": "Conseils",
|
||||
"title": "Verification d'identite",
|
||||
"sort_order": 3,
|
||||
"current_text": (
|
||||
"Connaitre la personne par plusieurs moyens de communication differents "
|
||||
"(physique, electronique, etc.). Connaitre son lieu de vie principal. "
|
||||
"Avoir echange avec elle en utilisant des moyens de communication "
|
||||
"susceptibles d'identifier un humain vivant."
|
||||
"Je certifie avoir verifie que la personne n'a qu'un seul compte "
|
||||
"membre dans la toile de confiance, et que l'identite declaree "
|
||||
"correspond a une personne humaine vivante."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "4",
|
||||
"item_type": "verification",
|
||||
"title": "Verifications",
|
||||
"item_type": "clause",
|
||||
"title": "Engagement de suivi",
|
||||
"sort_order": 4,
|
||||
"current_text": (
|
||||
"De suffisamment bien connaitre la personne pour pouvoir la contacter, "
|
||||
"echanger avec elle. De s'assurer que la personne a bien le controle "
|
||||
"de son compte Duniter."
|
||||
"Je m'engage a surveiller l'activite de mes certifies et a "
|
||||
"signaler tout comportement suspect (comptes multiples, "
|
||||
"usurpation d'identite, comptes abandonnes)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "5",
|
||||
"item_type": "rule",
|
||||
"title": "Regles TdC",
|
||||
"item_type": "verification",
|
||||
"title": "Delai entre certifications",
|
||||
"sort_order": 5,
|
||||
"current_text": (
|
||||
"Chaque membre dispose de 100 certifications possibles. "
|
||||
"Il est possible de certifier 1 nouveau membre tous les 5 jours. "
|
||||
"Un membre doit avoir au moins 5 certifications pour devenir membre. "
|
||||
"Un membre doit renouveler son adhesion tous les 2 ans."
|
||||
"Je respecte un delai minimum de reflexion de 5 jours entre "
|
||||
"chaque nouvelle certification emise."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "6",
|
||||
"item_type": "rule",
|
||||
"title": "Production DU",
|
||||
"title": "Renouvellement",
|
||||
"sort_order": 6,
|
||||
"current_text": (
|
||||
"1 DU (Dividende Universel) est produit par personne et par jour. "
|
||||
"Le DU est la monnaie de base co-produite par chaque membre."
|
||||
"Je renouvelle mes certifications avant leur expiration pour "
|
||||
"maintenir la cohesion de la toile de confiance."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "7",
|
||||
"item_type": "rule",
|
||||
"title": "Code monetaire",
|
||||
"item_type": "clause",
|
||||
"title": "Responsabilite",
|
||||
"sort_order": 7,
|
||||
"current_text": (
|
||||
"DU formule : DU(t+1) = DU(t) + c^2 * M/N. "
|
||||
"c = 4.88% / an. Le DU est re-evalue chaque equinoxe."
|
||||
"Je suis conscient que la certification engage ma responsabilite "
|
||||
"vis-a-vis de la communaute. Une certification abusive peut "
|
||||
"entrainer la perte de confiance des autres membres."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "8",
|
||||
"item_type": "clause",
|
||||
"title": "Logiciels",
|
||||
"item_type": "rule",
|
||||
"title": "Revocation",
|
||||
"sort_order": 8,
|
||||
"current_text": (
|
||||
"Les logiciels G1 doivent transmettre cette licence integralement "
|
||||
"aux utilisateurs et developper un acces libre au code source."
|
||||
),
|
||||
},
|
||||
{
|
||||
"position": "9",
|
||||
"item_type": "clause",
|
||||
"title": "Modification",
|
||||
"sort_order": 9,
|
||||
"current_text": (
|
||||
"Proposants, soutiens et votants doivent etre membres de la TdC. "
|
||||
"Toute modification de cette licence doit etre soumise au vote "
|
||||
"des membres selon le protocole en vigueur."
|
||||
"Une certification peut etre revoquee si les conditions de "
|
||||
"l'engagement ne sont plus remplies. La revocation est soumise "
|
||||
"au protocole de vote en vigueur."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def seed_document_licence_g1(session: AsyncSession) -> Document:
|
||||
"""Create the Licence G1 document with its items."""
|
||||
async def seed_document_engagement_certification(session: AsyncSession) -> Document:
|
||||
"""Create the Acte d'engagement certification document with its items."""
|
||||
doc, created = await get_or_create(
|
||||
session,
|
||||
Document,
|
||||
"slug",
|
||||
"licence-g1",
|
||||
title="Licence G1",
|
||||
doc_type="licence",
|
||||
version="0.3.0",
|
||||
"engagement-certification",
|
||||
title="Acte d'engagement certification",
|
||||
doc_type="engagement",
|
||||
version="1.0.0",
|
||||
status="active",
|
||||
description=(
|
||||
"Licence de la monnaie libre G1 (June). "
|
||||
"Definit les regles de la toile de confiance et du Dividende Universel."
|
||||
"Acte d'engagement pour les certificateurs de la toile de confiance "
|
||||
"Duniter. Definit les obligations et responsabilites liees a la "
|
||||
"certification de nouveaux membres."
|
||||
),
|
||||
)
|
||||
print(f" Document 'Licence G1': {'created' if created else 'exists'}")
|
||||
print(f" Document 'Acte d'engagement certification': {'created' if created else 'exists'}")
|
||||
|
||||
if created:
|
||||
for item_data in LICENCE_G1_ITEMS:
|
||||
for item_data in ENGAGEMENT_CERTIFICATION_ITEMS:
|
||||
item = DocumentItem(document_id=doc.id, **item_data)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
print(f" -> {len(LICENCE_G1_ITEMS)} items created")
|
||||
print(f" -> {len(ENGAGEMENT_CERTIFICATION_ITEMS)} items created")
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed: Document - Engagement Forgeron v2.0.0
|
||||
# Seed: Document - Acte d'engagement forgeron v2.0.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FORGERON_ITEMS: list[dict] = [
|
||||
ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
|
||||
{
|
||||
"position": "1",
|
||||
"item_type": "preamble",
|
||||
@@ -387,36 +384,36 @@ FORGERON_ITEMS: list[dict] = [
|
||||
]
|
||||
|
||||
|
||||
async def seed_document_forgeron(session: AsyncSession) -> Document:
|
||||
"""Create the Engagement Forgeron v2.0.0 document with its items."""
|
||||
async def seed_document_engagement_forgeron(session: AsyncSession) -> Document:
|
||||
"""Create the Acte d'engagement forgeron v2.0.0 document with its items."""
|
||||
doc, created = await get_or_create(
|
||||
session,
|
||||
Document,
|
||||
"slug",
|
||||
"engagement-forgeron",
|
||||
title="Engagement Forgeron v2.0.0",
|
||||
title="Acte d'engagement forgeron",
|
||||
doc_type="engagement",
|
||||
version="2.0.0",
|
||||
status="active",
|
||||
description=(
|
||||
"Engagement des forgerons (validateurs) pour Duniter V2. "
|
||||
"Adopte en fevrier 2026 (97 pour / 23 contre)."
|
||||
"Acte d'engagement des forgerons (validateurs de blocs) pour "
|
||||
"Duniter V2. Adopte en fevrier 2026 (97 pour / 23 contre)."
|
||||
),
|
||||
)
|
||||
print(f" Document 'Engagement Forgeron v2.0.0': {'created' if created else 'exists'}")
|
||||
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
|
||||
|
||||
if created:
|
||||
for item_data in FORGERON_ITEMS:
|
||||
for item_data in ENGAGEMENT_FORGERON_ITEMS:
|
||||
item = DocumentItem(document_id=doc.id, **item_data)
|
||||
session.add(item)
|
||||
await session.flush()
|
||||
print(f" -> {len(FORGERON_ITEMS)} items created")
|
||||
print(f" -> {len(ENGAGEMENT_FORGERON_ITEMS)} items created")
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seed: Decision template - Processus Runtime Upgrade
|
||||
# Seed: Decision template - Runtime Upgrade
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RUNTIME_UPGRADE_STEPS: list[dict] = [
|
||||
@@ -474,15 +471,16 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
|
||||
session,
|
||||
Decision,
|
||||
"title",
|
||||
"Processus Runtime Upgrade",
|
||||
"Runtime Upgrade",
|
||||
description=(
|
||||
"Template de decision pour les mises a jour du runtime Duniter V2. "
|
||||
"5 etapes : qualification, revue, vote, execution, suivi."
|
||||
"Processus de mise a jour du runtime Duniter V2 on-chain. "
|
||||
"Chaque upgrade suit un protocole strict en 5 etapes. "
|
||||
"(Decision on-chain)"
|
||||
),
|
||||
decision_type="runtime_upgrade",
|
||||
status="draft",
|
||||
)
|
||||
print(f" Decision 'Processus Runtime Upgrade': {'created' if created else 'exists'}")
|
||||
print(f" Decision 'Runtime Upgrade': {'created' if created else 'exists'}")
|
||||
|
||||
if created:
|
||||
for step_data in RUNTIME_UPGRADE_STEPS:
|
||||
@@ -509,16 +507,16 @@ async def run_seed():
|
||||
print("\n[1/5] Formula Configs...")
|
||||
formulas = await seed_formula_configs(session)
|
||||
|
||||
print("\n[2/5] Voting Protocols...")
|
||||
print("\n[2/5] Voting Protocols (premier pack de modalites)...")
|
||||
await seed_voting_protocols(session, formulas)
|
||||
|
||||
print("\n[3/5] Document: Licence G1...")
|
||||
await seed_document_licence_g1(session)
|
||||
print("\n[3/5] Document: Acte d'engagement certification...")
|
||||
await seed_document_engagement_certification(session)
|
||||
|
||||
print("\n[4/5] Document: Engagement Forgeron v2.0.0...")
|
||||
await seed_document_forgeron(session)
|
||||
print("\n[4/5] Document: Acte d'engagement forgeron...")
|
||||
await seed_document_engagement_forgeron(session)
|
||||
|
||||
print("\n[5/5] Decision: Processus Runtime Upgrade...")
|
||||
print("\n[5/5] Decision: Runtime Upgrade...")
|
||||
await seed_decision_runtime_upgrade(session)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
|
||||
@@ -1,37 +1,73 @@
|
||||
version: "3.9"
|
||||
|
||||
# Dev overrides -- usage:
|
||||
# docker compose -f docker/docker-compose.yml -f docker/docker-compose.dev.yml up
|
||||
# Development stack -- standalone (no Traefik needed)
|
||||
# Usage: docker compose -f docker/docker-compose.dev.yml up
|
||||
# Ports: frontend 3002, backend 8002, postgres 5432, IPFS API 5001, IPFS GW 8080
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-glibredecision}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-glibredecision}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-glibredecision-dev}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-glibredecision}"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
target: development
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ../backend:/app
|
||||
ports:
|
||||
- "8002:8002"
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-glibredecision}:${POSTGRES_PASSWORD:-glibredecision-dev}@postgres:5432/${POSTGRES_DB:-glibredecision}
|
||||
SECRET_KEY: dev-secret-key-not-for-production
|
||||
DEBUG: "true"
|
||||
ENVIRONMENT: development
|
||||
CORS_ORIGINS: '["http://localhost:3002"]'
|
||||
labels: []
|
||||
DUNITER_RPC_URL: ${DUNITER_RPC_URL:-wss://gdev.p2p.legal/ws}
|
||||
IPFS_API_URL: http://ipfs:5001
|
||||
IPFS_GATEWAY_URL: http://ipfs:8080
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: docker/frontend.Dockerfile
|
||||
target: development
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
- ../frontend:/app
|
||||
- frontend-node-modules:/app/node_modules
|
||||
ports:
|
||||
- "3002:3002"
|
||||
environment:
|
||||
NUXT_PUBLIC_API_BASE: http://localhost:8002/api/v1
|
||||
labels: []
|
||||
|
||||
ipfs:
|
||||
image: ipfs/kubo:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5001:5001"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ipfs-data:/data/ipfs
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
ipfs-data:
|
||||
frontend-node-modules:
|
||||
|
||||
@@ -42,4 +42,10 @@ FROM base AS development
|
||||
ENV NODE_ENV=development
|
||||
|
||||
WORKDIR /app
|
||||
ENTRYPOINT ["npm", "run", "dev"]
|
||||
|
||||
COPY frontend/package.json ./
|
||||
RUN npm install
|
||||
|
||||
EXPOSE 3002
|
||||
|
||||
CMD ["npm", "run", "dev"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const { initMood } = useMood()
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
@@ -43,6 +44,9 @@ const ws = useWebSocket()
|
||||
const { setupWsNotifications } = useNotifications()
|
||||
|
||||
onMounted(async () => {
|
||||
// Apply saved mood / ambiance
|
||||
initMood()
|
||||
|
||||
// Hydrate auth from localStorage
|
||||
auth.hydrateFromStorage()
|
||||
if (auth.token) {
|
||||
@@ -68,7 +72,13 @@ onUnmounted(() => {
|
||||
<!-- Offline detection banner -->
|
||||
<OfflineBanner />
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<div
|
||||
class="min-h-screen flex flex-col"
|
||||
:style="{
|
||||
backgroundColor: 'var(--mood-bg)',
|
||||
color: 'var(--mood-text)',
|
||||
}"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="sticky top-0 z-30 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
@@ -92,6 +102,9 @@ onUnmounted(() => {
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Center: Mood switcher -->
|
||||
<MoodSwitcher class="hidden sm:flex" />
|
||||
|
||||
<!-- Right: Auth controls -->
|
||||
<div class="flex items-center gap-2 sm:gap-4">
|
||||
<template v-if="auth.isAuthenticated">
|
||||
|
||||
167
frontend/app/assets/css/moods.css
Normal file
167
frontend/app/assets/css/moods.css
Normal 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);
|
||||
}
|
||||
54
frontend/app/components/common/MoodSwitcher.vue
Normal file
54
frontend/app/components/common/MoodSwitcher.vue
Normal 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>
|
||||
278
frontend/app/components/common/SectionLayout.vue
Normal file
278
frontend/app/components/common/SectionLayout.vue
Normal 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>
|
||||
@@ -1,61 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
status: string
|
||||
type?: 'document' | 'decision' | 'mandate' | 'version' | 'vote'
|
||||
type?: 'document' | 'decision' | 'mandate' | 'vote' | 'version'
|
||||
clickable?: boolean
|
||||
active?: boolean
|
||||
}>(), {
|
||||
clickable: true,
|
||||
active: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
const statusConfig: Record<string, Record<string, { color: string; label: string }>> = {
|
||||
document: {
|
||||
draft: { color: 'warning', label: 'Brouillon' },
|
||||
active: { color: 'success', label: 'Actif' },
|
||||
archived: { color: 'neutral', label: 'Archive' },
|
||||
},
|
||||
version: {
|
||||
proposed: { color: 'info', label: 'Propose' },
|
||||
voting: { color: 'warning', label: 'En vote' },
|
||||
accepted: { color: 'success', label: 'Accepte' },
|
||||
rejected: { color: 'error', label: 'Rejete' },
|
||||
},
|
||||
decision: {
|
||||
draft: { color: 'warning', label: 'Brouillon' },
|
||||
qualification: { color: 'info', label: 'Qualification' },
|
||||
review: { color: 'info', label: 'Revue' },
|
||||
voting: { color: 'primary', label: 'En vote' },
|
||||
executed: { color: 'success', label: 'Execute' },
|
||||
closed: { color: 'neutral', label: 'Clos' },
|
||||
},
|
||||
mandate: {
|
||||
draft: { color: 'warning', label: 'Brouillon' },
|
||||
candidacy: { color: 'info', label: 'Candidature' },
|
||||
voting: { color: 'primary', label: 'En vote' },
|
||||
active: { color: 'success', label: 'Actif' },
|
||||
reporting: { color: 'info', label: 'Rapport' },
|
||||
completed: { color: 'neutral', label: 'Termine' },
|
||||
revoked: { color: 'error', label: 'Revoque' },
|
||||
},
|
||||
vote: {
|
||||
open: { color: 'success', label: 'Ouvert' },
|
||||
closed: { color: 'warning', label: 'Ferme' },
|
||||
tallied: { color: 'neutral', label: 'Depouille' },
|
||||
},
|
||||
const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
|
||||
// Universal statuses
|
||||
draft: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
active: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
||||
closed: { label: 'Clos', cssClass: 'status-clos' },
|
||||
|
||||
// Decision/vote specific
|
||||
qualification: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
review: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
voting: { label: 'En vote', cssClass: 'status-vote' },
|
||||
open: { label: 'En vote', cssClass: 'status-vote' },
|
||||
executed: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
||||
|
||||
// Version specific
|
||||
pending: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
accepted: { label: 'En vigueur', cssClass: 'status-vigueur' },
|
||||
rejected: { label: 'Clos', cssClass: 'status-clos' },
|
||||
|
||||
// Mandate specific
|
||||
formulation: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
candidature: { label: 'En prepa', cssClass: 'status-prepa' },
|
||||
investiture: { label: 'En vote', cssClass: 'status-vote' },
|
||||
revoked: { label: 'Clos', cssClass: 'status-clos' },
|
||||
completed: { label: 'Clos', cssClass: 'status-clos' },
|
||||
}
|
||||
|
||||
const resolved = computed(() => {
|
||||
const typeKey = props.type || 'document'
|
||||
const typeMap = statusConfig[typeKey]
|
||||
if (typeMap && typeMap[props.status]) {
|
||||
return typeMap[props.status]
|
||||
}
|
||||
return { color: 'neutral', label: props.status }
|
||||
return STATUS_MAP[props.status] ?? { label: props.status, cssClass: 'status-prepa' }
|
||||
})
|
||||
|
||||
function handleClick() {
|
||||
if (props.clickable) {
|
||||
emit('click')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
:color="(resolved.color as any)"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
<button
|
||||
v-if="clickable"
|
||||
type="button"
|
||||
class="status-pill"
|
||||
:class="[resolved.cssClass, { 'status-pill--active': active }]"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ resolved.label }}
|
||||
</UBadge>
|
||||
</button>
|
||||
<span
|
||||
v-else
|
||||
class="status-pill"
|
||||
:class="[resolved.cssClass]"
|
||||
>
|
||||
{{ resolved.label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
border: 1px solid transparent;
|
||||
cursor: default;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
button.status-pill {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.status-pill:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.status-pill--active {
|
||||
box-shadow: 0 0 0 2px currentColor;
|
||||
}
|
||||
|
||||
/* --- En prepa (amber/warning) --- */
|
||||
.status-prepa {
|
||||
background-color: var(--ui-color-amber-50, #fffbeb);
|
||||
color: var(--ui-color-amber-700, #b45309);
|
||||
border-color: var(--ui-color-amber-200, #fde68a);
|
||||
}
|
||||
|
||||
/* --- En vigueur (green/success) --- */
|
||||
.status-vigueur {
|
||||
background-color: var(--ui-color-green-50, #f0fdf4);
|
||||
color: var(--ui-color-green-700, #15803d);
|
||||
border-color: var(--ui-color-green-200, #bbf7d0);
|
||||
}
|
||||
|
||||
/* --- En vote (blue/primary) --- */
|
||||
.status-vote {
|
||||
background-color: var(--ui-color-blue-50, #eff6ff);
|
||||
color: var(--ui-color-blue-700, #1d4ed8);
|
||||
border-color: var(--ui-color-blue-200, #bfdbfe);
|
||||
}
|
||||
|
||||
/* --- Clos (gray/neutral) --- */
|
||||
.status-clos {
|
||||
background-color: var(--ui-color-gray-50, #f9fafb);
|
||||
color: var(--ui-color-gray-500, #6b7280);
|
||||
border-color: var(--ui-color-gray-200, #e5e7eb);
|
||||
}
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .status-prepa {
|
||||
background-color: var(--ui-color-amber-950, #451a03);
|
||||
color: var(--ui-color-amber-300, #fcd34d);
|
||||
border-color: var(--ui-color-amber-800, #92400e);
|
||||
}
|
||||
|
||||
.dark .status-vigueur {
|
||||
background-color: var(--ui-color-green-950, #052e16);
|
||||
color: var(--ui-color-green-300, #86efac);
|
||||
border-color: var(--ui-color-green-800, #166534);
|
||||
}
|
||||
|
||||
.dark .status-vote {
|
||||
background-color: var(--ui-color-blue-950, #172554);
|
||||
color: var(--ui-color-blue-300, #93c5fd);
|
||||
border-color: var(--ui-color-blue-800, #1e40af);
|
||||
}
|
||||
|
||||
.dark .status-clos {
|
||||
background-color: var(--ui-color-gray-900, #111827);
|
||||
color: var(--ui-color-gray-400, #9ca3af);
|
||||
border-color: var(--ui-color-gray-700, #374151);
|
||||
}
|
||||
</style>
|
||||
|
||||
132
frontend/app/components/common/ToolboxVignette.vue
Normal file
132
frontend/app/components/common/ToolboxVignette.vue
Normal 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>
|
||||
@@ -99,7 +99,7 @@ function onSubmit() {
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Protocole de vote
|
||||
</label>
|
||||
<ProtocolsProtocolPicker
|
||||
<ProtocolPicker
|
||||
:model-value="modelValue.voting_protocol_id ?? null"
|
||||
@update:model-value="updateField('voting_protocol_id', $event)"
|
||||
/>
|
||||
|
||||
@@ -48,7 +48,7 @@ function navigate() {
|
||||
{{ decision.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<CommonStatusBadge :status="decision.status" type="decision" />
|
||||
<StatusBadge :status="decision.status" type="decision" />
|
||||
</div>
|
||||
|
||||
<p v-if="decision.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
|
||||
@@ -96,7 +96,7 @@ function formatDate(dateStr: string): string {
|
||||
{{ stepTypeLabel(step.step_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<CommonStatusBadge :status="step.status" type="decision" />
|
||||
<StatusBadge :status="step.status" type="decision" />
|
||||
</div>
|
||||
|
||||
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
|
||||
|
||||
@@ -55,7 +55,7 @@ function formatDate(dateStr: string): string {
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white text-sm leading-tight">
|
||||
{{ doc.title }}
|
||||
</h3>
|
||||
<CommonStatusBadge :status="doc.status" type="document" />
|
||||
<StatusBadge :status="doc.status" type="document" />
|
||||
</div>
|
||||
|
||||
<!-- Type + Version -->
|
||||
|
||||
@@ -70,7 +70,7 @@ function navigateToItem() {
|
||||
|
||||
<!-- Item text -->
|
||||
<div class="pl-2">
|
||||
<CommonMarkdownRenderer :content="item.current_text" />
|
||||
<MarkdownRenderer :content="item.current_text" />
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
|
||||
@@ -35,7 +35,7 @@ function truncateAddress(address: string | null): string {
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<CommonStatusBadge :status="version.status" type="version" />
|
||||
<StatusBadge :status="version.status" type="version" />
|
||||
<span class="text-sm text-gray-500">
|
||||
Propose par
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300 font-mono text-xs">
|
||||
@@ -57,14 +57,14 @@ function truncateAddress(address: string | null): string {
|
||||
<!-- Diff view -->
|
||||
<div v-if="version.diff">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Modifications</p>
|
||||
<CommonDiffView :diff="version.diff" />
|
||||
<DiffView :diff="version.diff" />
|
||||
</div>
|
||||
|
||||
<!-- Proposed text (fallback if no diff) -->
|
||||
<div v-else-if="version.proposed_text">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-2">Texte propose</p>
|
||||
<div class="bg-green-50 dark:bg-green-900/10 border border-green-200 dark:border-green-800 rounded-lg p-3">
|
||||
<CommonMarkdownRenderer :content="version.proposed_text" />
|
||||
<MarkdownRenderer :content="version.proposed_text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ function navigate() {
|
||||
{{ mandate.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<CommonStatusBadge :status="mandate.status" type="mandate" />
|
||||
<StatusBadge :status="mandate.status" type="mandate" />
|
||||
</div>
|
||||
|
||||
<p v-if="mandate.description" class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
|
||||
@@ -98,7 +98,7 @@ function formatDate(dateStr: string): string {
|
||||
{{ stepTypeLabel(step.step_type) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<CommonStatusBadge :status="step.status" type="mandate" />
|
||||
<StatusBadge :status="step.status" type="mandate" />
|
||||
</div>
|
||||
|
||||
<h3 v-if="step.title" class="font-medium text-gray-900 dark:text-white">
|
||||
|
||||
@@ -121,7 +121,7 @@ async function copyHash() {
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">IPFS CID</span>
|
||||
</div>
|
||||
<div @click.stop>
|
||||
<SanctuaryIPFSLink :cid="entry.ipfs_cid" />
|
||||
<IPFSLink :cid="entry.ipfs_cid" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@ async function copyHash() {
|
||||
<UIcon name="i-lucide-link" class="text-gray-400 text-sm" />
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">On-chain</span>
|
||||
</div>
|
||||
<SanctuaryChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
|
||||
<ChainAnchor :tx-hash="entry.chain_tx_hash" :block="entry.chain_block" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
65
frontend/app/composables/useMood.ts
Normal file
65
frontend/app/composables/useMood.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -334,7 +334,7 @@ async function handleAddStep() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DecisionsDecisionWorkflow
|
||||
<DecisionWorkflow
|
||||
:steps="decisions.current.steps"
|
||||
:current-status="decisions.current.status"
|
||||
@create-vote-session="handleCreateVoteSession"
|
||||
|
||||
@@ -1,67 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Decisions — page index.
|
||||
*
|
||||
* Utilise SectionLayout avec status filters, recherche, tri,
|
||||
* et sidebar "Boite a outils" affichant les protocoles de vote.
|
||||
*/
|
||||
const decisions = useDecisionsStore()
|
||||
const protocols = useProtocolsStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const filterType = ref<string | undefined>(undefined)
|
||||
const filterStatus = ref<string | undefined>(undefined)
|
||||
const activeStatus = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const sortBy = ref<'date' | 'title' | 'status'>('date')
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Tous les types', value: undefined },
|
||||
{ label: 'Runtime upgrade', value: 'runtime_upgrade' },
|
||||
{ label: 'Modification de document', value: 'document_change' },
|
||||
{ label: 'Vote de mandat', value: 'mandate_vote' },
|
||||
{ label: 'Changement de parametre', value: 'parameter_change' },
|
||||
{ label: 'Autre', value: 'other' },
|
||||
const sortOptions = [
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Titre', value: 'title' },
|
||||
{ label: 'Statut', value: 'status' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Tous les statuts', value: undefined },
|
||||
{ label: 'Brouillon', value: 'draft' },
|
||||
{ label: 'Qualification', value: 'qualification' },
|
||||
{ label: 'Revue', value: 'review' },
|
||||
{ label: 'En vote', value: 'voting' },
|
||||
{ label: 'Execute', value: 'executed' },
|
||||
{ label: 'Clos', value: 'closed' },
|
||||
]
|
||||
|
||||
async function loadDecisions() {
|
||||
await decisions.fetchAll({
|
||||
decision_type: filterType.value,
|
||||
status: filterStatus.value,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDecisions()
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
decisions.fetchAll(),
|
||||
protocols.fetchProtocols(),
|
||||
])
|
||||
})
|
||||
|
||||
watch([filterType, filterStatus], () => {
|
||||
loadDecisions()
|
||||
/** Status filter pills with counts. */
|
||||
const statuses = computed(() => [
|
||||
{ id: 'draft', label: 'En prepa', count: decisions.list.filter(d => d.status === 'draft').length },
|
||||
{ id: 'voting', label: 'En vote', count: decisions.list.filter(d => d.status === 'voting' || d.status === 'qualification' || d.status === 'review').length },
|
||||
{ id: 'executed', label: 'En vigueur', count: decisions.list.filter(d => d.status === 'executed').length },
|
||||
{ id: 'closed', label: 'Clos', count: decisions.list.filter(d => d.status === 'closed').length },
|
||||
])
|
||||
|
||||
/** Map for the voting pill — include qualification/review under "En vote". */
|
||||
const statusGroupMap: Record<string, string[]> = {
|
||||
draft: ['draft'],
|
||||
voting: ['qualification', 'review', 'voting'],
|
||||
executed: ['executed'],
|
||||
closed: ['closed'],
|
||||
}
|
||||
|
||||
/** Filtered and sorted decisions. */
|
||||
const filteredDecisions = computed(() => {
|
||||
let list = [...decisions.list]
|
||||
|
||||
// Filter by status group
|
||||
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
|
||||
const statuses = statusGroupMap[activeStatus.value]
|
||||
list = list.filter(d => statuses.includes(d.status))
|
||||
}
|
||||
|
||||
// Filter by search query (client-side)
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
list = list.filter(d => d.title.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
// Sort
|
||||
switch (sortBy.value) {
|
||||
case 'title':
|
||||
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
|
||||
break
|
||||
case 'status':
|
||||
list.sort((a, b) => a.status.localeCompare(b.status))
|
||||
break
|
||||
case 'date':
|
||||
default:
|
||||
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
break
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft': return 'warning'
|
||||
case 'qualification': return 'info'
|
||||
case 'review': return 'info'
|
||||
case 'voting': return 'primary'
|
||||
case 'executed': return 'success'
|
||||
case 'closed': return 'neutral'
|
||||
default: return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'draft': return 'Brouillon'
|
||||
case 'qualification': return 'Qualification'
|
||||
case 'review': return 'Revue'
|
||||
case 'voting': return 'En vote'
|
||||
case 'executed': return 'Execute'
|
||||
case 'closed': return 'Clos'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = (decisionType: string) => {
|
||||
switch (decisionType) {
|
||||
case 'runtime_upgrade': return 'Runtime upgrade'
|
||||
@@ -83,121 +96,204 @@ function formatDate(dateStr: string): string {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Decisions
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Processus de decision collectifs de la communaute
|
||||
<SectionLayout
|
||||
title="Decisions"
|
||||
subtitle="Processus de decision collectifs"
|
||||
:statuses="statuses"
|
||||
:active-status="activeStatus"
|
||||
@update:active-status="activeStatus = $event"
|
||||
>
|
||||
<!-- Search / sort bar -->
|
||||
<template #search>
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Rechercher une decision..."
|
||||
icon="i-lucide-search"
|
||||
size="sm"
|
||||
class="w-full sm:w-64"
|
||||
/>
|
||||
<USelect
|
||||
v-model="sortBy"
|
||||
:items="sortOptions"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
<UButton
|
||||
v-if="auth.isAuthenticated"
|
||||
to="/decisions/new"
|
||||
label="Nouvelle"
|
||||
icon="i-lucide-plus"
|
||||
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>
|
||||
</div>
|
||||
<UButton
|
||||
to="/decisions/new"
|
||||
icon="i-lucide-plus"
|
||||
label="Nouvelle decision"
|
||||
color="primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<USelect
|
||||
v-model="filterType"
|
||||
:items="typeOptions"
|
||||
placeholder="Type de decision"
|
||||
class="w-56"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
placeholder="Statut"
|
||||
class="w-48"
|
||||
/>
|
||||
</div>
|
||||
<!-- Decision cards -->
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="decision in filteredDecisions"
|
||||
:key="decision.id"
|
||||
class="decision-card"
|
||||
@click="navigateTo(`/decisions/${decision.id}`)"
|
||||
>
|
||||
<div class="decision-card__header">
|
||||
<div class="decision-card__title-block">
|
||||
<h3 class="decision-card__title">
|
||||
{{ decision.title }}
|
||||
</h3>
|
||||
<p v-if="decision.description" class="decision-card__description">
|
||||
{{ decision.description }}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge :status="decision.status" type="decision" />
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<template v-if="decisions.loading">
|
||||
<div class="space-y-3">
|
||||
<USkeleton v-for="i in 5" :key="i" class="h-12 w-full" />
|
||||
<div class="decision-card__meta">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(decision.decision_type) }}
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="decisions.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ decisions.error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
<!-- Toolbox sidebar -->
|
||||
<template #toolbox>
|
||||
<div class="toolbox-section-title">
|
||||
Modalites de vote
|
||||
</div>
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:title="protocol.name"
|
||||
:description="protocol.description || undefined"
|
||||
context-label="Decisions"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<p v-else class="toolbox-empty-text">
|
||||
Aucun protocole configure
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
</SectionLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.decision-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1rem;
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.decision-card:hover {
|
||||
border-color: var(--mood-accent);
|
||||
box-shadow: 0 2px 8px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.decision-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.decision-card__title-block {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.decision-card__title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.decision-card__description {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.decision-card__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.decision-card__steps {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.decision-card__date {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toolbox-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.toolbox-empty-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -62,7 +62,7 @@ async function onSubmit() {
|
||||
|
||||
<!-- Form -->
|
||||
<UCard>
|
||||
<DecisionsDecisionCadrage
|
||||
<DecisionCadrage
|
||||
v-model="formData"
|
||||
:submitting="submitting"
|
||||
@submit="onSubmit"
|
||||
|
||||
@@ -105,7 +105,7 @@ async function archiveToSanctuary() {
|
||||
<UBadge variant="subtle" color="primary">
|
||||
{{ typeLabel(documents.current.doc_type) }}
|
||||
</UBadge>
|
||||
<CommonStatusBadge :status="documents.current.status" type="document" />
|
||||
<StatusBadge :status="documents.current.status" type="document" />
|
||||
<span class="text-sm text-gray-500 font-mono">
|
||||
v{{ documents.current.version }}
|
||||
</span>
|
||||
@@ -155,7 +155,7 @@ async function archiveToSanctuary() {
|
||||
<div>
|
||||
<p class="text-gray-500">Ancrage IPFS</p>
|
||||
<div class="mt-1">
|
||||
<SanctuaryIPFSLink :cid="documents.current.ipfs_cid" />
|
||||
<IPFSLink :cid="documents.current.ipfs_cid" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +164,7 @@ async function archiveToSanctuary() {
|
||||
<div v-if="documents.current.chain_anchor" class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-gray-500">Ancrage on-chain :</p>
|
||||
<SanctuaryChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
|
||||
<ChainAnchor :tx-hash="documents.current.chain_anchor" :block="null" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
@@ -181,7 +181,7 @@ async function archiveToSanctuary() {
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<DocumentsItemCard
|
||||
<ItemCard
|
||||
v-for="item in documents.items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
|
||||
@@ -218,7 +218,7 @@ function formatDate(dateStr: string): string {
|
||||
<UIcon name="i-lucide-file-text" class="text-gray-400" />
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase">Texte en vigueur</h2>
|
||||
</div>
|
||||
<CommonMarkdownRenderer :content="currentItem.current_text" />
|
||||
<MarkdownRenderer :content="currentItem.current_text" />
|
||||
<div class="flex items-center gap-4 pt-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400">
|
||||
<span>Cree le {{ formatDate(currentItem.created_at) }}</span>
|
||||
<span>Mis a jour le {{ formatDate(currentItem.updated_at) }}</span>
|
||||
@@ -259,7 +259,7 @@ function formatDate(dateStr: string): string {
|
||||
</template>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<DocumentsItemVersionDiff
|
||||
<ItemVersionDiff
|
||||
v-for="version in documents.versions"
|
||||
:key="version.id"
|
||||
:version="version"
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Documents de reference — page index.
|
||||
*
|
||||
* Utilise SectionLayout avec status filters, recherche, tri,
|
||||
* et sidebar "Boite a outils" affichant les protocoles de vote.
|
||||
*/
|
||||
import type { DocumentCreate } from '~/stores/documents'
|
||||
|
||||
const documents = useDocumentsStore()
|
||||
const protocols = useProtocolsStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const filterType = ref<string | undefined>(undefined)
|
||||
const filterStatus = ref<string | undefined>(undefined)
|
||||
|
||||
const docTypeOptions = [
|
||||
{ label: 'Tous les types', value: undefined },
|
||||
{ label: 'Licence', value: 'licence' },
|
||||
{ label: 'Engagement', value: 'engagement' },
|
||||
{ label: 'Reglement', value: 'reglement' },
|
||||
{ label: 'Constitution', value: 'constitution' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Tous les statuts', value: undefined },
|
||||
{ label: 'Brouillon', value: 'draft' },
|
||||
{ label: 'Actif', value: 'active' },
|
||||
{ label: 'Archive', value: 'archived' },
|
||||
]
|
||||
const activeStatus = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const sortBy = ref<'date' | 'title' | 'status'>('date')
|
||||
|
||||
// New document modal state
|
||||
const showNewDocModal = ref(false)
|
||||
@@ -40,30 +33,78 @@ const newDocTypeOptions = [
|
||||
{ label: 'Constitution', value: 'constitution' },
|
||||
]
|
||||
|
||||
async function loadDocuments() {
|
||||
await documents.fetchAll({
|
||||
doc_type: filterType.value,
|
||||
status: filterStatus.value,
|
||||
})
|
||||
const sortOptions = [
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Titre', value: 'title' },
|
||||
{ label: 'Statut', value: 'status' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
documents.fetchAll(),
|
||||
protocols.fetchProtocols(),
|
||||
])
|
||||
})
|
||||
|
||||
/** Status filter pills with counts. */
|
||||
const statuses = computed(() => [
|
||||
{ id: 'draft', label: 'En prepa', count: documents.list.filter(d => d.status === 'draft').length },
|
||||
{ id: 'voting', label: 'En vote', count: documents.list.filter(d => d.status === 'voting').length },
|
||||
{ id: 'active', label: 'En vigueur', count: documents.list.filter(d => d.status === 'active').length },
|
||||
{ id: 'archived', label: 'Clos', count: documents.list.filter(d => d.status === 'archived').length },
|
||||
])
|
||||
|
||||
/** Filtered and sorted documents. */
|
||||
const filteredDocuments = computed(() => {
|
||||
let list = [...documents.list]
|
||||
|
||||
// Filter by status
|
||||
if (activeStatus.value) {
|
||||
list = list.filter(d => d.status === activeStatus.value)
|
||||
}
|
||||
|
||||
// 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(() => {
|
||||
loadDocuments()
|
||||
})
|
||||
|
||||
watch([filterType, filterStatus], () => {
|
||||
loadDocuments()
|
||||
})
|
||||
|
||||
function openNewDocModal() {
|
||||
newDoc.value = {
|
||||
slug: '',
|
||||
title: '',
|
||||
doc_type: 'licence',
|
||||
description: null,
|
||||
version: '1.0.0',
|
||||
}
|
||||
showNewDocModal.value = true
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
@@ -83,6 +124,17 @@ watch(() => newDoc.value.title, (title) => {
|
||||
}
|
||||
})
|
||||
|
||||
function openNewDocModal() {
|
||||
newDoc.value = {
|
||||
slug: '',
|
||||
title: '',
|
||||
doc_type: 'licence',
|
||||
description: null,
|
||||
version: '1.0.0',
|
||||
}
|
||||
showNewDocModal.value = true
|
||||
}
|
||||
|
||||
async function createDocument() {
|
||||
creating.value = true
|
||||
try {
|
||||
@@ -91,153 +143,301 @@ async function createDocument() {
|
||||
if (doc) {
|
||||
navigateTo(`/documents/${doc.slug}`)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
// Error handled in store
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Documents de reference
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Documents fondateurs de la communaute Duniter/G1 sous vote permanent
|
||||
<SectionLayout
|
||||
title="Documents de reference"
|
||||
subtitle="Textes fondateurs sous vote permanent de la communaute"
|
||||
:statuses="statuses"
|
||||
:active-status="activeStatus"
|
||||
@update:active-status="activeStatus = $event"
|
||||
>
|
||||
<!-- Search / sort bar -->
|
||||
<template #search>
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Rechercher un document..."
|
||||
icon="i-lucide-search"
|
||||
size="sm"
|
||||
class="w-full sm:w-64"
|
||||
/>
|
||||
<USelect
|
||||
v-model="sortBy"
|
||||
:items="sortOptions"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
<UButton
|
||||
v-if="auth.isAuthenticated"
|
||||
label="Nouveau"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="openNewDocModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Main content: document list -->
|
||||
<template #default>
|
||||
<!-- Error state -->
|
||||
<div v-if="documents.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
|
||||
<p style="color: var(--mood-text);">{{ documents.error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else-if="documents.loading" class="space-y-3">
|
||||
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="filteredDocuments.length === 0"
|
||||
class="text-center py-12"
|
||||
style="color: var(--mood-text-muted);"
|
||||
>
|
||||
<UIcon name="i-lucide-book-open" class="text-4xl mb-3 block mx-auto" />
|
||||
<p>Aucun document trouve</p>
|
||||
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
|
||||
Essayez de modifier vos filtres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- New document button for authenticated users -->
|
||||
<UButton
|
||||
v-if="auth.isAuthenticated"
|
||||
label="Nouveau document"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
@click="openNewDocModal"
|
||||
/>
|
||||
</div>
|
||||
<!-- Document cards -->
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="doc in filteredDocuments"
|
||||
:key="doc.id"
|
||||
class="doc-card"
|
||||
@click="navigateTo(`/documents/${doc.slug}`)"
|
||||
>
|
||||
<div class="doc-card__header">
|
||||
<h3 class="doc-card__title">
|
||||
{{ doc.title }}
|
||||
</h3>
|
||||
<StatusBadge :status="doc.status" type="document" />
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<USelect
|
||||
v-model="filterType"
|
||||
:items="docTypeOptions"
|
||||
placeholder="Type de document"
|
||||
class="w-48"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
placeholder="Statut"
|
||||
class="w-48"
|
||||
/>
|
||||
</div>
|
||||
<div class="doc-card__meta">
|
||||
<UBadge variant="subtle" color="primary" size="xs">
|
||||
{{ typeLabel(doc.doc_type) }}
|
||||
</UBadge>
|
||||
<span class="doc-card__version">v{{ doc.version }}</span>
|
||||
<span class="doc-card__items">
|
||||
<UIcon name="i-lucide-list" class="text-xs" />
|
||||
{{ doc.items_count }} item{{ doc.items_count !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
<span class="doc-card__date">
|
||||
{{ formatDate(doc.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-if="documents.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ documents.error }}</p>
|
||||
<p v-if="doc.description" class="doc-card__description">
|
||||
{{ doc.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Document list component -->
|
||||
<DocumentsDocumentList
|
||||
:documents="documents.list"
|
||||
:loading="documents.loading"
|
||||
/>
|
||||
<!-- Toolbox sidebar -->
|
||||
<template #toolbox>
|
||||
<div class="toolbox-section-title">
|
||||
{{ toolboxTitle }}
|
||||
</div>
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:title="protocol.name"
|
||||
:description="protocol.description || undefined"
|
||||
context-label="Documents"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
<p v-else class="toolbox-empty-text">
|
||||
Aucun protocole configure
|
||||
</p>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
|
||||
<!-- New document modal -->
|
||||
<UModal v-model:open="showNewDocModal">
|
||||
<template #content>
|
||||
<div class="p-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Nouveau document de reference
|
||||
</h3>
|
||||
<!-- New document modal -->
|
||||
<UModal v-model:open="showNewDocModal">
|
||||
<template #content>
|
||||
<div class="p-6 space-y-4">
|
||||
<h3 class="text-lg font-semibold" style="color: var(--mood-text);">
|
||||
Nouveau document de reference
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Titre
|
||||
</label>
|
||||
<UInput
|
||||
v-model="newDoc.title"
|
||||
placeholder="Ex: Licence G1"
|
||||
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 class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
||||
Titre
|
||||
</label>
|
||||
<UInput
|
||||
v-model="newDoc.title"
|
||||
placeholder="Ex: Licence G1"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-2">
|
||||
<UButton
|
||||
label="Annuler"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="showNewDocModal = false"
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
||||
Slug (identifiant URL)
|
||||
</label>
|
||||
<UInput
|
||||
v-model="newDoc.slug"
|
||||
placeholder="Ex: licence-g1"
|
||||
class="w-full font-mono text-sm"
|
||||
/>
|
||||
<UButton
|
||||
label="Creer le document"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
:loading="creating"
|
||||
:disabled="!newDoc.title.trim() || !newDoc.slug.trim()"
|
||||
@click="createDocument"
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
||||
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" 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>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 pt-2">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,66 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Dashboard / Page d'accueil — Glibredecision.
|
||||
*
|
||||
* Accueil chaleureux avec onboarding : cartes d'entree vers les sections,
|
||||
* banniere de connexion, apercu de la boite a outils et activite recente.
|
||||
*/
|
||||
const documents = useDocumentsStore()
|
||||
const decisions = useDecisionsStore()
|
||||
const mandates = useMandatesStore()
|
||||
const votes = useVotesStore()
|
||||
const protocols = useProtocolsStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const formulaOpen = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
documents.fetchAll(),
|
||||
decisions.fetchAll(),
|
||||
mandates.fetchAll(),
|
||||
votes.fetchSessions(),
|
||||
protocols.fetchProtocols(),
|
||||
])
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/** Summary stats for the dashboard cards. */
|
||||
const stats = computed(() => [
|
||||
/** Entry cards — the 3 main doors. */
|
||||
const entryCards = computed(() => [
|
||||
{
|
||||
label: 'Documents actifs',
|
||||
value: documents.activeDocuments.length,
|
||||
total: documents.list.length,
|
||||
key: 'documents',
|
||||
title: 'Documents',
|
||||
icon: 'i-lucide-book-open',
|
||||
color: 'primary' as const,
|
||||
to: '/documents',
|
||||
count: documents.activeDocuments.length,
|
||||
countLabel: `${documents.activeDocuments.length} actif${documents.activeDocuments.length > 1 ? 's' : ''}`,
|
||||
totalLabel: `${documents.list.length} au total`,
|
||||
description: 'Textes fondateurs sous vote permanent',
|
||||
},
|
||||
{
|
||||
label: 'Decisions en cours',
|
||||
value: decisions.activeDecisions.length,
|
||||
total: decisions.list.length,
|
||||
key: 'decisions',
|
||||
title: 'Decisions',
|
||||
icon: 'i-lucide-scale',
|
||||
color: 'success' as const,
|
||||
to: '/decisions',
|
||||
count: decisions.activeDecisions.length,
|
||||
countLabel: `${decisions.activeDecisions.length} en cours`,
|
||||
totalLabel: `${decisions.list.length} au total`,
|
||||
description: 'Processus de decision collectifs',
|
||||
},
|
||||
{
|
||||
label: 'Votes ouverts',
|
||||
value: openVoteSessions.value.length,
|
||||
total: votes.sessions.length,
|
||||
icon: 'i-lucide-vote',
|
||||
color: 'warning' as const,
|
||||
to: '/decisions',
|
||||
},
|
||||
{
|
||||
label: 'Mandats actifs',
|
||||
value: mandates.activeMandates.length,
|
||||
total: mandates.list.length,
|
||||
key: 'mandats',
|
||||
title: 'Mandats',
|
||||
icon: 'i-lucide-user-check',
|
||||
color: 'info' as const,
|
||||
to: '/mandates',
|
||||
count: null,
|
||||
countLabel: null,
|
||||
totalLabel: null,
|
||||
description: 'Un contexte, un objectif, une duree, une ou plusieurs nominations ; par defaut : nomination d\'un binome.',
|
||||
},
|
||||
])
|
||||
|
||||
/** Open vote sessions. */
|
||||
const openVoteSessions = computed(() => {
|
||||
return votes.sessions.filter(s => s.status === 'open')
|
||||
})
|
||||
|
||||
/** Last 5 decisions sorted by most recent. */
|
||||
const recentDecisions = computed(() => {
|
||||
return [...decisions.list]
|
||||
@@ -68,13 +67,6 @@ const recentDecisions = computed(() => {
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
/** Last 5 vote sessions sorted by most recent. */
|
||||
const recentVotes = computed(() => {
|
||||
return [...votes.sessions]
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 5)
|
||||
})
|
||||
|
||||
/** Format a date string to a localized relative or absolute string. */
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
@@ -94,287 +86,504 @@ function formatDate(dateStr: string): string {
|
||||
}
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
/** Section cards for the "Domaines" grid. */
|
||||
const sections = [
|
||||
{
|
||||
title: 'Documents de reference',
|
||||
description: 'Licence G1, engagements forgerons, reglement du comite technique et autres documents fondateurs sous vote permanent.',
|
||||
icon: 'i-lucide-book-open',
|
||||
to: '/documents',
|
||||
color: 'primary' as const,
|
||||
},
|
||||
{
|
||||
title: 'Decisions',
|
||||
description: 'Processus de decision collectifs: runtime upgrades, modifications de documents, votes de mandats.',
|
||||
icon: 'i-lucide-scale',
|
||||
to: '/decisions',
|
||||
color: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: 'Mandats',
|
||||
description: 'Gestion des mandats du comite technique, des forgerons et autres roles de gouvernance.',
|
||||
icon: 'i-lucide-user-check',
|
||||
to: '/mandates',
|
||||
color: 'warning' as const,
|
||||
},
|
||||
{
|
||||
title: 'Protocoles de vote',
|
||||
description: 'Configuration des formules de seuil WoT, criteres Smith et TechComm, parametres de vote nuance.',
|
||||
icon: 'i-lucide-settings',
|
||||
to: '/protocols',
|
||||
color: 'info' as const,
|
||||
},
|
||||
{
|
||||
title: 'Sanctuaire',
|
||||
description: 'Archive immuable: documents ancres sur IPFS avec preuve on-chain via system.remark.',
|
||||
icon: 'i-lucide-archive',
|
||||
to: '/sanctuary',
|
||||
color: 'error' as const,
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Glibredecision
|
||||
<div class="dashboard" :style="{ background: 'var(--mood-gradient)' }">
|
||||
<!-- Welcome banner -->
|
||||
<div class="dashboard__welcome">
|
||||
<h1 class="dashboard__welcome-title">
|
||||
Bienvenue sur Glibredecision
|
||||
</h1>
|
||||
<p class="mt-2 text-base sm:text-lg text-gray-600 dark:text-gray-400">
|
||||
Decisions collectives pour la communaute Duniter/G1
|
||||
<p class="dashboard__welcome-subtitle">
|
||||
Plateforme de decisions collectives pour la communaute Duniter / G1.
|
||||
Explorez les documents de reference, participez aux decisions, suivez les mandats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions (authenticated users only) -->
|
||||
<div v-if="auth.isAuthenticated" class="flex flex-wrap gap-2 sm:gap-3">
|
||||
<UButton
|
||||
to="/decisions"
|
||||
icon="i-lucide-plus"
|
||||
label="Nouvelle decision"
|
||||
variant="soft"
|
||||
color="primary"
|
||||
size="sm"
|
||||
/>
|
||||
<UButton
|
||||
to="/documents"
|
||||
icon="i-lucide-book-open"
|
||||
label="Voir les documents"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
/>
|
||||
<UButton
|
||||
to="/mandates"
|
||||
icon="i-lucide-user-check"
|
||||
label="Mandats"
|
||||
variant="soft"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
||||
<!-- Entry cards grid -->
|
||||
<div class="dashboard__entries">
|
||||
<template v-if="loading">
|
||||
<LoadingSkeleton
|
||||
v-for="i in 4"
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:lines="2"
|
||||
:lines="3"
|
||||
card
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink v-for="stat in stats" :key="stat.label" :to="stat.to">
|
||||
<UCard class="hover:shadow-md transition-shadow h-full">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ stat.label }}
|
||||
</p>
|
||||
<p class="text-2xl sm:text-3xl font-bold text-gray-900 dark:text-white mt-1">
|
||||
{{ stat.value }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{{ stat.total }} au total
|
||||
</p>
|
||||
</div>
|
||||
<UIcon
|
||||
:name="stat.icon"
|
||||
class="text-2xl sm:text-3xl text-gray-400 flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
<NuxtLink
|
||||
v-for="card in entryCards"
|
||||
:key="card.key"
|
||||
:to="card.to"
|
||||
class="dashboard__entry-card"
|
||||
>
|
||||
<div class="dashboard__entry-icon">
|
||||
<UIcon :name="card.icon" class="text-2xl" />
|
||||
</div>
|
||||
<h2 class="dashboard__entry-title">
|
||||
{{ card.title }}
|
||||
</h2>
|
||||
|
||||
<!-- Count badge for documents and decisions -->
|
||||
<template v-if="card.count !== null">
|
||||
<p class="dashboard__entry-count">
|
||||
{{ card.countLabel }}
|
||||
</p>
|
||||
<p class="dashboard__entry-total">
|
||||
{{ card.totalLabel }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Special onboarding text for mandats -->
|
||||
<template v-else>
|
||||
<p class="dashboard__entry-onboard">
|
||||
{{ card.description }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<UButton
|
||||
label="Entrer"
|
||||
variant="soft"
|
||||
size="xs"
|
||||
trailing-icon="i-lucide-arrow-right"
|
||||
class="dashboard__entry-btn"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Recent activity: decisions + votes side-by-side -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
|
||||
<!-- Recent decisions -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-scale" class="text-lg text-primary" />
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
Decisions recentes
|
||||
</h3>
|
||||
</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" />
|
||||
<!-- Onboarding banner for unauthenticated users -->
|
||||
<div v-if="!auth.isAuthenticated" class="dashboard__onboarding">
|
||||
<div class="dashboard__onboarding-content">
|
||||
<UIcon name="i-lucide-key-round" class="text-xl" />
|
||||
<div>
|
||||
<p class="dashboard__onboarding-text">
|
||||
Connectez-vous avec votre identite Duniter pour participer aux decisions collectives.
|
||||
</p>
|
||||
<p class="dashboard__onboarding-hint">
|
||||
Authentification par signature Ed25519 — aucun mot de passe.
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="recentDecisions.length === 0" class="text-center py-6">
|
||||
<UIcon name="i-lucide-inbox" class="text-3xl text-gray-300 dark:text-gray-600 mx-auto" />
|
||||
<p class="text-sm text-gray-500 mt-2">Aucune decision pour le moment</p>
|
||||
</div>
|
||||
<div v-else class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<NuxtLink
|
||||
v-for="decision in recentDecisions"
|
||||
:key="decision.id"
|
||||
:to="`/decisions/${decision.id}`"
|
||||
class="flex items-center justify-between py-3 first:pt-0 last:pb-0 hover:bg-gray-50 dark:hover:bg-gray-800/50 -mx-2 px-2 rounded transition-colors"
|
||||
>
|
||||
<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>
|
||||
<UButton
|
||||
to="/login"
|
||||
label="Se connecter"
|
||||
icon="i-lucide-log-in"
|
||||
variant="soft"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Section cards (Domaines) -->
|
||||
<div>
|
||||
<h2 class="text-lg sm:text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Domaines
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
<NuxtLink v-for="section in sections" :key="section.title" :to="section.to">
|
||||
<UCard class="h-full hover:shadow-md transition-shadow">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<UIcon :name="section.icon" class="text-2xl text-primary flex-shrink-0" />
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ section.description }}
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
<!-- Boite a outils teaser -->
|
||||
<div class="dashboard__toolbox-teaser">
|
||||
<div class="dashboard__toolbox-teaser-header">
|
||||
<UIcon name="i-lucide-wrench" class="text-lg" />
|
||||
<h3>Boite a outils</h3>
|
||||
<UBadge variant="subtle" size="xs">
|
||||
{{ protocols.protocols.length }} modalite{{ protocols.protocols.length > 1 ? 's' : '' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p class="dashboard__toolbox-teaser-description">
|
||||
Protocoles de vote configurables avec formule de seuil WoT adaptative.
|
||||
</p>
|
||||
<div class="dashboard__toolbox-teaser-tags">
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<NuxtLink
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:to="`/protocols/${protocol.id}`"
|
||||
class="dashboard__toolbox-tag"
|
||||
>
|
||||
{{ protocol.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="dashboard__toolbox-tag">Vote majoritaire</span>
|
||||
<span class="dashboard__toolbox-tag">Vote nuance</span>
|
||||
<span class="dashboard__toolbox-tag">Vote permanent</span>
|
||||
</template>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula explainer -->
|
||||
<UCard>
|
||||
<div class="space-y-3">
|
||||
<!-- Formula explainer — collapsible -->
|
||||
<UCollapsible v-model:open="formulaOpen">
|
||||
<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">
|
||||
<UIcon name="i-lucide-calculator" class="text-xl text-primary" />
|
||||
<h3 class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Formule de seuil WoT
|
||||
</h3>
|
||||
<UIcon name="i-lucide-calculator" />
|
||||
<span>Formule de seuil WoT</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Le seuil d'adoption s'adapte dynamiquement a la participation :
|
||||
faible participation = quasi-unanimite requise ; forte participation = majorite simple suffisante.
|
||||
</p>
|
||||
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-xs sm:text-sm font-mono overflow-x-auto">
|
||||
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
|
||||
</code>
|
||||
<div class="flex flex-wrap gap-3 sm:gap-4 text-xs text-gray-500">
|
||||
<span>C = constante de base</span>
|
||||
<span>B = exposant de base</span>
|
||||
<span>W = taille WoT</span>
|
||||
<span>T = votes totaux</span>
|
||||
<span>M = majorite</span>
|
||||
<span>G = gradient</span>
|
||||
</UButton>
|
||||
<template #content>
|
||||
<div class="dashboard__formula-content">
|
||||
<p class="dashboard__formula-description">
|
||||
Le seuil d'adoption s'adapte dynamiquement a la participation :
|
||||
faible participation = quasi-unanimite requise ; forte participation = majorite simple suffisante.
|
||||
</p>
|
||||
<code class="dashboard__formula-code">
|
||||
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
|
||||
</code>
|
||||
<div class="dashboard__formula-params">
|
||||
<span>C = constante de base</span>
|
||||
<span>B = exposant de base</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>
|
||||
</UCard>
|
||||
</template>
|
||||
</UCollapsible>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
min-height: 100%;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* --- Welcome --- */
|
||||
.dashboard__welcome {
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.dashboard__welcome-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-accent);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.dashboard__welcome-title {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard__welcome-subtitle {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--mood-text-muted);
|
||||
max-width: 42rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* --- Entry cards --- */
|
||||
.dashboard__entries {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.dashboard__entry-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 0.75rem;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dashboard__entry-card:hover {
|
||||
border-color: var(--mood-accent);
|
||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.dashboard__entry-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.dashboard__entry-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.dashboard__entry-count {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-accent);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.dashboard__entry-total {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.dashboard__entry-onboard {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.4;
|
||||
max-width: 18rem;
|
||||
}
|
||||
|
||||
.dashboard__entry-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Onboarding banner --- */
|
||||
.dashboard__onboarding {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard__onboarding-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.dashboard__onboarding-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dashboard__onboarding-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
/* --- Toolbox teaser --- */
|
||||
.dashboard__toolbox-teaser {
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.dashboard__toolbox-teaser-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--mood-accent);
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.dashboard__toolbox-teaser-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard__toolbox-teaser-description {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.dashboard__toolbox-teaser-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.dashboard__toolbox-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--mood-accent);
|
||||
background: var(--mood-accent-soft);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 9999px;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard__toolbox-tag:hover {
|
||||
background: var(--mood-surface-hover);
|
||||
}
|
||||
|
||||
/* --- Recent activity --- */
|
||||
.dashboard__activity {
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.dashboard__activity-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--mood-text);
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard__activity-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dashboard__activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard__activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--mood-border);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard__activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dashboard__activity-item:hover {
|
||||
background: var(--mood-surface-hover);
|
||||
margin-left: -0.5rem;
|
||||
margin-right: -0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.dashboard__activity-item-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dashboard__activity-item-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--mood-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dashboard__activity-item-date {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
/* --- Formula explainer --- */
|
||||
.dashboard__formula-trigger {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard__formula-content {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard__formula-description {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dashboard__formula-code {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
color: var(--mood-text);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.dashboard__formula-params {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -200,7 +200,7 @@ async function handleDelete() {
|
||||
<UBadge variant="subtle" color="primary">
|
||||
{{ typeLabel(mandates.current.mandate_type) }}
|
||||
</UBadge>
|
||||
<CommonStatusBadge :status="mandates.current.status" type="mandate" />
|
||||
<StatusBadge :status="mandates.current.status" type="mandate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -323,7 +323,7 @@ async function handleDelete() {
|
||||
Etapes du mandat
|
||||
</h2>
|
||||
|
||||
<MandatesMandateTimeline
|
||||
<MandateTimeline
|
||||
:steps="mandates.current.steps"
|
||||
:current-status="mandates.current.status"
|
||||
/>
|
||||
|
||||
@@ -1,46 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Mandats — page index.
|
||||
*
|
||||
* Utilise SectionLayout avec status filters, recherche,
|
||||
* et sidebar "Boite a outils" affichant les protocoles de vote.
|
||||
* Etat vide enrichi avec onboarding expliquant le concept de mandat.
|
||||
*/
|
||||
import type { MandateCreate } from '~/stores/mandates'
|
||||
|
||||
const mandates = useMandatesStore()
|
||||
const protocols = useProtocolsStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const filterType = ref<string | undefined>(undefined)
|
||||
const filterStatus = ref<string | undefined>(undefined)
|
||||
const activeStatus = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const sortBy = ref<'date' | 'title' | 'status'>('date')
|
||||
|
||||
const typeOptions = [
|
||||
{ label: 'Tous les types', value: undefined },
|
||||
{ label: 'Comite technique', value: 'techcomm' },
|
||||
{ label: 'Forgeron', value: 'smith' },
|
||||
{ label: 'Personnalise', value: 'custom' },
|
||||
const sortOptions = [
|
||||
{ label: 'Date', value: 'date' },
|
||||
{ label: 'Titre', value: 'title' },
|
||||
{ label: 'Statut', value: 'status' },
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Tous les statuts', value: undefined },
|
||||
{ label: 'Brouillon', value: 'draft' },
|
||||
{ label: 'Candidature', value: 'candidacy' },
|
||||
{ label: 'En vote', value: 'voting' },
|
||||
{ label: 'Actif', value: 'active' },
|
||||
{ label: 'Rapport', value: 'reporting' },
|
||||
{ label: 'Termine', value: 'completed' },
|
||||
{ label: 'Revoque', value: 'revoked' },
|
||||
]
|
||||
|
||||
async function loadMandates() {
|
||||
await mandates.fetchAll({
|
||||
mandate_type: filterType.value,
|
||||
status: filterStatus.value,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMandates()
|
||||
})
|
||||
|
||||
watch([filterType, filterStatus], () => {
|
||||
loadMandates()
|
||||
})
|
||||
|
||||
// --- Create mandate modal ---
|
||||
|
||||
// Create mandate modal state
|
||||
const showCreateModal = ref(false)
|
||||
const mandateTypeOptions = [
|
||||
{ label: 'Comite technique', value: 'techcomm' },
|
||||
@@ -55,6 +37,80 @@ const newMandate = ref<MandateCreate>({
|
||||
})
|
||||
const creating = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
mandates.fetchAll(),
|
||||
protocols.fetchProtocols(),
|
||||
])
|
||||
})
|
||||
|
||||
/** Status filter pills with counts. */
|
||||
const statuses = computed(() => [
|
||||
{ id: 'draft', label: 'En prepa', count: mandates.list.filter(m => m.status === 'draft' || m.status === 'candidacy').length },
|
||||
{ id: 'voting', label: 'En vote', count: mandates.list.filter(m => m.status === 'voting').length },
|
||||
{ id: 'active', label: 'En vigueur', count: mandates.list.filter(m => m.status === 'active' || m.status === 'reporting').length },
|
||||
{ id: 'closed', label: 'Clos', count: mandates.list.filter(m => m.status === 'completed' || m.status === 'revoked').length },
|
||||
])
|
||||
|
||||
/** Map for status group filtering. */
|
||||
const statusGroupMap: Record<string, string[]> = {
|
||||
draft: ['draft', 'candidacy'],
|
||||
voting: ['voting'],
|
||||
active: ['active', 'reporting'],
|
||||
closed: ['completed', 'revoked'],
|
||||
}
|
||||
|
||||
/** Filtered and sorted mandates. */
|
||||
const filteredMandates = computed(() => {
|
||||
let list = [...mandates.list]
|
||||
|
||||
// Filter by status group
|
||||
if (activeStatus.value && statusGroupMap[activeStatus.value]) {
|
||||
const allowedStatuses = statusGroupMap[activeStatus.value]
|
||||
list = list.filter(m => allowedStatuses.includes(m.status))
|
||||
}
|
||||
|
||||
// Filter by search query (client-side)
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
list = list.filter(m => m.title.toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
// Sort
|
||||
switch (sortBy.value) {
|
||||
case 'title':
|
||||
list.sort((a, b) => a.title.localeCompare(b.title, 'fr'))
|
||||
break
|
||||
case 'status':
|
||||
list.sort((a, b) => a.status.localeCompare(b.status))
|
||||
break
|
||||
case 'date':
|
||||
default:
|
||||
list.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
break
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
const typeLabel = (mandateType: string) => {
|
||||
switch (mandateType) {
|
||||
case 'techcomm': return 'Comite technique'
|
||||
case 'smith': return 'Forgeron'
|
||||
case 'custom': return 'Personnalise'
|
||||
default: return mandateType
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
creating.value = true
|
||||
try {
|
||||
@@ -64,146 +120,372 @@ async function handleCreate() {
|
||||
if (mandate) {
|
||||
navigateTo(`/mandates/${mandate.id}`)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
// Error handled by store
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Mandats
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Mandats de gouvernance : comite technique, forgerons et roles specifiques
|
||||
</p>
|
||||
</div>
|
||||
<SectionLayout
|
||||
title="Mandats"
|
||||
subtitle="Un contexte, un objectif, une duree, une ou plusieurs nominations ; par defaut : nomination d'un binome."
|
||||
:statuses="statuses"
|
||||
:active-status="activeStatus"
|
||||
@update:active-status="activeStatus = $event"
|
||||
>
|
||||
<!-- Search / sort bar -->
|
||||
<template #search>
|
||||
<UInput
|
||||
v-model="searchQuery"
|
||||
placeholder="Rechercher un mandat..."
|
||||
icon="i-lucide-search"
|
||||
size="sm"
|
||||
class="w-full sm:w-64"
|
||||
/>
|
||||
<USelect
|
||||
v-model="sortBy"
|
||||
:items="sortOptions"
|
||||
size="sm"
|
||||
class="w-36"
|
||||
/>
|
||||
<UButton
|
||||
v-if="auth.isAuthenticated"
|
||||
label="Nouveau"
|
||||
icon="i-lucide-plus"
|
||||
label="Nouveau mandat"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="showCreateModal = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<USelect
|
||||
v-model="filterType"
|
||||
:items="typeOptions"
|
||||
placeholder="Type de mandat"
|
||||
class="w-56"
|
||||
/>
|
||||
<USelect
|
||||
v-model="filterStatus"
|
||||
:items="statusOptions"
|
||||
placeholder="Statut"
|
||||
class="w-48"
|
||||
/>
|
||||
</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" />
|
||||
<!-- Main content: mandates list -->
|
||||
<template #default>
|
||||
<!-- Error state -->
|
||||
<div v-if="mandates.error" class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
|
||||
<p style="color: var(--mood-text);">{{ mandates.error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="mandates.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ mandates.error }}</p>
|
||||
<!-- Loading state -->
|
||||
<div v-else-if="mandates.loading" class="space-y-3">
|
||||
<LoadingSkeleton v-for="i in 4" :key="i" :lines="2" card />
|
||||
</div>
|
||||
|
||||
<!-- Onboarding empty state -->
|
||||
<div
|
||||
v-else-if="mandates.list.length === 0 && !activeStatus && !searchQuery"
|
||||
class="mandate-onboarding"
|
||||
>
|
||||
<div class="mandate-onboarding__icon">
|
||||
<UIcon name="i-lucide-user-check" class="text-3xl" />
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template v-else-if="mandates.list.length === 0">
|
||||
<UCard>
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-user-check" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun mandat pour le moment</p>
|
||||
<h3 class="mandate-onboarding__title">
|
||||
Qu'est-ce qu'un mandat ?
|
||||
</h3>
|
||||
<p class="mandate-onboarding__text">
|
||||
Un mandat definit un contexte, un objectif et une duree pour une mission de gouvernance.
|
||||
Il peut porter sur le comite technique, les forgerons, ou tout role specifique de la communaute.
|
||||
</p>
|
||||
<p class="mandate-onboarding__text">
|
||||
Par defaut, un mandat nomme un binome pour assurer la continuite.
|
||||
Le processus comprend : candidature, vote communautaire, periode active et rapport final.
|
||||
</p>
|
||||
<div class="mandate-onboarding__actions">
|
||||
<UButton
|
||||
v-if="auth.isAuthenticated"
|
||||
label="Creer un premier mandat"
|
||||
icon="i-lucide-plus"
|
||||
color="primary"
|
||||
size="sm"
|
||||
@click="showCreateModal = true"
|
||||
/>
|
||||
<UButton
|
||||
to="/protocols"
|
||||
label="Decouvrir les protocoles"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-wrench"
|
||||
/>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Mandates list -->
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<MandatesMandateCard
|
||||
v-for="mandate in mandates.list"
|
||||
<!-- Filtered empty state -->
|
||||
<div
|
||||
v-else-if="filteredMandates.length === 0"
|
||||
class="text-center py-12"
|
||||
style="color: var(--mood-text-muted);"
|
||||
>
|
||||
<UIcon name="i-lucide-user-check" class="text-4xl mb-3 block mx-auto" />
|
||||
<p>Aucun mandat trouve</p>
|
||||
<p v-if="searchQuery || activeStatus" class="text-sm mt-1">
|
||||
Essayez de modifier vos filtres
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mandate cards -->
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="mandate in filteredMandates"
|
||||
:key="mandate.id"
|
||||
: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>
|
||||
</template>
|
||||
|
||||
<!-- Create mandate modal -->
|
||||
<UModal v-model:open="showCreateModal">
|
||||
<template #content>
|
||||
<form class="p-6 space-y-4" @submit.prevent="handleCreate">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Nouveau mandat
|
||||
</h3>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Titre <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<UInput
|
||||
v-model="newMandate.title"
|
||||
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>
|
||||
<!-- Toolbox sidebar -->
|
||||
<template #toolbox>
|
||||
<div class="toolbox-section-title">
|
||||
Modalites de vote
|
||||
</div>
|
||||
<template v-if="protocols.protocols.length > 0">
|
||||
<ToolboxVignette
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:title="protocol.name"
|
||||
:description="protocol.description || undefined"
|
||||
context-label="Mandats"
|
||||
:actions="[
|
||||
{ label: 'Voir', icon: 'i-lucide-eye', to: `/protocols/${protocol.id}` },
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
<p v-else class="toolbox-empty-text">
|
||||
Aucun protocole configure
|
||||
</p>
|
||||
</template>
|
||||
</SectionLayout>
|
||||
|
||||
<!-- Create mandate modal -->
|
||||
<UModal v-model:open="showCreateModal">
|
||||
<template #content>
|
||||
<form class="p-6 space-y-4" @submit.prevent="handleCreate">
|
||||
<h3 class="text-lg font-semibold" 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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Protocols index page.
|
||||
* Boite a outils — page principale des protocoles de vote.
|
||||
*
|
||||
* Lists all voting protocols with ModeParamsDisplay component,
|
||||
* links to protocol detail pages, and provides creation modal
|
||||
* and simulator link.
|
||||
* Liste les protocoles avec leurs details complets,
|
||||
* les configurations de formule, et le simulateur / explainer.
|
||||
*/
|
||||
const protocols = useProtocolsStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const formulaOpen = ref(false)
|
||||
|
||||
/** Creation form state. */
|
||||
const newProtocol = reactive({
|
||||
@@ -84,25 +84,27 @@ async function createProtocol() {
|
||||
})
|
||||
showCreateModal.value = false
|
||||
await protocols.fetchProtocols()
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-8">
|
||||
<div class="toolbox-page">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="toolbox-page__header">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Protocoles de vote
|
||||
<h1 class="toolbox-page__title">
|
||||
<UIcon name="i-lucide-wrench" class="toolbox-page__title-icon" />
|
||||
Boite a outils
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Configuration des protocoles de vote et formules de seuil WoT
|
||||
<p class="toolbox-page__subtitle">
|
||||
Modalites de vote et formules configurables
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="toolbox-page__actions">
|
||||
<NuxtLink to="/protocols/formulas">
|
||||
<UButton variant="outline" icon="i-lucide-calculator" size="sm">
|
||||
Simulateur de formules
|
||||
@@ -122,229 +124,216 @@ async function createProtocol() {
|
||||
<!-- Loading state -->
|
||||
<template v-if="protocols.loading">
|
||||
<div class="space-y-3">
|
||||
<USkeleton v-for="i in 4" :key="i" class="h-32 w-full" />
|
||||
<LoadingSkeleton v-for="i in 4" :key="i" :lines="4" card />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template v-else-if="protocols.error">
|
||||
<UCard>
|
||||
<div class="flex items-center gap-3 text-red-500">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" />
|
||||
<p>{{ protocols.error }}</p>
|
||||
</div>
|
||||
</UCard>
|
||||
<div class="flex items-center gap-3 p-4 rounded-lg" style="background: var(--mood-surface); border: 1px solid var(--mood-border);">
|
||||
<UIcon name="i-lucide-alert-circle" class="text-xl" style="color: var(--mood-error);" />
|
||||
<p style="color: var(--mood-text);">{{ protocols.error }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Voting Protocols -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Protocoles ({{ protocols.protocols.length }})
|
||||
<!-- Protocols section -->
|
||||
<div class="toolbox-section">
|
||||
<h2 class="toolbox-section__title">
|
||||
Protocoles de vote
|
||||
<UBadge variant="subtle" size="xs">
|
||||
{{ protocols.protocols.length }}
|
||||
</UBadge>
|
||||
</h2>
|
||||
|
||||
<div v-if="protocols.protocols.length === 0">
|
||||
<UCard>
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-settings" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucun protocole de vote configure</p>
|
||||
</div>
|
||||
</UCard>
|
||||
<div v-if="protocols.protocols.length === 0" class="toolbox-empty">
|
||||
<UIcon name="i-lucide-settings" class="text-3xl" />
|
||||
<p>Aucun protocole de vote configure</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div v-else class="toolbox-protocol-grid">
|
||||
<NuxtLink
|
||||
v-for="protocol in protocols.protocols"
|
||||
:key="protocol.id"
|
||||
:to="`/protocols/${protocol.id}`"
|
||||
class="block"
|
||||
class="protocol-card"
|
||||
>
|
||||
<UCard class="hover:ring-2 hover:ring-primary-300 dark:hover:ring-primary-700 transition-all cursor-pointer">
|
||||
<div class="space-y-4">
|
||||
<!-- Protocol header -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ protocol.name }}
|
||||
</h3>
|
||||
<p v-if="protocol.description" class="text-sm text-gray-500 mt-0.5">
|
||||
{{ protocol.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle" size="xs">
|
||||
{{ voteTypeLabel(protocol.vote_type) }}
|
||||
</UBadge>
|
||||
<UBadge v-if="protocol.is_meta_governed" color="warning" variant="subtle" size="xs">
|
||||
Meta-gouverne
|
||||
</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Protocol header -->
|
||||
<div class="protocol-card__header">
|
||||
<h3 class="protocol-card__name">
|
||||
{{ protocol.name }}
|
||||
</h3>
|
||||
<div class="protocol-card__badges">
|
||||
<UBadge :color="(voteTypeColor(protocol.vote_type) as any)" variant="subtle" size="xs">
|
||||
{{ 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 -->
|
||||
<div v-if="protocol.mode_params">
|
||||
<ModeParamsDisplay :mode-params="protocol.mode_params" />
|
||||
</div>
|
||||
<p v-if="protocol.description" class="protocol-card__description">
|
||||
{{ protocol.description }}
|
||||
</p>
|
||||
|
||||
<!-- Formula config summary -->
|
||||
<div class="border-t border-gray-100 dark:border-gray-800 pt-3">
|
||||
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||
Formule : {{ protocol.formula_config.name }}
|
||||
</h4>
|
||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span class="text-gray-400 block">Duree</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.duration_days }}j
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400 block">Majorite</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.majority_pct }}%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400 block">Base</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.base_exponent }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400 block">Gradient</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.gradient_exponent }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="protocol.formula_config.smith_exponent !== null">
|
||||
<span class="text-gray-400 block">Smith</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white">
|
||||
{{ protocol.formula_config.smith_exponent }}
|
||||
</span>
|
||||
</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>
|
||||
<!-- Mode params -->
|
||||
<div v-if="protocol.mode_params" class="protocol-card__mode-params">
|
||||
<ModeParamsDisplay :mode-params="protocol.mode_params" />
|
||||
</div>
|
||||
|
||||
<!-- Formula config summary -->
|
||||
<div class="protocol-card__formula">
|
||||
<h4 class="protocol-card__formula-title">
|
||||
Formule : {{ protocol.formula_config.name }}
|
||||
</h4>
|
||||
<div class="protocol-card__formula-grid">
|
||||
<div>
|
||||
<span class="protocol-card__formula-label">Duree</span>
|
||||
<span class="protocol-card__formula-value">{{ protocol.formula_config.duration_days }}j</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="protocol-card__formula-label">Majorite</span>
|
||||
<span class="protocol-card__formula-value">{{ protocol.formula_config.majority_pct }}%</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="protocol-card__formula-label">Base</span>
|
||||
<span class="protocol-card__formula-value">{{ protocol.formula_config.base_exponent }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="protocol-card__formula-label">Gradient</span>
|
||||
<span class="protocol-card__formula-value">{{ protocol.formula_config.gradient_exponent }}</span>
|
||||
</div>
|
||||
<div v-if="protocol.formula_config.smith_exponent !== null">
|
||||
<span class="protocol-card__formula-label">Smith</span>
|
||||
<span class="protocol-card__formula-value">{{ protocol.formula_config.smith_exponent }}</span>
|
||||
</div>
|
||||
<div v-if="protocol.formula_config.techcomm_exponent !== null">
|
||||
<span class="protocol-card__formula-label">TechComm</span>
|
||||
<span class="protocol-card__formula-value">{{ protocol.formula_config.techcomm_exponent }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula Configurations -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Configurations de formule ({{ protocols.formulas.length }})
|
||||
<!-- Formula configurations section -->
|
||||
<div class="toolbox-section">
|
||||
<h2 class="toolbox-section__title">
|
||||
Configurations de formule
|
||||
<UBadge variant="subtle" size="xs">
|
||||
{{ protocols.formulas.length }}
|
||||
</UBadge>
|
||||
</h2>
|
||||
|
||||
<div v-if="protocols.formulas.length === 0">
|
||||
<UCard>
|
||||
<div class="text-center py-8">
|
||||
<UIcon name="i-lucide-calculator" class="text-4xl text-gray-400 mb-3" />
|
||||
<p class="text-gray-500">Aucune configuration de formule</p>
|
||||
</div>
|
||||
</UCard>
|
||||
<div v-if="protocols.formulas.length === 0" class="toolbox-empty">
|
||||
<UIcon name="i-lucide-calculator" class="text-3xl" />
|
||||
<p>Aucune configuration de formule</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<UCard>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Nom</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Duree</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Majorite</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">B</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">G</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">C</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Smith</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">TechComm</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-500">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="formula in protocols.formulas"
|
||||
:key="formula.id"
|
||||
class="border-b border-gray-100 dark:border-gray-800"
|
||||
>
|
||||
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
|
||||
{{ formula.name }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600">{{ formula.duration_days }}j</td>
|
||||
<td class="px-4 py-3 text-gray-600">{{ formula.majority_pct }}%</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.base_exponent }}</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.gradient_exponent }}</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">{{ formula.constant_base }}</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">
|
||||
{{ formula.smith_exponent ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-gray-600">
|
||||
{{ formula.techcomm_exponent ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-500 text-xs">
|
||||
{{ formatDate(formula.created_at) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</UCard>
|
||||
<div v-else class="formula-table-wrapper">
|
||||
<table class="formula-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nom</th>
|
||||
<th>Duree</th>
|
||||
<th>Majorite</th>
|
||||
<th>B</th>
|
||||
<th>G</th>
|
||||
<th>C</th>
|
||||
<th>Smith</th>
|
||||
<th>TechComm</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="formula in protocols.formulas" :key="formula.id">
|
||||
<td class="formula-table__name">{{ formula.name }}</td>
|
||||
<td>{{ formula.duration_days }}j</td>
|
||||
<td>{{ formula.majority_pct }}%</td>
|
||||
<td class="font-mono">{{ formula.base_exponent }}</td>
|
||||
<td class="font-mono">{{ formula.gradient_exponent }}</td>
|
||||
<td class="font-mono">{{ formula.constant_base }}</td>
|
||||
<td class="font-mono">{{ formula.smith_exponent ?? '-' }}</td>
|
||||
<td class="font-mono">{{ formula.techcomm_exponent ?? '-' }}</td>
|
||||
<td class="formula-table__date">{{ formatDate(formula.created_at) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formula explainer -->
|
||||
<UCard>
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Reference : Formule de seuil WoT
|
||||
</h3>
|
||||
<code class="block p-3 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono">
|
||||
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
|
||||
</code>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 text-xs text-gray-500">
|
||||
<div><strong>C</strong> = constante de base (plancher fixe)</div>
|
||||
<div><strong>B</strong> = exposant de base (B^W tend vers 0 si B < 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>
|
||||
<!-- Formula explainer — collapsible -->
|
||||
<UCollapsible v-model:open="formulaOpen">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:icon="formulaOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
|
||||
class="formula-explainer-trigger"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<UIcon name="i-lucide-book-open" />
|
||||
<span>Reference : Formule de seuil WoT</span>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</UButton>
|
||||
<template #content>
|
||||
<div class="formula-explainer-content">
|
||||
<code class="formula-explainer-code">
|
||||
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
|
||||
</code>
|
||||
<div class="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 < 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>
|
||||
|
||||
<!-- Create protocol modal -->
|
||||
<UModal v-model:open="showCreateModal">
|
||||
<template #content>
|
||||
<div class="p-6 space-y-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<h3 class="text-lg font-semibold" style="color: var(--mood-text);">
|
||||
Nouveau protocole de vote
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
||||
Nom du protocole
|
||||
</label>
|
||||
<UInput v-model="newProtocol.name" placeholder="Ex: Vote standard G1" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
||||
Description (optionnel)
|
||||
</label>
|
||||
<UTextarea v-model="newProtocol.description" placeholder="Description du protocole..." :rows="2" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
||||
Type de vote
|
||||
</label>
|
||||
<USelect
|
||||
@@ -355,7 +344,7 @@ async function createProtocol() {
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label class="text-sm font-medium" style="color: var(--mood-text-muted);">
|
||||
Configuration de formule
|
||||
</label>
|
||||
<USelect
|
||||
@@ -384,3 +373,248 @@ async function createProtocol() {
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbox-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* --- Header --- */
|
||||
.toolbox-page__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toolbox-page__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.toolbox-page__title-icon {
|
||||
color: var(--mood-accent);
|
||||
}
|
||||
|
||||
.toolbox-page__subtitle {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.toolbox-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* --- Section --- */
|
||||
.toolbox-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.toolbox-section__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.toolbox-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--mood-text-muted);
|
||||
background: var(--mood-surface);
|
||||
border: 1px dashed var(--mood-border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
/* --- Protocol cards --- */
|
||||
.toolbox-protocol-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.protocol-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 0.75rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.protocol-card:hover {
|
||||
border-color: var(--mood-accent);
|
||||
box-shadow: 0 4px 12px var(--mood-shadow);
|
||||
}
|
||||
|
||||
.protocol-card__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.protocol-card__name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
.protocol-card__badges {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.protocol-card__description {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.protocol-card__mode-params {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.protocol-card__formula {
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--mood-border);
|
||||
}
|
||||
|
||||
.protocol-card__formula-title {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--mood-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.protocol-card__formula-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.protocol-card__formula-label {
|
||||
display: block;
|
||||
color: var(--mood-text-muted);
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.protocol-card__formula-value {
|
||||
font-weight: 600;
|
||||
color: var(--mood-text);
|
||||
}
|
||||
|
||||
/* --- Formula table --- */
|
||||
.formula-table-wrapper {
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.formula-table {
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.formula-table th {
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
border-bottom: 1px solid var(--mood-border);
|
||||
}
|
||||
|
||||
.formula-table td {
|
||||
padding: 0.625rem 1rem;
|
||||
color: var(--mood-text-muted);
|
||||
border-bottom: 1px solid var(--mood-border);
|
||||
}
|
||||
|
||||
.formula-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.formula-table__name {
|
||||
font-weight: 600;
|
||||
color: var(--mood-text) !important;
|
||||
}
|
||||
|
||||
.formula-table__date {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
/* --- Formula explainer --- */
|
||||
.formula-explainer-trigger {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.formula-explainer-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
background: var(--mood-surface);
|
||||
border: 1px solid var(--mood-border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.formula-explainer-code {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
background: var(--mood-accent-soft);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
color: var(--mood-text);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.formula-explainer-params {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mood-text-muted);
|
||||
}
|
||||
|
||||
.formula-explainer-note {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mood-text-muted);
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -146,7 +146,7 @@ watch(filterType, () => {
|
||||
<template v-else>
|
||||
<div class="space-y-4">
|
||||
<div v-for="entry in entries" :key="entry.id" class="relative">
|
||||
<SanctuarySanctuaryEntry
|
||||
<SanctuaryEntry
|
||||
:entry="entry"
|
||||
@verify="handleVerify"
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineNuxtConfig({
|
||||
devtools: { enabled: true },
|
||||
devServer: { port: 3002 },
|
||||
components: [{ path: '~/components', pathPrefix: false }],
|
||||
css: ['~/assets/css/moods.css'],
|
||||
modules: [
|
||||
'@nuxt/ui',
|
||||
'@pinia/nuxt',
|
||||
|
||||
17579
frontend/package-lock.json
generated
Normal file
17579
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@
|
||||
"dependencies": {
|
||||
"@nuxt/content": "^3.11.2",
|
||||
"@nuxt/ui": "^3.1.0",
|
||||
"@pinia/nuxt": "^0.9.0",
|
||||
"@pinia/nuxt": "^0.11.0",
|
||||
"@unocss/nuxt": "^66.6.0",
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"nuxt": "^4.3.1",
|
||||
|
||||
5
frontend/uno.config.ts
Normal file
5
frontend/uno.config.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'unocss'
|
||||
|
||||
export default defineConfig({
|
||||
// UnoCSS config - Nuxt UI v3 handles most styling
|
||||
})
|
||||
Reference in New Issue
Block a user