Design moderne + seed reel engagements forgeron avec votes

- CSS: couleurs saturees sans pastels, border-radius 4-6px, inputs mood-aware
- Header: allege (3.25rem), logo typographique, bouton connexion fin
- Login: redesign complet avec steps dots et input natif style
- Dashboard: entry cards epurees, tags toolbox compacts
- Seed: 34 vraies clauses forgeron v2.0.0 (forum topic 33165)
- Seed: 9 clauses certification (licence G1)
- Seed: 11 votants simules + 3 sessions de vote (10 pour / 1 contre)
- MoodSwitcher: dots colores au lieu d'icones

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 18:28:14 +01:00
parent 77dceb49c3
commit 61a414d214
10 changed files with 1774 additions and 1015 deletions

View File

@@ -1,7 +1,9 @@
"""Seed the database with initial FormulaConfigs, VotingProtocols, Documents, and Decisions. """Seed the database with real data from Duniter community documents.
Usage: Sources:
python seed.py - Engagement Forgeron v2.0.0: https://forum.monnaie-libre.fr/t/33165
- Engagement Certification (Licence G1): monnaie-libre.fr/licence-g1/
- Runtime Upgrade process template
Idempotent: checks if data already exists before inserting. Idempotent: checks if data already exists before inserting.
""" """
@@ -9,7 +11,9 @@ Idempotent: checks if data already exists before inserting.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import uuid import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -18,6 +22,8 @@ from app.database import async_session, engine, Base
from app.models.protocol import FormulaConfig, VotingProtocol from app.models.protocol import FormulaConfig, VotingProtocol
from app.models.document import Document, DocumentItem from app.models.document import Document, DocumentItem
from app.models.decision import Decision, DecisionStep from app.models.decision import Decision, DecisionStep
from app.models.user import DuniterIdentity
from app.models.vote import VoteSession, Vote
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -43,12 +49,16 @@ async def get_or_create(
return instance, True return instance, True
def fake_signature(payload: str) -> str:
"""Generate a deterministic fake Ed25519-like signature for seed data."""
return hashlib.sha256(payload.encode()).hexdigest()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Seed: FormulaConfigs # Seed: FormulaConfigs
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig]: async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig]:
"""Create the 4 base formula configurations."""
configs: dict[str, dict] = { configs: dict[str, dict] = {
"Standard Licence G1": { "Standard Licence G1": {
"description": "Formule standard pour la Licence G1 : vote binaire WoT.", "description": "Formule standard pour la Licence G1 : vote binaire WoT.",
@@ -101,14 +111,13 @@ async def seed_formula_configs(session: AsyncSession) -> dict[str, FormulaConfig
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Seed: VotingProtocols (premier pack de modalites) # Seed: VotingProtocols
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def seed_voting_protocols( async def seed_voting_protocols(
session: AsyncSession, session: AsyncSession,
formulas: dict[str, FormulaConfig], formulas: dict[str, FormulaConfig],
) -> dict[str, VotingProtocol]: ) -> dict[str, VotingProtocol]:
"""Create the first pack of voting modalities (3 protocols)."""
protocols: dict[str, dict] = { protocols: dict[str, dict] = {
"Vote majoritaire": { "Vote majoritaire": {
"description": ( "description": (
@@ -155,102 +164,112 @@ async def seed_voting_protocols(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Seed: Document - Acte d'engagement certification # Seed: Engagement Certification (Licence G1 - obligations certificateur)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [ ENGAGEMENT_CERTIFICATION_ITEMS: list[dict] = [
{ {
"position": "1", "position": "C1",
"item_type": "preamble", "item_type": "clause",
"title": "Objet", "title": "Transmission de la licence",
"sort_order": 1, "sort_order": 1,
"current_text": ( "current_text": (
"Le present acte definit les engagements de tout membre de la " "Toute operation de certification d'un nouveau membre de la monnaie libre G1 "
"toile de confiance qui certifie l'identite d'une autre personne " "doit prealablement s'accompagner de la transmission de cette licence "
"dans le reseau Duniter." "de la monnaie libre G1 au certifie."
), ),
}, },
{ {
"position": "2", "position": "C2",
"item_type": "clause", "item_type": "clause",
"title": "Connaissance personnelle", "title": "Connaissance suffisante du certifie",
"sort_order": 2, "sort_order": 2,
"current_text": ( "current_text": (
"Je certifie connaitre personnellement la personne que je " "Certifier n'est pas uniquement s'assurer que vous avez rencontre la personne, "
"certifie, l'avoir rencontree physiquement a plusieurs reprises, " "c'est assurer a la communaute G1 que vous connaissez suffisamment bien la "
"et pouvoir la contacter par au moins deux moyens de communication " "personne que vous vous appretez a certifier."
"differents."
), ),
}, },
{ {
"position": "3", "position": "C3",
"item_type": "clause", "item_type": "clause",
"title": "Verification d'identite", "title": "Contact par plusieurs moyens",
"sort_order": 3, "sort_order": 3,
"current_text": ( "current_text": (
"Je certifie avoir verifie que la personne n'a qu'un seul compte " "Connaitre la personne par plusieurs moyens de contact differents "
"membre dans la toile de confiance, et que l'identite declaree " "(physique, electronique, etc.) permettant de verifier son identite "
"correspond a une personne humaine vivante." "et de maintenir un lien de confiance dans la duree."
), ),
}, },
{ {
"position": "4", "position": "C4",
"item_type": "clause", "item_type": "clause",
"title": "Engagement de suivi", "title": "Ne jamais certifier seul",
"sort_order": 4, "sort_order": 4,
"current_text": ( "current_text": (
"Je m'engage a surveiller l'activite de mes certifies et a " "Ne certifiez jamais seul, mais accompagne d'au moins un autre membre "
"signaler tout comportement suspect (comptes multiples, " "de la TdC G1, pour garantir un double controle et eviter les "
"usurpation d'identite, comptes abandonnes)." "certifications abusives."
), ),
}, },
{ {
"position": "5", "position": "C5",
"item_type": "verification", "item_type": "verification",
"title": "Delai entre certifications", "title": "Verification des certifications existantes",
"sort_order": 5, "sort_order": 5,
"current_text": ( "current_text": (
"Je respecte un delai minimum de reflexion de 5 jours entre " "Avant toute certification, assurez-vous de verifier si le compte du "
"chaque nouvelle certification emise." "certifie a deja recu une ou plusieurs certifications, et de qui "
"elles proviennent."
), ),
}, },
{ {
"position": "6", "position": "C6",
"item_type": "rule", "item_type": "verification",
"title": "Renouvellement", "title": "Verification de maitrise du compte",
"sort_order": 6, "sort_order": 6,
"current_text": ( "current_text": (
"Je renouvelle mes certifications avant leur expiration pour " "Verifier la maitrise du compte par un transfert test : envoyer "
"maintenir la cohesion de la toile de confiance." "quelques G1 et demander un renvoi, afin de s'assurer que la personne "
"controle bien sa cle privee."
), ),
}, },
{ {
"position": "7", "position": "C7",
"item_type": "clause", "item_type": "verification",
"title": "Responsabilite", "title": "Verification de la licence",
"sort_order": 7, "sort_order": 7,
"current_text": ( "current_text": (
"Je suis conscient que la certification engage ma responsabilite " "Verifiez que vos contacts ont bien etudie et compris la licence G1 "
"vis-a-vis de la communaute. Une certification abusive peut " "a jour avant de proceder a la certification."
"entrainer la perte de confiance des autres membres."
), ),
}, },
{ {
"position": "8", "position": "C8",
"item_type": "rule", "item_type": "verification",
"title": "Revocation", "title": "Document de revocation",
"sort_order": 8, "sort_order": 8,
"current_text": ( "current_text": (
"Une certification peut etre revoquee si les conditions de " "D'avoir bien verifie avec la personne concernee qu'elle a bien genere "
"l'engagement ne sont plus remplies. La revocation est soumise " "son document Duniter de revocation de compte, et qu'elle le conserve "
"au protocole de vote en vigueur." "en lieu sur."
),
},
{
"position": "C9",
"item_type": "clause",
"title": "Rencontre physique ou multi-canal",
"sort_order": 9,
"current_text": (
"De rencontrer la personne physiquement, OU de verifier a distance "
"le lien personne / cle publique par plusieurs moyens de communication "
"differents et independants."
), ),
}, },
] ]
async def seed_document_engagement_certification(session: AsyncSession) -> Document: 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( doc, created = await get_or_create(
session, session,
Document, Document,
@@ -261,9 +280,8 @@ async def seed_document_engagement_certification(session: AsyncSession) -> Docum
version="1.0.0", version="1.0.0",
status="active", status="active",
description=( description=(
"Acte d'engagement pour les certificateurs de la toile de confiance " "Obligations des certificateurs de la toile de confiance Duniter. "
"Duniter. Definit les obligations et responsabilites liees a la " "Chaque clause est soumise au vote permanent de la communaute."
"certification de nouveaux membres."
), ),
) )
print(f" Document 'Acte d'engagement certification': {'created' if created else 'exists'}") print(f" Document 'Acte d'engagement certification': {'created' if created else 'exists'}")
@@ -279,113 +297,360 @@ async def seed_document_engagement_certification(session: AsyncSession) -> Docum
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Seed: Document - Acte d'engagement forgeron v2.0.0 # Seed: Engagement Forgeron v2.0.0 (real content from forum topic 33165)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ENGAGEMENT_FORGERON_ITEMS: list[dict] = [ ENGAGEMENT_FORGERON_ITEMS: list[dict] = [
# --- Aspirant Forgeron : Securite et conformite ---
{ {
"position": "1", "position": "A1",
"item_type": "preamble", "item_type": "clause",
"title": "Intention", "title": "Intention et motivation",
"sort_order": 1, "sort_order": 1,
"current_text": ( "current_text": (
"Avec la V2, une sous-toile de confiance pour les forgerons est " "J'ai clarifie ce qui me motive a devenir forgeron, "
"introduite. Les forgerons (validateurs de blocs) doivent demontrer " "j'en assume les raisons."
"leurs competences techniques et leur engagement envers le reseau."
), ),
}, },
{ {
"position": "2", "position": "A2",
"item_type": "clause", "item_type": "clause",
"title": "Savoirs-faire", "title": "Veille securite",
"sort_order": 2, "sort_order": 2,
"current_text": ( "current_text": (
"Administration systeme Linux, securite informatique, " "Je fais de la veille pour maintenir mes pratiques de securite "
"cryptographie, blockchain Substrate. Le forgeron doit maitriser " "systeme et reseau a jour."
"l'ensemble de la chaine technique necessaire a la validation."
), ),
}, },
{ {
"position": "3", "position": "A3",
"item_type": "clause", "item_type": "clause",
"title": "Rigueur", "title": "Notifications forum",
"sort_order": 3, "sort_order": 3,
"current_text": ( "current_text": (
"Comprendre en profondeur les configurations du runtime, " "J'ai active les notifications sur forum.duniter.org pour etre "
"les parametres de consensus et les mecanismes de mise a jour " "alerte des discussions importantes concernant le reseau."
"du reseau Duniter V2."
), ),
}, },
{ {
"position": "4", "position": "A4",
"item_type": "clause", "item_type": "verification",
"title": "Reactivite", "title": "Phrase de recuperation aleatoire",
"sort_order": 4, "sort_order": 4,
"current_text": ( "current_text": (
"Reponse sous 24h aux alertes reseau. Disponibilite pour les " "Je confirme que ma phrase de recuperation a ete generee "
"mises a jour critiques. Monitoring continu du noeud validateur." "aleatoirement et n'est pas une phrase choisie par moi."
), ),
}, },
{ {
"position": "5", "position": "A5",
"item_type": "verification", "item_type": "verification",
"title": "Securite aspirant", "title": "Compte separe",
"sort_order": 5, "sort_order": 5,
"current_text": ( "current_text": (
"Phrases aleatoires de 12+ mots, comptes separes pour identite " "J'utilise un autre compte pour mes transactions courantes ; "
"et validation, sauvegardes chiffrees des cles, infrastructure " "le compte forgeron est strictement reserve a la validation."
"securisee et a jour."
), ),
}, },
{ {
"position": "6", "position": "A6",
"item_type": "verification", "item_type": "verification",
"title": "Contact aspirant", "title": "Sauvegarde phrase de recuperation",
"sort_order": 6, "sort_order": 6,
"current_text": ( "current_text": (
"Le candidat forgeron doit contacter au minimum 3 forgerons " "J'ai stocke ma phrase de recuperation sur plusieurs supports "
"existants par au moins 2 canaux de communication differents " "physiques distincts et securises."
"avant de demander ses certifications."
), ),
}, },
{ {
"position": "7", "position": "A7",
"item_type": "clause", "item_type": "verification",
"title": "Clauses pieges", "title": "Noeud a jour et synchronise",
"sort_order": 7, "sort_order": 7,
"current_text": ( "current_text": (
"Exclusions : harcelement, abus de pouvoir, tentative " "Je gere deja un noeud a jour, correctement synchronise et "
"d'infiltration malveillante du reseau. Tout manquement " "joignable par les autres noeuds du reseau."
"entraine le retrait des certifications forgeron."
), ),
}, },
{ {
"position": "8", "position": "A8",
"item_type": "verification", "item_type": "verification",
"title": "Securite certificateur", "title": "API unsafe non exposee",
"sort_order": 8, "sort_order": 8,
"current_text": ( "current_text": (
"Verification de l'intention du candidat, de ses pratiques " "J'ai veille a ne pas exposer publiquement l'api unsafe "
"de securite, et du bon fonctionnement de son noeud validateur " "de mon noeud validateur."
"avant de delivrer une certification forgeron."
), ),
}, },
{ {
"position": "9", "position": "A9",
"item_type": "rule", "item_type": "clause",
"title": "Regles TdC forgerons", "title": "Transparence technique",
"sort_order": 9, "sort_order": 9,
"current_text": ( "current_text": (
"Etre membre de la TdC principale. Recevoir une invitation " "Je fournis a la demande d'un autre forgeron, mes choix "
"d'un forgeron existant. Obtenir au minimum 3 certifications " "techniques (materiel, OS, configuration reseau)."
"de forgerons actifs. Renouvellement annuel obligatoire." ),
},
{
"position": "A10",
"item_type": "clause",
"title": "Declaration offline en cas de doute",
"sort_order": 10,
"current_text": (
"Je me declare offline en cas de doute sur la securite "
"de mon noeud ou de mon infrastructure."
),
},
{
"position": "A11",
"item_type": "clause",
"title": "Reactivite 24h",
"sort_order": 11,
"current_text": (
"Je m'engage a repondre en moins de 24h aux forgerons "
"quand je suis declare online."
),
},
# --- Aspirant Forgeron : Contact ---
{
"position": "A12",
"item_type": "clause",
"title": "Contact multi-canal",
"sort_order": 12,
"current_text": (
"Je sais joindre efficacement et rapidement 3 des forgerons "
"par au moins 2 canaux de communication differents."
),
},
# --- Aspirant Forgeron : Connaissances ---
{
"position": "A13",
"item_type": "clause",
"title": "Acceptation des engagements",
"sort_order": 13,
"current_text": (
"J'ai lu et j'accepte de respecter l'ensemble des "
"engagements forgerons en vigueur."
),
},
{
"position": "A14",
"item_type": "clause",
"title": "Regles de la TdC forgeron",
"sort_order": 14,
"current_text": (
"J'ai pris connaissance des regles et delais associes au "
"fonctionnement de la TdC forgeron."
),
},
{
"position": "A15",
"item_type": "clause",
"title": "Fonctionnement blockchain",
"sort_order": 15,
"current_text": (
"J'ai bien compris le fonctionnement d'un reseau blockchain "
"Duniter et le role du validateur."
),
},
# --- Aspirant Forgeron : Pieges (expected: NON) ---
{
"position": "A16",
"item_type": "rule",
"title": "Piege : harcelement",
"sort_order": 16,
"current_text": (
"[Piege - reponse attendue : NON] "
"J'insiste, harcele ou fais pression pour etre certifie forgeron."
),
},
{
"position": "A17",
"item_type": "rule",
"title": "Piege : gloire et pouvoir",
"sort_order": 17,
"current_text": (
"[Piege - reponse attendue : NON] "
"Je veux etre forgeron pour la gloire et le pouvoir."
),
},
{
"position": "A18",
"item_type": "rule",
"title": "Piege : nuisance",
"sort_order": 18,
"current_text": (
"[Piege - reponse attendue : NON] "
"Je cherche a nuire a l'ecosysteme G1."
),
},
# --- Certificateur Forgeron : Securite et conformite ---
{
"position": "C1",
"item_type": "clause",
"title": "Intention du certifie questionnee",
"sort_order": 19,
"current_text": (
"J'ai questionne l'intention du certifie a rejoindre "
"les forgerons et verifie sa motivation."
),
},
{
"position": "C2",
"item_type": "verification",
"title": "Pratiques de securite du certifie",
"sort_order": 20,
"current_text": (
"J'ai demande au certifie quelles etaient ses pratiques de "
"securite systeme et reseau."
),
},
{
"position": "C3",
"item_type": "verification",
"title": "Phrase aleatoire du certifie",
"sort_order": 21,
"current_text": (
"Le certifie m'assure que son compte forgeron est issu d'une "
"phrase generee aleatoirement."
),
},
{
"position": "C4",
"item_type": "verification",
"title": "Sauvegarde du certifie",
"sort_order": 22,
"current_text": (
"Le certifie m'assure avoir stocke sa phrase de recuperation "
"sur plusieurs supports physiques."
),
},
{
"position": "C5",
"item_type": "verification",
"title": "Noeud du certifie verifie",
"sort_order": 23,
"current_text": (
"J'ai verifie que le certifie gere deja un noeud a jour, "
"correctement synchronise et joignable."
),
},
{
"position": "C6",
"item_type": "clause",
"title": "Configuration du certifie notee",
"sort_order": 24,
"current_text": (
"J'ai note le style de configuration du noeud du certifie "
"(materiel, OS, hebergement)."
),
},
{
"position": "C7",
"item_type": "clause",
"title": "Engagement d'information du certifie",
"sort_order": 25,
"current_text": (
"Le certifie s'est engage a m'informer de tout changement "
"significatif de sa configuration."
),
},
{
"position": "C8",
"item_type": "verification",
"title": "Risques offline connus du certifie",
"sort_order": 26,
"current_text": (
"J'ai verifie avec le certifie qu'il connait les risques "
"d'etre declare offline et les consequences."
),
},
# --- Certificateur Forgeron : Contact ---
{
"position": "C9",
"item_type": "clause",
"title": "Joindre les certifies",
"sort_order": 27,
"current_text": (
"Je sais joindre efficacement les forgerons que j'ai certifies."
),
},
{
"position": "C10",
"item_type": "clause",
"title": "Deux canaux de contact",
"sort_order": 28,
"current_text": (
"Je peux les joindre par au moins 2 canaux differents."
),
},
{
"position": "C11",
"item_type": "clause",
"title": "Contact sous 24h en cas de defaut",
"sort_order": 29,
"current_text": (
"Je m'engage a contacter sous 24h ce forgeron si un defaut "
"concerne son noeud."
),
},
# --- Certificateur Forgeron : Connaissances ---
{
"position": "C12",
"item_type": "verification",
"title": "Engagements acceptes par le certifie",
"sort_order": 30,
"current_text": (
"J'ai verifie que le certifie a accepte les engagements "
"forgerons integralement."
),
},
{
"position": "C13",
"item_type": "verification",
"title": "Regles consultables par le certifie",
"sort_order": 31,
"current_text": (
"J'ai verifie que le certifie sait ou consulter les regles "
"detaillees de la TdC forgeron."
),
},
{
"position": "C14",
"item_type": "verification",
"title": "Delais connus du certifie",
"sort_order": 32,
"current_text": (
"J'ai verifie que le certifie connait les delais de passage "
"en ligne et hors ligne."
),
},
# --- Certificateur Forgeron : Pieges (expected: NON) ---
{
"position": "C15",
"item_type": "rule",
"title": "Piege : certification sous pression",
"sort_order": 33,
"current_text": (
"[Piege - reponse attendue : NON] "
"Je certifie sous la menace ou autre forme de pression."
),
},
{
"position": "C16",
"item_type": "rule",
"title": "Piege : avantage personnel",
"sort_order": 34,
"current_text": (
"[Piege - reponse attendue : NON] "
"Je tire un avantage personnel en echange de ma certification."
), ),
}, },
] ]
async def seed_document_engagement_forgeron(session: AsyncSession) -> Document: 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( doc, created = await get_or_create(
session, session,
Document, Document,
@@ -396,8 +661,9 @@ async def seed_document_engagement_forgeron(session: AsyncSession) -> Document:
version="2.0.0", version="2.0.0",
status="active", status="active",
description=( description=(
"Acte d'engagement des forgerons (validateurs de blocs) pour " "Acte d'engagement des forgerons (validateurs) Duniter V2. "
"Duniter V2. Adopte en fevrier 2026 (97 pour / 23 contre)." "Adopte en fevrier 2026 (97 pour / 23 contre). "
"34 clauses : aspirant (18) + certificateur (16)."
), ),
) )
print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}") print(f" Document 'Acte d'engagement forgeron': {'created' if created else 'exists'}")
@@ -413,7 +679,7 @@ async def seed_document_engagement_forgeron(session: AsyncSession) -> Document:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Seed: Decision template - Runtime Upgrade # Seed: Decision - Runtime Upgrade
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
RUNTIME_UPGRADE_STEPS: list[dict] = [ RUNTIME_UPGRADE_STEPS: list[dict] = [
@@ -466,7 +732,6 @@ RUNTIME_UPGRADE_STEPS: list[dict] = [
async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision: async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
"""Create the Runtime Upgrade decision template."""
decision, created = await get_or_create( decision, created = await get_or_create(
session, session,
Decision, Decision,
@@ -492,33 +757,165 @@ async def seed_decision_runtime_upgrade(session: AsyncSession) -> Decision:
return decision return decision
# ---------------------------------------------------------------------------
# Seed: Simulated voters + votes on first 3 engagement items
# ---------------------------------------------------------------------------
VOTER_NAMES = [
"Moul", "Poka", "Hugo", "Elois", "Cgeek",
"Galuel", "Tortue", "Inso", "Tuxmain", "Matograine",
"Maaltir",
]
async def seed_voters(session: AsyncSession) -> list[DuniterIdentity]:
"""Create 11 simulated Duniter identities for voting demo."""
voters: list[DuniterIdentity] = []
for i, name in enumerate(VOTER_NAMES):
# Deterministic address from name
addr_hash = hashlib.sha256(name.encode()).hexdigest()[:32]
address = f"5{addr_hash[:47]}"
voter, created = await get_or_create(
session,
DuniterIdentity,
"address",
address,
display_name=name,
wot_status="member",
is_smith=(i < 5), # First 5 are smiths
)
if created:
print(f" Voter '{name}': created")
voters.append(voter)
return voters
async def seed_votes_on_items(
session: AsyncSession,
doc: Document,
protocol: VotingProtocol,
voters: list[DuniterIdentity],
):
"""Create vote sessions on first 3 items with 10 for + 1 against."""
# Fetch items for this document
stmt = select(DocumentItem).where(
DocumentItem.document_id == doc.id
).order_by(DocumentItem.sort_order).limit(3)
result = await session.execute(stmt)
items = result.scalars().all()
if not items:
print(" No items found to vote on")
return
now = datetime.now(timezone.utc)
for item in items:
# Check if a session already exists for this item
existing_stmt = select(VoteSession).where(
VoteSession.item_version_id == None,
VoteSession.voting_protocol_id == protocol.id,
)
# We'll use a simpler idempotency check
session_id = uuid.uuid5(uuid.NAMESPACE_URL, f"seed-vote-{item.id}")
check_stmt = select(VoteSession).where(VoteSession.id == session_id)
check_result = await session.execute(check_stmt)
if check_result.scalar_one_or_none() is not None:
print(f" VoteSession for '{item.title}': exists")
continue
vote_session = VoteSession(
id=session_id,
decision_id=None,
item_version_id=None,
voting_protocol_id=protocol.id,
wot_size=7224,
smith_size=23,
techcomm_size=5,
starts_at=now - timedelta(days=15),
ends_at=now + timedelta(days=15),
status="open",
votes_for=10,
votes_against=1,
votes_total=11,
threshold_required=97.0,
)
session.add(vote_session)
await session.flush()
# 10 votes "for"
for voter in voters[:10]:
payload = f"vote:{vote_session.id}:{voter.id}:for"
vote = Vote(
session_id=vote_session.id,
voter_id=voter.id,
vote_value="for",
comment="Oui c'est mieux que l'existant",
signature=fake_signature(payload),
signed_payload=payload,
voter_wot_status="member",
voter_is_smith=voter.is_smith,
)
session.add(vote)
# 1 vote "against"
against_voter = voters[10]
payload = f"vote:{vote_session.id}:{against_voter.id}:against"
vote = Vote(
session_id=vote_session.id,
voter_id=against_voter.id,
vote_value="against",
comment="Non, on ne remplace pas tel quel",
signature=fake_signature(payload),
signed_payload=payload,
voter_wot_status="member",
voter_is_smith=False,
)
session.add(vote)
await session.flush()
print(f" VoteSession for '{item.title}': created (10 pour, 1 contre)")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Main seed runner # Main seed runner
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def run_seed(): async def run_seed():
"""Execute all seed functions inside a single transaction."""
print("=" * 60) print("=" * 60)
print("Glibredecision - Seed Database") print("Glibredecision - Seed Database")
print("=" * 60) print("=" * 60)
async with async_session() as session: async with async_session() as session:
async with session.begin(): async with session.begin():
print("\n[1/5] Formula Configs...") print("\n[1/7] Formula Configs...")
formulas = await seed_formula_configs(session) formulas = await seed_formula_configs(session)
print("\n[2/5] Voting Protocols (premier pack de modalites)...") print("\n[2/7] Voting Protocols...")
await seed_voting_protocols(session, formulas) protocols = await seed_voting_protocols(session, formulas)
print("\n[3/5] Document: Acte d'engagement certification...") print("\n[3/7] Document: Acte d'engagement certification...")
await seed_document_engagement_certification(session) await seed_document_engagement_certification(session)
print("\n[4/5] Document: Acte d'engagement forgeron...") print("\n[4/7] Document: Acte d'engagement forgeron v2.0.0...")
await seed_document_engagement_forgeron(session) doc_forgeron = await seed_document_engagement_forgeron(session)
print("\n[5/5] Decision: Runtime Upgrade...") print("\n[5/7] Decision: Runtime Upgrade...")
await seed_decision_runtime_upgrade(session) await seed_decision_runtime_upgrade(session)
print("\n[6/7] Simulated voters...")
voters = await seed_voters(session)
print("\n[7/7] Votes on first 3 engagements forgeron...")
await seed_votes_on_items(
session,
doc_forgeron,
protocols["Vote majoritaire"],
voters,
)
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("Seed complete.") print("Seed complete.")
print("=" * 60) print("=" * 60)

View File

@@ -5,7 +5,7 @@ const { initMood } = useMood()
const navigationItems = [ const navigationItems = [
{ {
label: 'Documents de reference', label: 'Documents',
icon: 'i-lucide-book-open', icon: 'i-lucide-book-open',
to: '/documents', to: '/documents',
}, },
@@ -44,10 +44,7 @@ const ws = useWebSocket()
const { setupWsNotifications } = useNotifications() const { setupWsNotifications } = useNotifications()
onMounted(async () => { onMounted(async () => {
// Apply saved mood / ambiance
initMood() initMood()
// Hydrate auth from localStorage
auth.hydrateFromStorage() auth.hydrateFromStorage()
if (auth.token) { if (auth.token) {
try { try {
@@ -56,8 +53,6 @@ onMounted(async () => {
auth.logout() auth.logout()
} }
} }
// Connect WebSocket and setup notifications
ws.connect() ws.connect()
setupWsNotifications(ws) setupWsNotifications(ws)
}) })
@@ -65,88 +60,81 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
ws.disconnect() ws.disconnect()
}) })
function isActive(to: string) {
return route.path === to || route.path.startsWith(to + '/')
}
</script> </script>
<template> <template>
<UApp> <UApp>
<!-- Offline detection banner -->
<OfflineBanner /> <OfflineBanner />
<div <div
class="min-h-screen flex flex-col" class="app-shell"
:style="{ :style="{
backgroundColor: 'var(--mood-bg)', backgroundColor: 'var(--mood-bg)',
color: 'var(--mood-text)', color: 'var(--mood-text)',
}" }"
> >
<!-- Header --> <!-- Header -->
<header class="sticky top-0 z-30 border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900"> <header class="app-header">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="app-header__inner">
<div class="flex items-center justify-between h-16"> <!-- Left: Hamburger (mobile) + Logo -->
<!-- Left: Hamburger (mobile) + Logo --> <div class="app-header__left">
<div class="flex items-center gap-3"> <button
<!-- Hamburger menu button for mobile --> class="app-header__menu-btn md:hidden"
<UButton aria-label="Ouvrir le menu"
class="md:hidden" @click="mobileMenuOpen = true"
icon="i-lucide-menu" >
variant="ghost" <UIcon name="i-lucide-menu" class="text-lg" />
color="neutral" </button>
size="sm" <NuxtLink to="/" class="app-header__logo">
aria-label="Ouvrir le menu" <span class="app-header__logo-mark">G</span>
@click="mobileMenuOpen = true" <span class="app-header__logo-text">libredecision</span>
/> </NuxtLink>
</div>
<NuxtLink to="/" class="flex items-center gap-2"> <!-- Center: Mood switcher -->
<UIcon name="i-lucide-vote" class="text-primary text-2xl" /> <MoodSwitcher class="hidden sm:flex" />
<span class="text-xl font-bold text-gray-900 dark:text-white">Glibredecision</span>
<!-- Right: Auth -->
<div class="app-header__right">
<template v-if="auth.isAuthenticated">
<span class="app-header__identity">
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 10) + '...' }}
</span>
<span
v-if="auth.identity?.is_smith"
class="app-header__role app-header__role--smith"
>
Forgeron
</span>
<span
v-if="auth.identity?.is_techcomm"
class="app-header__role app-header__role--tech"
>
CoTec
</span>
<button
class="app-header__icon-btn"
aria-label="Se deconnecter"
@click="auth.logout()"
>
<UIcon name="i-lucide-log-out" />
</button>
</template>
<template v-else>
<NuxtLink to="/login" class="app-header__connect-btn">
<UIcon name="i-lucide-log-in" class="text-sm" />
<span>Connexion</span>
</NuxtLink> </NuxtLink>
</div> </template>
<!-- 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">
<UBadge
:color="auth.identity?.is_smith ? 'success' : 'neutral'"
variant="subtle"
class="hidden sm:inline-flex"
>
{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 12) + '...' }}
</UBadge>
<UBadge
v-if="auth.identity?.is_techcomm"
color="info"
variant="subtle"
class="hidden sm:inline-flex"
>
Comite Tech
</UBadge>
<UButton
icon="i-lucide-log-out"
variant="ghost"
color="neutral"
size="sm"
aria-label="Se deconnecter"
@click="auth.logout()"
/>
</template>
<template v-else>
<UButton
to="/login"
icon="i-lucide-log-in"
label="Se connecter"
variant="soft"
color="primary"
/>
</template>
</div>
</div> </div>
</div> </div>
</header> </header>
<!-- Mobile navigation drawer (USlideover) --> <!-- Mobile navigation drawer -->
<USlideover <USlideover
v-model:open="mobileMenuOpen" v-model:open="mobileMenuOpen"
side="left" side="left"
@@ -154,70 +142,48 @@ onUnmounted(() => {
:ui="{ width: 'max-w-xs' }" :ui="{ width: 'max-w-xs' }"
> >
<template #body> <template #body>
<nav class="py-2"> <nav class="app-mobile-nav">
<NuxtLink <NuxtLink
v-for="item in navigationItems" v-for="item in navigationItems"
:key="item.to" :key="item.to"
:to="item.to" :to="item.to"
class="flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-lg transition-colors" class="app-mobile-nav__link"
:class=" :class="{ 'app-mobile-nav__link--active': isActive(item.to) }"
route.path.startsWith(item.to)
? 'bg-primary-50 dark:bg-primary-900/20 text-primary'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
"
@click="mobileMenuOpen = false" @click="mobileMenuOpen = false"
> >
<UIcon :name="item.icon" class="text-lg" /> <UIcon :name="item.icon" class="text-base" />
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</NuxtLink> </NuxtLink>
</nav> </nav>
<!-- Mobile user info -->
<div <div
v-if="auth.isAuthenticated" v-if="auth.isAuthenticated"
class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700 px-4" class="app-mobile-nav__user"
> >
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400"> <span>{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 16) + '...' }}</span>
<UIcon name="i-lucide-user" class="text-lg" />
<span>{{ auth.identity?.display_name || auth.identity?.address?.slice(0, 16) + '...' }}</span>
</div>
<div class="flex gap-2 mt-2">
<UBadge
v-if="auth.identity?.is_smith"
color="success"
variant="subtle"
size="xs"
>
Forgeron
</UBadge>
<UBadge
v-if="auth.identity?.is_techcomm"
color="info"
variant="subtle"
size="xs"
>
Comite Tech
</UBadge>
</div>
</div> </div>
</template> </template>
</USlideover> </USlideover>
<!-- Main content with sidebar --> <!-- Main content with sidebar -->
<div class="flex flex-1"> <div class="app-body">
<!-- Desktop sidebar navigation --> <!-- Desktop sidebar -->
<aside class="w-64 border-r border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50 hidden md:block flex-shrink-0"> <aside class="app-sidebar">
<nav class="p-4 sticky top-16"> <nav class="app-sidebar__nav">
<UNavigationMenu <NuxtLink
:items="navigationItems" v-for="item in navigationItems"
orientation="vertical" :key="item.to"
class="w-full" :to="item.to"
/> class="app-sidebar__link"
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
>
<UIcon :name="item.icon" class="text-base" />
<span>{{ item.label }}</span>
</NuxtLink>
</nav> </nav>
</aside> </aside>
<!-- Page content with error boundary --> <!-- Page content -->
<main class="flex-1 min-w-0 p-4 sm:p-6 lg:p-8"> <main class="app-main">
<ErrorBoundary> <ErrorBoundary>
<NuxtPage /> <NuxtPage />
</ErrorBoundary> </ErrorBoundary>
@@ -228,52 +194,337 @@ onUnmounted(() => {
<Transition name="slide-up"> <Transition name="slide-up">
<div <div
v-if="ws.error.value" v-if="ws.error.value"
class="fixed bottom-0 left-0 right-0 z-40 bg-error-100 dark:bg-error-900/50 border-t border-error-300 dark:border-error-700 px-4 py-3 text-center" class="app-ws-banner"
role="alert" role="alert"
> >
<div class="flex items-center justify-center gap-2 text-sm text-error-800 dark:text-error-200"> <UIcon name="i-lucide-plug-zap" class="text-base" />
<UIcon name="i-lucide-plug-zap" class="text-lg flex-shrink-0" /> <span>{{ ws.error.value }}</span>
<span>{{ ws.error.value }}</span> <button class="app-ws-banner__btn" @click="ws.disconnect(); ws.connect()">
<UButton Reconnecter
size="xs" </button>
variant="soft"
color="error"
label="Reconnecter"
icon="i-lucide-refresh-cw"
@click="ws.disconnect(); ws.connect()"
/>
</div>
</div> </div>
</Transition> </Transition>
<!-- Footer --> <!-- Footer -->
<footer class="border-t border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900/50"> <footer class="app-footer">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <span>Glibredecision v0.1.0</span>
<div class="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-gray-500"> <span class="app-footer__sep">·</span>
<span>Glibredecision v0.1.0 - Decisions collectives pour Duniter/G1</span> <span>Licence libre</span>
<span>Licence libre</span>
</div>
</div>
</footer> </footer>
</div> </div>
</UApp> </UApp>
</template> </template>
<style scoped> <style scoped>
/* === Shell === */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* === Header === */
.app-header {
position: sticky;
top: 0;
z-index: 30;
background: var(--mood-surface);
border-bottom: 1px solid var(--mood-border);
}
.app-header__inner {
max-width: 80rem;
margin: 0 auto;
padding: 0 1rem;
display: flex;
align-items: center;
justify-content: space-between;
height: 3.25rem;
}
.app-header__left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.app-header__menu-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
background: none;
color: var(--mood-text-muted);
cursor: pointer;
border-radius: 4px;
}
.app-header__menu-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-header__logo {
text-decoration: none;
display: flex;
align-items: baseline;
gap: 0;
}
.app-header__logo-mark {
font-size: 1.25rem;
font-weight: 800;
color: var(--mood-accent);
line-height: 1;
}
.app-header__logo-text {
font-size: 0.9375rem;
font-weight: 600;
color: var(--mood-text);
letter-spacing: -0.01em;
}
.app-header__right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.app-header__identity {
font-size: 0.75rem;
font-weight: 500;
color: var(--mood-text);
max-width: 10rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-header__role {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 6px;
border-radius: 3px;
}
.app-header__role--smith {
background: rgba(24, 132, 59, 0.12);
color: var(--mood-success);
}
.app-header__role--tech {
background: rgba(24, 86, 168, 0.12);
color: var(--mood-status-vote, #1856a8);
}
.app-header__icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: none;
background: none;
color: var(--mood-text-muted);
cursor: pointer;
border-radius: 4px;
font-size: 0.875rem;
}
.app-header__icon-btn:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-header__connect-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-accent-text);
background: var(--mood-accent);
border: none;
border-radius: 4px;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease;
letter-spacing: 0.01em;
}
.app-header__connect-btn:hover {
opacity: 0.88;
}
/* === Sidebar === */
.app-body {
display: flex;
flex: 1;
}
.app-sidebar {
width: 13rem;
flex-shrink: 0;
border-right: 1px solid var(--mood-border);
background: var(--mood-surface);
display: none;
}
@media (min-width: 768px) {
.app-sidebar {
display: block;
}
}
.app-sidebar__nav {
position: sticky;
top: 3.25rem;
padding: 0.75rem 0.5rem;
display: flex;
flex-direction: column;
gap: 2px;
}
.app-sidebar__link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: 4px;
transition: all 0.12s ease;
}
.app-sidebar__link:hover {
color: var(--mood-text);
background: var(--mood-accent-soft);
}
.app-sidebar__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 600;
}
/* === Mobile nav === */
.app-mobile-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0.5rem;
}
.app-mobile-nav__link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--mood-text-muted);
text-decoration: none;
border-radius: 4px;
}
.app-mobile-nav__link:hover {
background: var(--mood-accent-soft);
color: var(--mood-text);
}
.app-mobile-nav__link--active {
color: var(--mood-accent);
background: var(--mood-accent-soft);
font-weight: 600;
}
.app-mobile-nav__user {
margin-top: 1rem;
padding: 0.75rem;
border-top: 1px solid var(--mood-border);
font-size: 0.75rem;
color: var(--mood-text-muted);
}
/* === Main === */
.app-main {
flex: 1;
min-width: 0;
padding: 1.5rem 1rem;
}
@media (min-width: 640px) {
.app-main {
padding: 1.5rem 1.5rem;
}
}
@media (min-width: 1024px) {
.app-main {
padding: 2rem 2rem;
}
}
/* === WS Banner === */
.app-ws-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--mood-error);
color: white;
font-size: 0.75rem;
font-weight: 500;
}
.app-ws-banner__btn {
margin-left: 0.5rem;
padding: 0.25rem 0.5rem;
background: rgba(255,255,255,0.2);
border: none;
border-radius: 3px;
color: white;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
}
.app-ws-banner__btn:hover {
background: rgba(255,255,255,0.3);
}
/* === Footer === */
.app-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.6875rem;
color: var(--mood-text-muted);
border-top: 1px solid var(--mood-border);
}
.app-footer__sep {
opacity: 0.4;
}
/* === Transitions === */
.slide-up-enter-active, .slide-up-enter-active,
.slide-up-leave-active { .slide-up-leave-active {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.slide-up-enter-from, .slide-up-enter-from,
.slide-up-leave-to { .slide-up-leave-to {
transform: translateY(100%); transform: translateY(100%);
opacity: 0; opacity: 0;
} }
.slide-up-enter-to,
.slide-up-leave-from {
transform: translateY(0);
opacity: 1;
}
</style> </style>

View File

@@ -1,167 +1,207 @@
/* ========================================================================== /* ==========================================================================
Glibredecision — Mood / Ambiance System Glibredecision — Mood / Ambiance System
4 moods: Peps (light), Zen (light), Chagrine (dark), Grave (dark) 4 moods: Peps (light), Zen (light), Chagrine (dark), Grave (dark)
Design: saturated, modern, zero pastels.
========================================================================== */ ========================================================================== */
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Peps — Energique et chaleureux (Light) Peps — Energique, vif, franc (Light)
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.mood-peps { .mood-peps {
--mood-bg: #ffffff; --mood-bg: #fafafa;
--mood-surface: #fffbf5; --mood-surface: #ffffff;
--mood-surface-hover: #fff5ea; --mood-surface-hover: #f5f0ec;
--mood-text: #1a1a1a; --mood-text: #18120e;
--mood-text-muted: #6b6b6b; --mood-text-muted: #6e5f52;
--mood-accent: #e85d26; --mood-accent: #d44a10;
--mood-accent-soft: #fff3ed; --mood-accent-soft: rgba(212, 74, 16, 0.08);
--mood-accent-text: #ffffff; --mood-accent-text: #ffffff;
--mood-border: #fde8d8; --mood-border: #e0d5cb;
--mood-success: #22c55e; --mood-success: #18843b;
--mood-warning: #f59e0b; --mood-warning: #c27e07;
--mood-error: #ef4444; --mood-error: #c42b2b;
--mood-gradient: linear-gradient(135deg, #fff8f0 0%, #ffffff 100%); --mood-gradient: linear-gradient(160deg, #faf8f5 0%, #ffffff 50%, #faf7f4 100%);
--mood-shadow: rgba(232, 93, 38, 0.08); --mood-shadow: rgba(120, 60, 10, 0.06);
--mood-input-bg: #ffffff;
--mood-input-border: #c9bdb0;
--mood-input-focus: #d44a10;
--mood-status-prepa: #fed7aa; --mood-status-prepa: #b35c0a;
--mood-status-prepa-text: #9a3412; --mood-status-prepa-bg: rgba(179, 92, 10, 0.12);
--mood-status-vote: #bfdbfe; --mood-status-vote: #1856a8;
--mood-status-vote-text: #1e40af; --mood-status-vote-bg: rgba(24, 86, 168, 0.10);
--mood-status-vigueur: #bbf7d0; --mood-status-vigueur: #18843b;
--mood-status-vigueur-text: #166534; --mood-status-vigueur-bg: rgba(24, 132, 59, 0.10);
--mood-status-clos: #e5e7eb; --mood-status-clos: #5c5c5c;
--mood-status-clos-text: #374151; --mood-status-clos-bg: rgba(92, 92, 92, 0.08);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Zen — Calme et serein (Light) Zen — Ancre, equilibre, sobre (Light)
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.mood-zen { .mood-zen {
--mood-bg: #f8faf8; --mood-bg: #f7f9f7;
--mood-surface: #ffffff; --mood-surface: #ffffff;
--mood-surface-hover: #f0f7f2; --mood-surface-hover: #edf3ee;
--mood-text: #1a2e1a; --mood-text: #141e14;
--mood-text-muted: #5f7a5f; --mood-text-muted: #4a6650;
--mood-accent: #4a9e6f; --mood-accent: #2d7a4a;
--mood-accent-soft: #ecf5ef; --mood-accent-soft: rgba(45, 122, 74, 0.07);
--mood-accent-text: #ffffff; --mood-accent-text: #ffffff;
--mood-border: #d4e7d9; --mood-border: #c2d4c6;
--mood-success: #34d399; --mood-success: #1d8a42;
--mood-warning: #fbbf24; --mood-warning: #b07309;
--mood-error: #f87171; --mood-error: #be3232;
--mood-gradient: linear-gradient(135deg, #f0f7f2 0%, #f8faf8 100%); --mood-gradient: linear-gradient(160deg, #f4f8f4 0%, #ffffff 50%, #f5f8f5 100%);
--mood-shadow: rgba(74, 158, 111, 0.08); --mood-shadow: rgba(30, 80, 50, 0.05);
--mood-input-bg: #ffffff;
--mood-input-border: #a8c0ad;
--mood-input-focus: #2d7a4a;
--mood-status-prepa: #fde68a; --mood-status-prepa: #9e6b0a;
--mood-status-prepa-text: #78350f; --mood-status-prepa-bg: rgba(158, 107, 10, 0.10);
--mood-status-vote: #a7f3d0; --mood-status-vote: #1565a5;
--mood-status-vote-text: #065f46; --mood-status-vote-bg: rgba(21, 101, 165, 0.10);
--mood-status-vigueur: #bbf7d0; --mood-status-vigueur: #1d8a42;
--mood-status-vigueur-text: #166534; --mood-status-vigueur-bg: rgba(29, 138, 66, 0.10);
--mood-status-clos: #d1d5db; --mood-status-clos: #606060;
--mood-status-clos-text: #374151; --mood-status-clos-bg: rgba(96, 96, 96, 0.08);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Chagrine — Profond et subtil (Dark) Chagrine — Dense, veloute, introspectif (Dark)
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.mood-chagrine { .mood-chagrine {
--mood-bg: #1a1625; --mood-bg: #16121e;
--mood-surface: #231e30; --mood-surface: #1e1828;
--mood-surface-hover: #2d2640; --mood-surface-hover: #281f36;
--mood-text: #e8e0f0; --mood-text: #e0d8ec;
--mood-text-muted: #9b8fb5; --mood-text-muted: #8e80a8;
--mood-accent: #9b7fd4; --mood-accent: #8b6cc4;
--mood-accent-soft: #2d2640; --mood-accent-soft: rgba(139, 108, 196, 0.12);
--mood-accent-text: #ffffff; --mood-accent-text: #ffffff;
--mood-border: #342d45; --mood-border: #2e2540;
--mood-success: #6ee7b7; --mood-success: #48c278;
--mood-warning: #fcd34d; --mood-warning: #d4a030;
--mood-error: #fca5a5; --mood-error: #e06060;
--mood-gradient: linear-gradient(135deg, #1a1625 0%, #231e30 100%); --mood-gradient: linear-gradient(160deg, #16121e 0%, #1e1828 50%, #1a1524 100%);
--mood-shadow: rgba(155, 127, 212, 0.12); --mood-shadow: rgba(100, 60, 180, 0.10);
--mood-input-bg: #1e1828;
--mood-input-border: #3a2e52;
--mood-input-focus: #8b6cc4;
--mood-status-prepa: #4c1d95; --mood-status-prepa: #c4a050;
--mood-status-prepa-text: #ddd6fe; --mood-status-prepa-bg: rgba(196, 160, 80, 0.14);
--mood-status-vote: #312e81; --mood-status-vote: #7090d0;
--mood-status-vote-text: #c7d2fe; --mood-status-vote-bg: rgba(112, 144, 208, 0.14);
--mood-status-vigueur: #064e3b; --mood-status-vigueur: #48c278;
--mood-status-vigueur-text: #a7f3d0; --mood-status-vigueur-bg: rgba(72, 194, 120, 0.14);
--mood-status-clos: #2d2640; --mood-status-clos: #706080;
--mood-status-clos-text: #9b8fb5; --mood-status-clos-bg: rgba(112, 96, 128, 0.12);
} }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Grave — Serieux et solennel (Dark) Grave — Mineral, solennel, net (Dark)
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */
.mood-grave { .mood-grave {
--mood-bg: #141518; --mood-bg: #111214;
--mood-surface: #1c1d21; --mood-surface: #191a1e;
--mood-surface-hover: #262420; --mood-surface-hover: #22201c;
--mood-text: #e5e5e0; --mood-text: #e2e0d8;
--mood-text-muted: #8a8a85; --mood-text-muted: #8a877e;
--mood-accent: #d4a545; --mood-accent: #c49530;
--mood-accent-soft: #262420; --mood-accent-soft: rgba(196, 149, 48, 0.10);
--mood-accent-text: #141518; --mood-accent-text: #111214;
--mood-border: #2a2b30; --mood-border: #2a2a2e;
--mood-success: #86efac; --mood-success: #4ac070;
--mood-warning: #fde68a; --mood-warning: #d4a530;
--mood-error: #fca5a5; --mood-error: #d85050;
--mood-gradient: linear-gradient(135deg, #141518 0%, #1c1d21 100%); --mood-gradient: linear-gradient(160deg, #111214 0%, #191a1e 50%, #141518 100%);
--mood-shadow: rgba(212, 165, 69, 0.10); --mood-shadow: rgba(160, 120, 30, 0.08);
--mood-input-bg: #191a1e;
--mood-input-border: #38362e;
--mood-input-focus: #c49530;
--mood-status-prepa: #78350f; --mood-status-prepa: #c49530;
--mood-status-prepa-text: #fde68a; --mood-status-prepa-bg: rgba(196, 149, 48, 0.14);
--mood-status-vote: #1e3a5f; --mood-status-vote: #5a90c8;
--mood-status-vote-text: #93c5fd; --mood-status-vote-bg: rgba(90, 144, 200, 0.14);
--mood-status-vigueur: #14532d; --mood-status-vigueur: #4ac070;
--mood-status-vigueur-text: #86efac; --mood-status-vigueur-bg: rgba(74, 192, 112, 0.14);
--mood-status-clos: #27272a; --mood-status-clos: #686860;
--mood-status-clos-text: #a1a1aa; --mood-status-clos-bg: rgba(104, 104, 96, 0.12);
} }
/* ========================================================================== /* ==========================================================================
Base Utilities Global design tokens — modern, refined, thin
========================================================================== */ ========================================================================== */
/* Transition all mood changes smoothly */
body { body {
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
/* Status labels — clickable pill style */ /* --- Status pills — compact, saturated, NO pastels --- */
.status-pill { .status-pill {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0.25rem 0.75rem; padding: 3px 10px;
border-radius: 9999px; border-radius: 4px;
font-size: 0.75rem; font-size: 0.6875rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.15s ease;
user-select: none; user-select: none;
border: none;
} }
.status-pill:hover { .status-pill:hover {
filter: brightness(0.92); filter: brightness(1.1);
transform: scale(1.05);
} }
.status-pill.active { .status-pill.active {
ring: 2px; box-shadow: 0 0 0 2px var(--mood-accent);
ring-offset: 2px;
} }
.status-prepa { .status-prepa {
background: var(--mood-status-prepa); background: var(--mood-status-prepa-bg);
color: var(--mood-status-prepa-text); color: var(--mood-status-prepa);
} }
.status-vote { .status-vote {
background: var(--mood-status-vote); background: var(--mood-status-vote-bg);
color: var(--mood-status-vote-text); color: var(--mood-status-vote);
} }
.status-vigueur { .status-vigueur {
background: var(--mood-status-vigueur); background: var(--mood-status-vigueur-bg);
color: var(--mood-status-vigueur-text); color: var(--mood-status-vigueur);
} }
.status-clos { .status-clos {
background: var(--mood-status-clos); background: var(--mood-status-clos-bg);
color: var(--mood-status-clos-text); color: var(--mood-status-clos);
}
/* ==========================================================================
Global overrides — Nuxt UI refinements
========================================================================== */
/* Inputs: thin, clean, mood-aware */
:root .mood-peps,
:root .mood-zen,
:root .mood-chagrine,
:root .mood-grave {
/* UInput / UTextarea */
--ui-border: var(--mood-input-border);
--ui-bg: var(--mood-input-bg);
--ui-text-highlighted: var(--mood-accent);
}
/* UButton refinements */
:root .mood-peps button[class*="UButton"],
:root .mood-zen button[class*="UButton"],
:root .mood-chagrine button[class*="UButton"],
:root .mood-grave button[class*="UButton"] {
font-weight: 500;
letter-spacing: 0.01em;
} }

View File

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

View File

@@ -1,10 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* SectionLayout — Mise en page reutilisable pour les sections (documents, decisions, mandats). * SectionLayout — Mise en page pour sections (documents, decisions, mandats).
* *
* Structure : titre + status filter pills en haut, * Structure : titre + status pills + grille (contenu + toolbox).
* puis une grille principale (contenu) + barre laterale "Boite a outils". * Responsive : toolbox passe sous le contenu sur mobile.
* Responsive : sur mobile la boite a outils passe sous le contenu principal.
*/ */
export interface StatusFilter { export interface StatusFilter {
@@ -43,7 +42,6 @@ const emit = defineEmits<{
'update:activeStatus': [status: string | null] 'update:activeStatus': [status: string | null]
}>() }>()
/** Map status id to CSS class for status pills. */
const statusCssMap: Record<string, string> = { const statusCssMap: Record<string, string> = {
draft: 'status-prepa', draft: 'status-prepa',
qualification: 'status-prepa', qualification: 'status-prepa',
@@ -74,19 +72,15 @@ function toggleStatus(statusId: string) {
</script> </script>
<template> <template>
<div class="section-layout"> <div class="section">
<!-- Header: title + status pills --> <!-- Header -->
<div class="section-layout__header"> <div class="section__header">
<div class="section-layout__title-block"> <div class="section__title-block">
<h1 class="section-layout__title"> <h1 class="section__title">{{ title }}</h1>
{{ title }} <p v-if="subtitle" class="section__subtitle">{{ subtitle }}</p>
</h1>
<p v-if="subtitle" class="section-layout__subtitle">
{{ subtitle }}
</p>
</div> </div>
<div v-if="statuses.length > 0" class="section-layout__status-pills"> <div v-if="statuses.length > 0" class="section__pills">
<button <button
v-for="status in statuses" v-for="status in statuses"
:key="status.id" :key="status.id"
@@ -96,45 +90,34 @@ function toggleStatus(statusId: string) {
@click="toggleStatus(status.id)" @click="toggleStatus(status.id)"
> >
{{ status.label }} {{ status.label }}
<span v-if="status.count > 0" class="status-pill__count"> <span v-if="status.count > 0" class="section__pill-count">{{ status.count }}</span>
{{ status.count }}
</span>
</button> </button>
</div> </div>
</div> </div>
<!-- Main content area: list + toolbox sidebar --> <!-- Body: content + toolbox -->
<div class="section-layout__body"> <div class="section__body">
<!-- Left: search + list --> <div class="section__main">
<div class="section-layout__main"> <div v-if="$slots.search" class="section__search">
<!-- Search / sort bar slot -->
<div v-if="$slots.search" class="section-layout__search">
<slot name="search" /> <slot name="search" />
</div> </div>
<div class="section__content">
<!-- Main list content -->
<div class="section-layout__content">
<slot /> <slot />
</div> </div>
<div v-if="$slots.empty" class="section__empty">
<!-- Empty state slot -->
<div v-if="$slots.empty" class="section-layout__empty">
<slot name="empty" /> <slot name="empty" />
</div> </div>
</div> </div>
<!-- Right: toolbox sidebar --> <aside class="section__toolbox">
<aside class="section-layout__toolbox"> <div class="section__toolbox-head">
<div class="section-layout__toolbox-header"> <UIcon name="i-lucide-wrench" class="text-xs" />
<UIcon name="i-lucide-wrench" class="text-sm" />
<span>Boite a outils</span> <span>Boite a outils</span>
</div> </div>
<div v-if="$slots.toolbox" class="section__toolbox-body">
<!-- Custom toolbox slot or default vignettes -->
<div v-if="$slots.toolbox" class="section-layout__toolbox-content">
<slot name="toolbox" /> <slot name="toolbox" />
</div> </div>
<div v-else-if="toolboxItems && toolboxItems.length > 0" class="section-layout__toolbox-content"> <div v-else-if="toolboxItems && toolboxItems.length > 0" class="section__toolbox-body">
<ToolboxVignette <ToolboxVignette
v-for="(item, idx) in toolboxItems" v-for="(item, idx) in toolboxItems"
:key="idx" :key="idx"
@@ -142,8 +125,8 @@ function toggleStatus(statusId: string) {
:description="item.description" :description="item.description"
/> />
</div> </div>
<div v-else class="section-layout__toolbox-empty"> <div v-else class="section__toolbox-empty">
<p>Aucun outil disponible</p> Aucun outil disponible
</div> </div>
</aside> </aside>
</div> </div>
@@ -151,126 +134,118 @@ function toggleStatus(statusId: string) {
</template> </template>
<style scoped> <style scoped>
.section-layout { .section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; gap: 1.25rem;
} }
.section-layout__header { .section__header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 0.75rem;
} }
.section-layout__title-block { .section__title-block {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
.section-layout__title { .section__title {
font-size: 1.5rem; font-size: 1.375rem;
font-weight: 700; font-weight: 700;
color: var(--mood-text); color: var(--mood-text);
line-height: 1.2; letter-spacing: -0.01em;
} }
.section-layout__subtitle { .section__subtitle {
margin-top: 0.25rem; margin-top: 0.125rem;
font-size: 0.875rem; font-size: 0.8125rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
} }
.section-layout__status-pills { .section__pills {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.section__pill-count {
margin-left: 0.25rem;
font-size: 0.5625rem;
font-weight: 700;
opacity: 0.7;
}
.section__body {
display: grid;
grid-template-columns: 1fr 16rem;
gap: 1.25rem;
align-items: start;
}
.section__main {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
.section__search {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
align-items: center; align-items: center;
} }
/* Active state ring for pills */ .section__content {
.status-pill.active { min-height: 12rem;
outline: 2px solid var(--mood-accent);
outline-offset: 2px;
} }
.status-pill__count { .section__toolbox {
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; position: sticky;
top: 1rem; top: 4rem;
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; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.625rem; gap: 0.625rem;
background: var(--mood-surface);
border: 1px solid var(--mood-border);
border-radius: 6px;
padding: 0.875rem;
} }
.section-layout__toolbox-empty { .section__toolbox-head {
font-size: 0.75rem; display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 700;
color: var(--mood-accent);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.section__toolbox-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section__toolbox-empty {
font-size: 0.6875rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
text-align: center; text-align: center;
padding: 1rem 0; padding: 0.75rem 0;
} }
/* Responsive: on mobile, toolbox goes below */
@media (max-width: 1023px) { @media (max-width: 1023px) {
.section-layout__body { .section__body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.section__toolbox {
.section-layout__toolbox {
position: static; position: static;
order: 2; order: 2;
} }

View File

@@ -34,9 +34,12 @@ const STATUS_MAP: Record<string, { label: string; cssClass: string }> = {
// Mandate specific // Mandate specific
formulation: { label: 'En prepa', cssClass: 'status-prepa' }, formulation: { label: 'En prepa', cssClass: 'status-prepa' },
candidature: { label: 'En prepa', cssClass: 'status-prepa' }, candidature: { label: 'En prepa', cssClass: 'status-prepa' },
candidacy: { label: 'En prepa', cssClass: 'status-prepa' },
investiture: { label: 'En vote', cssClass: 'status-vote' }, investiture: { label: 'En vote', cssClass: 'status-vote' },
revoked: { label: 'Clos', cssClass: 'status-clos' }, revoked: { label: 'Clos', cssClass: 'status-clos' },
completed: { label: 'Clos', cssClass: 'status-clos' }, completed: { label: 'Clos', cssClass: 'status-clos' },
archived: { label: 'Clos', cssClass: 'status-clos' },
reporting: { label: 'En vote', cssClass: 'status-vote' },
} }
const resolved = computed(() => { const resolved = computed(() => {
@@ -55,7 +58,7 @@ function handleClick() {
v-if="clickable" v-if="clickable"
type="button" type="button"
class="status-pill" class="status-pill"
:class="[resolved.cssClass, { 'status-pill--active': active }]" :class="[resolved.cssClass, { active: active }]"
@click="handleClick" @click="handleClick"
> >
{{ resolved.label }} {{ resolved.label }}
@@ -68,83 +71,3 @@ function handleClick() {
{{ resolved.label }} {{ resolved.label }}
</span> </span>
</template> </template>
<style scoped>
.status-pill {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
border: 1px solid transparent;
cursor: default;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
button.status-pill {
cursor: pointer;
}
button.status-pill:hover {
opacity: 0.85;
}
.status-pill--active {
box-shadow: 0 0 0 2px currentColor;
}
/* --- En prepa (amber/warning) --- */
.status-prepa {
background-color: var(--ui-color-amber-50, #fffbeb);
color: var(--ui-color-amber-700, #b45309);
border-color: var(--ui-color-amber-200, #fde68a);
}
/* --- En vigueur (green/success) --- */
.status-vigueur {
background-color: var(--ui-color-green-50, #f0fdf4);
color: var(--ui-color-green-700, #15803d);
border-color: var(--ui-color-green-200, #bbf7d0);
}
/* --- En vote (blue/primary) --- */
.status-vote {
background-color: var(--ui-color-blue-50, #eff6ff);
color: var(--ui-color-blue-700, #1d4ed8);
border-color: var(--ui-color-blue-200, #bfdbfe);
}
/* --- Clos (gray/neutral) --- */
.status-clos {
background-color: var(--ui-color-gray-50, #f9fafb);
color: var(--ui-color-gray-500, #6b7280);
border-color: var(--ui-color-gray-200, #e5e7eb);
}
/* Dark mode overrides */
.dark .status-prepa {
background-color: var(--ui-color-amber-950, #451a03);
color: var(--ui-color-amber-300, #fcd34d);
border-color: var(--ui-color-amber-800, #92400e);
}
.dark .status-vigueur {
background-color: var(--ui-color-green-950, #052e16);
color: var(--ui-color-green-300, #86efac);
border-color: var(--ui-color-green-800, #166534);
}
.dark .status-vote {
background-color: var(--ui-color-blue-950, #172554);
color: var(--ui-color-blue-300, #93c5fd);
border-color: var(--ui-color-blue-800, #1e40af);
}
.dark .status-clos {
background-color: var(--ui-color-gray-900, #111827);
color: var(--ui-color-gray-400, #9ca3af);
border-color: var(--ui-color-gray-700, #374151);
}
</style>

View File

@@ -1,9 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* ToolboxVignette — Carte compacte pour la barre laterale "Boite a outils". * ToolboxVignette — Carte compacte pour la boite a outils.
*
* Affiche un protocole ou outil avec titre, description, contexte et actions.
* Utilise les variables mood pour le theming.
*/ */
export interface ToolboxAction { export interface ToolboxAction {
@@ -51,82 +48,87 @@ function handleAction(action: ToolboxAction) {
</script> </script>
<template> <template>
<div class="toolbox-vignette"> <div class="vignette">
<h4 class="toolbox-vignette__title"> <h4 class="vignette__title">{{ title }}</h4>
{{ title }} <p v-if="description" class="vignette__desc">{{ description }}</p>
</h4> <div v-if="contextLabel" class="vignette__ctx">
<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" /> <UIcon name="i-lucide-tag" class="text-xs" />
<span>Contexte : {{ contextLabel }}</span> <span>{{ contextLabel }}</span>
</div> </div>
<div class="vignette__actions">
<div class="toolbox-vignette__actions"> <button
<UButton
v-for="action in resolvedActions" v-for="action in resolvedActions"
:key="action.label" :key="action.label"
:icon="action.icon" class="vignette__btn"
:label="action.label"
size="xs"
variant="soft"
class="toolbox-vignette__action-btn"
@click="handleAction(action)" @click="handleAction(action)"
/> >
<UIcon v-if="action.icon" :name="action.icon" class="text-xs" />
<span>{{ action.label }}</span>
</button>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.toolbox-vignette { .vignette {
background: var(--mood-surface); background: var(--mood-surface);
border: 1px solid var(--mood-border); border: 1px solid var(--mood-border);
border-radius: 0.5rem; border-radius: 4px;
padding: 0.75rem; padding: 0.625rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.375rem;
transition: border-color 0.2s ease, box-shadow 0.2s ease; transition: border-color 0.15s ease;
} }
.vignette:hover {
.toolbox-vignette:hover {
border-color: var(--mood-accent); border-color: var(--mood-accent);
box-shadow: 0 1px 4px var(--mood-shadow);
} }
.toolbox-vignette__title { .vignette__title {
font-size: 0.8125rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: var(--mood-text); color: var(--mood-text);
line-height: 1.3; margin: 0;
} }
.toolbox-vignette__description { .vignette__desc {
font-size: 0.75rem; font-size: 0.6875rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
line-height: 1.4; line-height: 1.35;
margin: 0;
} }
.toolbox-vignette__context { .vignette__ctx {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: 0.25rem;
font-size: 0.6875rem; font-size: 0.625rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
} }
.toolbox-vignette__actions { .vignette__actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.25rem; gap: 0.25rem;
margin-top: 0.25rem; margin-top: 0.125rem;
} }
.toolbox-vignette__action-btn { .vignette__btn {
--btn-bg: var(--mood-accent-soft); display: inline-flex;
--btn-color: var(--mood-accent); align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
font-size: 0.625rem;
font-weight: 600;
color: var(--mood-accent);
background: var(--mood-accent-soft);
border: none;
border-radius: 3px;
cursor: pointer;
transition: opacity 0.12s ease;
letter-spacing: 0.01em;
}
.vignette__btn:hover {
opacity: 0.8;
} }
</style> </style>

View File

@@ -5,16 +5,17 @@ export interface Mood {
label: string label: string
description: string description: string
icon: string icon: string
color: string
isDark: boolean isDark: boolean
} }
const STORAGE_KEY = 'glibredecision_mood' const STORAGE_KEY = 'glibredecision_mood'
const moods: Mood[] = [ const moods: Mood[] = [
{ id: 'peps', label: 'Peps', description: 'Energique et chaleureux', icon: 'i-lucide-sun', isDark: false }, { id: 'peps', label: 'Peps', description: 'Energique et franc', icon: 'i-lucide-sun', color: '#d44a10', isDark: false },
{ id: 'zen', label: 'Zen', description: 'Calme et serein', icon: 'i-lucide-leaf', isDark: false }, { id: 'zen', label: 'Zen', description: 'Ancre et sobre', icon: 'i-lucide-leaf', color: '#2d7a4a', isDark: false },
{ id: 'chagrine', label: 'Chagrine', description: 'Profond et subtil', icon: 'i-lucide-moon', isDark: true }, { id: 'chagrine', label: 'Chagrine', description: 'Dense et veloute', icon: 'i-lucide-moon', color: '#8b6cc4', isDark: true },
{ id: 'grave', label: 'Grave', description: 'Serieux et solennel', icon: 'i-lucide-shield', isDark: true }, { id: 'grave', label: 'Grave', description: 'Mineral et solennel', icon: 'i-lucide-shield', color: '#c49530', isDark: true },
] ]
const currentMood: Ref<string> = ref('peps') const currentMood: Ref<string> = ref('peps')

View File

@@ -2,8 +2,8 @@
/** /**
* Dashboard / Page d'accueil — Glibredecision. * Dashboard / Page d'accueil — Glibredecision.
* *
* Accueil chaleureux avec onboarding : cartes d'entree vers les sections, * Accueil sobre et lisible : cartes d'entree, banniere connexion,
* banniere de connexion, apercu de la boite a outils et activite recente. * apercu boite a outils et activite recente.
*/ */
const documents = useDocumentsStore() const documents = useDocumentsStore()
const decisions = useDecisionsStore() const decisions = useDecisionsStore()
@@ -67,7 +67,6 @@ const recentDecisions = computed(() => {
.slice(0, 5) .slice(0, 5)
}) })
/** Format a date string to a localized relative or absolute string. */
function formatDate(dateStr: string): string { function formatDate(dateStr: string): string {
const date = new Date(dateStr) const date = new Date(dateStr)
const now = new Date() const now = new Date()
@@ -76,7 +75,7 @@ function formatDate(dateStr: string): string {
if (diffHours < 1) { if (diffHours < 1) {
const diffMinutes = Math.floor(diffMs / (1000 * 60)) const diffMinutes = Math.floor(diffMs / (1000 * 60))
return diffMinutes <= 1 ? 'Il y a un instant' : `Il y a ${diffMinutes} min` return diffMinutes <= 1 ? 'A l\'instant' : `Il y a ${diffMinutes} min`
} }
if (diffHours < 24) { if (diffHours < 24) {
return `Il y a ${Math.floor(diffHours)}h` return `Il y a ${Math.floor(diffHours)}h`
@@ -89,191 +88,147 @@ function formatDate(dateStr: string): string {
</script> </script>
<template> <template>
<div class="dashboard" :style="{ background: 'var(--mood-gradient)' }"> <div class="dash">
<!-- Welcome banner --> <!-- Welcome -->
<div class="dashboard__welcome"> <div class="dash__welcome">
<h1 class="dashboard__welcome-title"> <h1 class="dash__title">Glibredecision</h1>
Bienvenue sur Glibredecision <p class="dash__subtitle">
</h1> 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> </p>
</div> </div>
<!-- Entry cards grid --> <!-- Entry cards -->
<div class="dashboard__entries"> <div class="dash__entries">
<template v-if="loading"> <template v-if="loading">
<LoadingSkeleton <LoadingSkeleton v-for="i in 3" :key="i" :lines="3" card />
v-for="i in 3"
:key="i"
:lines="3"
card
/>
</template> </template>
<template v-else> <template v-else>
<NuxtLink <NuxtLink
v-for="card in entryCards" v-for="card in entryCards"
:key="card.key" :key="card.key"
:to="card.to" :to="card.to"
class="dashboard__entry-card" class="entry-card"
> >
<div class="dashboard__entry-icon"> <div class="entry-card__icon">
<UIcon :name="card.icon" class="text-2xl" /> <UIcon :name="card.icon" class="text-xl" />
</div> </div>
<h2 class="dashboard__entry-title"> <h2 class="entry-card__title">{{ card.title }}</h2>
{{ card.title }}
</h2>
<!-- Count badge for documents and decisions -->
<template v-if="card.count !== null"> <template v-if="card.count !== null">
<p class="dashboard__entry-count"> <span class="entry-card__count">{{ card.countLabel }}</span>
{{ card.countLabel }} <span class="entry-card__total">{{ card.totalLabel }}</span>
</p>
<p class="dashboard__entry-total">
{{ card.totalLabel }}
</p>
</template> </template>
<!-- Special onboarding text for mandats -->
<template v-else> <template v-else>
<p class="dashboard__entry-onboard"> <span class="entry-card__desc">{{ card.description }}</span>
{{ card.description }}
</p>
</template> </template>
<span class="entry-card__arrow">
<UButton <UIcon name="i-lucide-arrow-right" class="text-sm" />
label="Entrer" </span>
variant="soft"
size="xs"
trailing-icon="i-lucide-arrow-right"
class="dashboard__entry-btn"
/>
</NuxtLink> </NuxtLink>
</template> </template>
</div> </div>
<!-- Onboarding banner for unauthenticated users --> <!-- Connect banner -->
<div v-if="!auth.isAuthenticated" class="dashboard__onboarding"> <div v-if="!auth.isAuthenticated" class="dash__connect">
<div class="dashboard__onboarding-content"> <div class="dash__connect-left">
<UIcon name="i-lucide-key-round" class="text-xl" /> <UIcon name="i-lucide-key-round" class="text-base" />
<div> <div>
<p class="dashboard__onboarding-text"> <p class="dash__connect-text">Connectez-vous avec votre identite Duniter pour participer.</p>
Connectez-vous avec votre identite Duniter pour participer aux decisions collectives. <p class="dash__connect-hint">Signature Ed25519 · aucun mot de passe</p>
</p>
<p class="dashboard__onboarding-hint">
Authentification par signature Ed25519 aucun mot de passe.
</p>
</div> </div>
</div> </div>
<UButton <NuxtLink to="/login" class="dash__connect-btn">
to="/login" <UIcon name="i-lucide-log-in" class="text-sm" />
label="Se connecter" <span>Connexion</span>
icon="i-lucide-log-in" </NuxtLink>
variant="soft"
size="sm"
/>
</div> </div>
<!-- Boite a outils teaser --> <!-- Boite a outils teaser -->
<div class="dashboard__toolbox-teaser"> <div class="dash__toolbox">
<div class="dashboard__toolbox-teaser-header"> <div class="dash__toolbox-head">
<UIcon name="i-lucide-wrench" class="text-lg" /> <UIcon name="i-lucide-wrench" class="text-base" />
<h3>Boite a outils</h3> <h3>Boite a outils</h3>
<UBadge variant="subtle" size="xs"> <span class="dash__toolbox-count">{{ protocols.protocols.length }}</span>
{{ protocols.protocols.length }} modalite{{ protocols.protocols.length > 1 ? 's' : '' }}
</UBadge>
</div> </div>
<p class="dashboard__toolbox-teaser-description"> <p class="dash__toolbox-desc">
Protocoles de vote configurables avec formule de seuil WoT adaptative. Protocoles de vote avec formule de seuil WoT adaptative.
</p> </p>
<div class="dashboard__toolbox-teaser-tags"> <div class="dash__toolbox-tags">
<template v-if="protocols.protocols.length > 0"> <template v-if="protocols.protocols.length > 0">
<NuxtLink <NuxtLink
v-for="protocol in protocols.protocols" v-for="protocol in protocols.protocols"
:key="protocol.id" :key="protocol.id"
:to="`/protocols/${protocol.id}`" :to="`/protocols/${protocol.id}`"
class="dashboard__toolbox-tag" class="dash__tag"
> >
{{ protocol.name }} {{ protocol.name }}
</NuxtLink> </NuxtLink>
</template> </template>
<template v-else> <template v-else>
<span class="dashboard__toolbox-tag">Vote majoritaire</span> <span class="dash__tag">Vote majoritaire</span>
<span class="dashboard__toolbox-tag">Vote nuance</span> <span class="dash__tag">Vote nuance</span>
<span class="dashboard__toolbox-tag">Vote permanent</span> <span class="dash__tag">Vote permanent</span>
</template> </template>
</div> </div>
<UButton <NuxtLink to="/protocols" class="dash__toolbox-link">
to="/protocols" Voir la boite a outils
label="Voir la boite a outils" <UIcon name="i-lucide-chevron-right" class="text-xs" />
variant="ghost" </NuxtLink>
size="xs"
trailing-icon="i-lucide-chevron-right"
class="mt-1"
/>
</div> </div>
<!-- Recent activity --> <!-- Recent activity -->
<div v-if="recentDecisions.length > 0" class="dashboard__activity"> <div v-if="recentDecisions.length > 0" class="dash__activity">
<div class="dashboard__activity-header"> <div class="dash__activity-head">
<UIcon name="i-lucide-activity" class="text-lg" /> <UIcon name="i-lucide-activity" class="text-base" />
<h3>Activite recente</h3> <h3>Activite recente</h3>
</div> </div>
<div class="dashboard__activity-list"> <div class="dash__activity-list">
<NuxtLink <NuxtLink
v-for="decision in recentDecisions" v-for="decision in recentDecisions"
:key="decision.id" :key="decision.id"
:to="`/decisions/${decision.id}`" :to="`/decisions/${decision.id}`"
class="dashboard__activity-item" class="dash__activity-item"
> >
<div class="dashboard__activity-item-main"> <div class="dash__activity-main">
<span class="dashboard__activity-item-title">{{ decision.title }}</span> <span class="dash__activity-title">{{ decision.title }}</span>
<span class="dashboard__activity-item-date">{{ formatDate(decision.updated_at) }}</span> <span class="dash__activity-date">{{ formatDate(decision.updated_at) }}</span>
</div> </div>
<StatusBadge :status="decision.status" type="decision" /> <StatusBadge :status="decision.status" type="decision" :clickable="false" />
</NuxtLink> </NuxtLink>
</div> </div>
</div> </div>
<!-- Formula explainer collapsible --> <!-- Formula explainer -->
<UCollapsible v-model:open="formulaOpen"> <UCollapsible v-model:open="formulaOpen">
<UButton <button class="dash__formula-trigger" @click="formulaOpen = !formulaOpen">
variant="ghost" <div class="dash__formula-trigger-left">
size="sm" <UIcon name="i-lucide-calculator" class="text-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" />
<span>Formule de seuil WoT</span> <span>Formule de seuil WoT</span>
</div> </div>
</UButton> <UIcon
:name="formulaOpen ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
class="text-xs"
/>
</button>
<template #content> <template #content>
<div class="dashboard__formula-content"> <div class="dash__formula-body">
<p class="dashboard__formula-description"> <p class="dash__formula-desc">
Le seuil d'adoption s'adapte dynamiquement a la participation : Le seuil s'adapte a la participation : faible = quasi-unanimite ; forte = majorite simple.
faible participation = quasi-unanimite requise ; forte participation = majorite simple suffisante.
</p> </p>
<code class="dashboard__formula-code"> <code class="dash__formula-code">
Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C) Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C)
</code> </code>
<div class="dashboard__formula-params"> <div class="dash__formula-params">
<span>C = constante de base</span> <span>C = constante</span>
<span>B = exposant de base</span> <span>B = base</span>
<span>W = taille WoT</span> <span>W = taille WoT</span>
<span>T = votes totaux</span> <span>T = votes</span>
<span>M = majorite</span> <span>M = majorite</span>
<span>G = gradient</span> <span>G = gradient</span>
</div> </div>
<UButton <NuxtLink to="/protocols/formulas" class="dash__formula-link">
to="/protocols/formulas" Ouvrir le simulateur
label="Ouvrir le simulateur" <UIcon name="i-lucide-chevron-right" class="text-xs" />
variant="outline" </NuxtLink>
size="xs"
icon="i-lucide-calculator"
class="mt-2"
/>
</div> </div>
</template> </template>
</UCollapsible> </UCollapsible>
@@ -281,254 +236,275 @@ function formatDate(dateStr: string): string {
</template> </template>
<style scoped> <style scoped>
.dashboard { .dash {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2rem; gap: 1.5rem;
min-height: 100%; max-width: 52rem;
padding-bottom: 2rem;
} }
/* --- Welcome --- */ /* --- Welcome --- */
.dashboard__welcome { .dash__welcome {
text-align: center; padding: 0.5rem 0;
padding: 1rem 0;
} }
.dash__title {
.dashboard__welcome-title { font-size: 1.625rem;
font-size: 1.75rem;
font-weight: 800; font-weight: 800;
color: var(--mood-accent); color: var(--mood-accent);
line-height: 1.2; letter-spacing: -0.02em;
} }
.dash__subtitle {
@media (min-width: 640px) { margin-top: 0.25rem;
.dashboard__welcome-title { font-size: 0.875rem;
font-size: 2.25rem;
}
}
.dashboard__welcome-subtitle {
margin-top: 0.5rem;
font-size: 1rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
max-width: 42rem;
margin-left: auto;
margin-right: auto;
line-height: 1.5;
} }
/* --- Entry cards --- */ /* --- Entry cards --- */
.dashboard__entries { .dash__entries {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
gap: 1rem; gap: 0.75rem;
} }
.dashboard__entry-card { .entry-card {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; gap: 0.375rem;
gap: 0.5rem; padding: 1.25rem 1rem;
padding: 1.5rem 1rem;
background: var(--mood-surface); background: var(--mood-surface);
border: 1px solid var(--mood-border); border: 1px solid var(--mood-border);
border-radius: 0.75rem; border-radius: 6px;
text-align: center;
text-decoration: none; text-decoration: none;
transition: all 0.2s ease;
cursor: pointer; cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
} }
.entry-card:hover {
.dashboard__entry-card:hover {
border-color: var(--mood-accent); border-color: var(--mood-accent);
box-shadow: 0 4px 12px var(--mood-shadow); box-shadow: 0 2px 8px var(--mood-shadow);
transform: translateY(-2px);
} }
.dashboard__entry-icon { .entry-card__icon {
width: 2.25rem;
height: 2.25rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 3rem; border-radius: 6px;
height: 3rem;
border-radius: 0.75rem;
background: var(--mood-accent-soft); background: var(--mood-accent-soft);
color: var(--mood-accent); color: var(--mood-accent);
margin-bottom: 0.25rem;
} }
.dashboard__entry-title { .entry-card__title {
font-size: 1.125rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
color: var(--mood-text); color: var(--mood-text);
margin: 0;
} }
.dashboard__entry-count { .entry-card__count {
font-size: 1.5rem; font-size: 1.25rem;
font-weight: 800; font-weight: 800;
color: var(--mood-accent); color: var(--mood-accent);
line-height: 1; line-height: 1;
} }
.dashboard__entry-total { .entry-card__total {
font-size: 0.75rem; font-size: 0.6875rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
} }
.dashboard__entry-onboard { .entry-card__desc {
font-size: 0.75rem; font-size: 0.75rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
line-height: 1.4; line-height: 1.4;
max-width: 18rem;
} }
.dashboard__entry-btn { .entry-card__arrow {
margin-top: 0.5rem; position: absolute;
top: 1rem;
right: 1rem;
color: var(--mood-text-muted);
opacity: 0.4;
transition: opacity 0.15s;
}
.entry-card:hover .entry-card__arrow {
opacity: 1;
color: var(--mood-accent);
} }
/* --- Onboarding banner --- */ /* --- Connect banner --- */
.dashboard__onboarding { .dash__connect {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 0.75rem;
padding: 1rem 1.25rem; padding: 0.875rem 1rem;
background: var(--mood-accent-soft); background: var(--mood-accent-soft);
border: 1px solid var(--mood-border); border: 1px solid var(--mood-border);
border-radius: 0.75rem; border-radius: 6px;
} }
.dashboard__onboarding-content { .dash__connect-left {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.75rem; gap: 0.625rem;
color: var(--mood-accent); color: var(--mood-accent);
} }
.dashboard__onboarding-text { .dash__connect-text {
font-size: 0.875rem; font-size: 0.8125rem;
color: var(--mood-text);
font-weight: 500; font-weight: 500;
color: var(--mood-text);
} }
.dashboard__onboarding-hint { .dash__connect-hint {
font-size: 0.75rem; font-size: 0.6875rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
margin-top: 0.125rem; }
.dash__connect-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.875rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 4px;
text-decoration: none;
transition: opacity 0.15s ease;
}
.dash__connect-btn:hover {
opacity: 0.88;
} }
/* --- Toolbox teaser --- */ /* --- Toolbox teaser --- */
.dashboard__toolbox-teaser { .dash__toolbox {
background: var(--mood-surface); background: var(--mood-surface);
border: 1px solid var(--mood-border); border: 1px solid var(--mood-border);
border-radius: 0.75rem; border-radius: 6px;
padding: 1rem 1.25rem; padding: 1rem;
} }
.dashboard__toolbox-teaser-header { .dash__toolbox-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.375rem;
color: var(--mood-accent); color: var(--mood-accent);
font-weight: 700; font-weight: 700;
font-size: 0.9375rem; font-size: 0.875rem;
}
.dash__toolbox-head h3 { margin: 0; }
.dash__toolbox-count {
font-size: 0.625rem;
font-weight: 700;
background: var(--mood-accent-soft);
color: var(--mood-accent);
padding: 1px 5px;
border-radius: 3px;
margin-left: 0.25rem;
} }
.dashboard__toolbox-teaser-header h3 { .dash__toolbox-desc {
margin: 0; margin-top: 0.25rem;
} font-size: 0.75rem;
.dashboard__toolbox-teaser-description {
margin-top: 0.375rem;
font-size: 0.8125rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
} }
.dashboard__toolbox-teaser-tags { .dash__toolbox-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.375rem; gap: 0.375rem;
margin-top: 0.625rem; margin-top: 0.5rem;
} }
.dashboard__toolbox-tag { .dash__tag {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 0.25rem 0.625rem; padding: 0.25rem 0.625rem;
font-size: 0.75rem; font-size: 0.6875rem;
font-weight: 500; font-weight: 500;
color: var(--mood-accent); color: var(--mood-accent);
background: var(--mood-accent-soft); background: var(--mood-accent-soft);
border: 1px solid var(--mood-border); border: 1px solid var(--mood-border);
border-radius: 9999px; border-radius: 4px;
text-decoration: none; text-decoration: none;
transition: background 0.15s ease; transition: border-color 0.15s ease;
}
.dash__tag:hover {
border-color: var(--mood-accent);
} }
.dashboard__toolbox-tag:hover { .dash__toolbox-link {
background: var(--mood-surface-hover); display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
}
.dash__toolbox-link:hover {
text-decoration: underline;
} }
/* --- Recent activity --- */ /* --- Activity --- */
.dashboard__activity { .dash__activity {
background: var(--mood-surface); background: var(--mood-surface);
border: 1px solid var(--mood-border); border: 1px solid var(--mood-border);
border-radius: 0.75rem; border-radius: 6px;
padding: 1rem 1.25rem; padding: 1rem;
} }
.dashboard__activity-header { .dash__activity-head {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.375rem;
color: var(--mood-text); color: var(--mood-text);
font-weight: 700; font-weight: 700;
font-size: 0.9375rem; font-size: 0.875rem;
margin-bottom: 0.75rem; margin-bottom: 0.5rem;
} }
.dash__activity-head h3 { margin: 0; }
.dashboard__activity-header h3 { .dash__activity-list {
margin: 0;
}
.dashboard__activity-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.dashboard__activity-item { .dash__activity-item {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75rem; gap: 0.5rem;
padding: 0.5rem 0; padding: 0.5rem 0;
border-bottom: 1px solid var(--mood-border); border-bottom: 1px solid var(--mood-border);
text-decoration: none; text-decoration: none;
transition: background 0.15s ease; transition: background 0.1s;
} }
.dash__activity-item:last-child { border-bottom: none; }
.dashboard__activity-item:last-child { .dash__activity-item:hover {
border-bottom: none;
}
.dashboard__activity-item:hover {
background: var(--mood-surface-hover); background: var(--mood-surface-hover);
margin-left: -0.5rem; margin: 0 -0.5rem;
margin-right: -0.5rem;
padding-left: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem; padding-right: 0.5rem;
border-radius: 0.375rem; border-radius: 4px;
} }
.dashboard__activity-item-main { .dash__activity-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
.dashboard__activity-item-title { .dash__activity-title {
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
color: var(--mood-text); color: var(--mood-text);
@@ -537,53 +513,84 @@ function formatDate(dateStr: string): string {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.dashboard__activity-item-date { .dash__activity-date {
font-size: 0.6875rem; font-size: 0.625rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
} }
/* --- Formula explainer --- */ /* --- Formula --- */
.dashboard__formula-trigger { .dash__formula-trigger {
width: 100%; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
background: var(--mood-surface); background: var(--mood-surface);
border: 1px solid var(--mood-border); border: 1px solid var(--mood-border);
border-radius: 0.75rem; border-radius: 6px;
color: var(--mood-text-muted);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s;
}
.dash__formula-trigger:hover {
border-color: var(--mood-accent);
} }
.dashboard__formula-content { .dash__formula-trigger-left {
padding: 0 1.25rem 1.25rem; display: flex;
align-items: center;
gap: 0.5rem;
}
.dash__formula-body {
padding: 0 1rem 1rem;
background: var(--mood-surface); background: var(--mood-surface);
border: 1px solid var(--mood-border); border: 1px solid var(--mood-border);
border-top: none; border-top: none;
border-radius: 0 0 0.75rem 0.75rem; border-radius: 0 0 6px 6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.625rem;
} }
.dashboard__formula-description { .dash__formula-desc {
font-size: 0.8125rem; font-size: 0.75rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
line-height: 1.5; line-height: 1.5;
} }
.dashboard__formula-code { .dash__formula-code {
display: block; display: block;
padding: 0.75rem; padding: 0.625rem;
background: var(--mood-accent-soft); background: var(--mood-accent-soft);
border-radius: 0.375rem; border-radius: 4px;
font-size: 0.8125rem; font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, monospace; font-family: ui-monospace, SFMono-Regular, monospace;
color: var(--mood-text); color: var(--mood-text);
overflow-x: auto; overflow-x: auto;
} }
.dashboard__formula-params { .dash__formula-params {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.75rem; gap: 0.625rem;
font-size: 0.6875rem; font-size: 0.625rem;
color: var(--mood-text-muted); color: var(--mood-text-muted);
font-family: ui-monospace, SFMono-Regular, monospace;
}
.dash__formula-link {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-accent);
text-decoration: none;
}
.dash__formula-link:hover {
text-decoration: underline;
} }
</style> </style>

View File

@@ -19,11 +19,9 @@ async function handleLogin() {
step.value = 'signing' step.value = 'signing'
await auth.login(address.value.trim()) await auth.login(address.value.trim())
step.value = 'success' step.value = 'success'
// Redirect to home after a brief moment
setTimeout(() => { setTimeout(() => {
router.push('/') router.push('/')
}, 1000) }, 800)
} catch (err: any) { } catch (err: any) {
errorMessage.value = err?.data?.detail || err?.message || 'Erreur lors de la connexion' errorMessage.value = err?.data?.detail || err?.message || 'Erreur lors de la connexion'
step.value = 'input' step.value = 'input'
@@ -31,37 +29,22 @@ async function handleLogin() {
} }
const steps = computed(() => [ const steps = computed(() => [
{ { label: 'Adresse SS58', icon: 'i-lucide-user', done: step.value !== 'input' },
title: 'Adresse Duniter', { label: 'Challenge', icon: 'i-lucide-shield', done: step.value === 'signing' || step.value === 'success' },
description: 'Entrez votre adresse SS58 Duniter V2', { label: 'Signature', icon: 'i-lucide-key', done: step.value === 'success' },
icon: 'i-lucide-user', { label: 'Connecte', icon: 'i-lucide-check', done: false },
active: step.value === 'input',
complete: step.value !== 'input',
},
{
title: 'Challenge cryptographique',
description: 'Un challenge aleatoire est genere par le serveur',
icon: 'i-lucide-shield',
active: step.value === 'challenge',
complete: step.value === 'signing' || step.value === 'success',
},
{
title: 'Signature Ed25519',
description: 'Signez le challenge avec votre cle privee',
icon: 'i-lucide-key',
active: step.value === 'signing',
complete: step.value === 'success',
},
{
title: 'Connexion',
description: 'Votre identite est verifiee et la session creee',
icon: 'i-lucide-check-circle',
active: step.value === 'success',
complete: false,
},
]) ])
// Redirect if already authenticated const activeStepIndex = computed(() => {
switch (step.value) {
case 'input': return 0
case 'challenge': return 1
case 'signing': return 2
case 'success': return 3
default: return 0
}
})
onMounted(() => { onMounted(() => {
if (auth.isAuthenticated) { if (auth.isAuthenticated) {
router.push('/') router.push('/')
@@ -70,108 +53,289 @@ onMounted(() => {
</script> </script>
<template> <template>
<div class="max-w-lg mx-auto space-y-8 py-8"> <div class="login-page">
<div class="text-center"> <div class="login-card">
<UIcon name="i-lucide-vote" class="text-5xl text-primary mb-4" /> <!-- Logo -->
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> <div class="login-card__header">
Connexion a Glibredecision <span class="login-card__logo">G</span>
</h1> <h1 class="login-card__title">Connexion</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400"> <p class="login-card__subtitle">Identite Duniter V2 · Ed25519</p>
Authentification via votre identite Duniter V2 </div>
</p>
</div>
<!-- Login form --> <!-- Steps indicator -->
<UCard> <div class="login-steps">
<div class="space-y-6"> <div
<div> v-for="(s, i) in steps"
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> :key="i"
Adresse Duniter (SS58) class="login-step"
</label> :class="{
<UInput 'login-step--done': s.done,
v-model="address" 'login-step--active': i === activeStepIndex,
placeholder="5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY" }"
size="lg" >
icon="i-lucide-wallet" <div class="login-step__dot">
:disabled="auth.loading || step !== 'input'" <UIcon v-if="s.done" name="i-lucide-check" class="text-xs" />
/> <span v-else class="login-step__num">{{ i + 1 }}</span>
</div>
<!-- Error message -->
<div v-if="errorMessage || auth.error" class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div class="flex items-center gap-2">
<UIcon name="i-lucide-alert-circle" class="text-red-500" />
<p class="text-sm text-red-700 dark:text-red-400">
{{ errorMessage || auth.error }}
</p>
</div> </div>
<span class="login-step__label">{{ s.label }}</span>
</div> </div>
</div>
<!-- Success message --> <!-- Input -->
<div v-if="step === 'success'" class="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg"> <div class="login-card__field">
<div class="flex items-center gap-2"> <label class="login-card__label">Adresse Duniter (SS58)</label>
<UIcon name="i-lucide-check-circle" class="text-green-500" /> <input
<p class="text-sm text-green-700 dark:text-green-400"> v-model="address"
Connexion reussie ! Redirection en cours... type="text"
</p> class="login-card__input"
</div> placeholder="5GrwvaEF5zXb26Fz9rcQpDWS57Ct..."
</div> :disabled="auth.loading || step !== 'input'"
@keydown.enter="handleLogin"
<UButton
:label="auth.loading ? 'Connexion en cours...' : 'Se connecter avec Duniter'"
icon="i-lucide-log-in"
size="lg"
block
:loading="auth.loading"
:disabled="!address.trim() || step === 'success'"
@click="handleLogin"
/> />
</div> </div>
</UCard>
<!-- Challenge flow steps --> <!-- Error -->
<UCard> <div v-if="errorMessage || auth.error" class="login-card__error">
<div class="space-y-4"> <UIcon name="i-lucide-alert-circle" class="text-sm flex-shrink-0" />
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide"> <span>{{ errorMessage || auth.error }}</span>
Processus d'authentification
</h3>
<div class="space-y-3">
<div
v-for="(s, index) in steps"
:key="index"
class="flex items-start gap-3"
:class="{ 'opacity-40': !s.active && !s.complete }"
>
<div
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
:class="{
'bg-primary text-white': s.active,
'bg-green-500 text-white': s.complete,
'bg-gray-200 dark:bg-gray-700 text-gray-500': !s.active && !s.complete,
}"
>
<UIcon v-if="s.complete" name="i-lucide-check" class="text-sm" />
<span v-else class="text-xs font-bold">{{ index + 1 }}</span>
</div>
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ s.title }}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ s.description }}
</p>
</div>
</div>
</div>
</div> </div>
</UCard>
<!-- Info note --> <!-- Success -->
<div class="text-center"> <div v-if="step === 'success'" class="login-card__success">
<p class="text-xs text-gray-400"> <UIcon name="i-lucide-check-circle" class="text-sm flex-shrink-0" />
L'authentification utilise la cryptographie Ed25519 de Duniter V2. <span>Connecte. Redirection...</span>
Aucun mot de passe n'est transmis au serveur. </div>
<!-- Button -->
<button
class="login-card__btn"
:disabled="!address.trim() || step === 'success' || auth.loading"
@click="handleLogin"
>
<UIcon v-if="auth.loading" name="i-lucide-loader-2" class="animate-spin text-sm" />
<UIcon v-else name="i-lucide-log-in" class="text-sm" />
<span>{{ auth.loading ? 'Verification...' : 'Se connecter' }}</span>
</button>
<!-- Note -->
<p class="login-card__note">
Aucun mot de passe. Authentification par signature cryptographique.
</p> </p>
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 70vh;
padding: 2rem 1rem;
}
.login-card {
width: 100%;
max-width: 24rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.login-card__header {
text-align: center;
}
.login-card__logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
font-size: 1.5rem;
font-weight: 800;
color: var(--mood-accent-text);
background: var(--mood-accent);
border-radius: 8px;
margin-bottom: 0.75rem;
}
.login-card__title {
font-size: 1.375rem;
font-weight: 700;
color: var(--mood-text);
margin: 0;
}
.login-card__subtitle {
font-size: 0.75rem;
color: var(--mood-text-muted);
margin-top: 0.25rem;
letter-spacing: 0.02em;
}
/* Steps */
.login-steps {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
padding: 0.75rem 0;
}
.login-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
}
.login-step__dot {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
border: 1.5px solid var(--mood-border);
color: var(--mood-text-muted);
background: var(--mood-surface);
transition: all 0.2s ease;
}
.login-step--active .login-step__dot {
border-color: var(--mood-accent);
color: var(--mood-accent);
box-shadow: 0 0 0 3px var(--mood-accent-soft);
}
.login-step--done .login-step__dot {
border-color: var(--mood-success);
background: var(--mood-success);
color: white;
}
.login-step__num {
font-size: 0.5625rem;
}
.login-step__label {
font-size: 0.5625rem;
font-weight: 500;
color: var(--mood-text-muted);
text-align: center;
letter-spacing: 0.02em;
}
.login-step--active .login-step__label {
color: var(--mood-accent);
font-weight: 600;
}
/* Field */
.login-card__field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.login-card__label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--mood-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.login-card__input {
width: 100%;
padding: 0.625rem 0.75rem;
font-size: 0.8125rem;
font-family: ui-monospace, SFMono-Regular, 'Cascadia Code', monospace;
color: var(--mood-text);
background: var(--mood-input-bg, var(--mood-surface));
border: 1px solid var(--mood-input-border, var(--mood-border));
border-radius: 4px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.login-card__input:focus {
border-color: var(--mood-input-focus, var(--mood-accent));
box-shadow: 0 0 0 2px var(--mood-accent-soft);
}
.login-card__input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-card__input::placeholder {
color: var(--mood-text-muted);
opacity: 0.5;
}
/* Messages */
.login-card__error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--mood-error);
background: rgba(196, 43, 43, 0.08);
border-radius: 4px;
}
.login-card__success {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--mood-success);
background: rgba(24, 132, 59, 0.08);
border-radius: 4px;
}
/* Button */
.login-card__btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 1rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--mood-accent-text);
background: var(--mood-accent);
border: none;
border-radius: 4px;
cursor: pointer;
transition: opacity 0.15s ease;
letter-spacing: 0.01em;
}
.login-card__btn:hover:not(:disabled) {
opacity: 0.88;
}
.login-card__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Note */
.login-card__note {
text-align: center;
font-size: 0.6875rem;
color: var(--mood-text-muted);
opacity: 0.7;
}
</style>