Fusion du lot A : accueil sur le choix du groupe, logo Clé de voûte, quatre ambiances, menu, À venir, Décisions
Contrôles du lot R renumérotés C18 (Raison d'être) et C19 (versions d'un article). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -44,13 +44,15 @@ crates/
|
|||||||
# sur la chaîne) · seed.rs · web.rs (sert web/build)
|
# sur la chaîne) · seed.rs · web.rs (sert web/build)
|
||||||
tests/api.rs # scénarios bout en bout
|
tests/api.rs # scénarios bout en bout
|
||||||
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur)
|
seeds/ # acte-engagement-certification.json (trilingue, 33 articles, I1/E1/E2 en vigueur)
|
||||||
|
# upcoming/<groupe>.json : les « À venir » des outils d'un groupe (fr, en, es), lus au démarrage
|
||||||
web/src/
|
web/src/
|
||||||
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
|
lib/i18n/ # fr.ts SOURCE (types Key) · en.ts · es.ts — parité testée
|
||||||
lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Stamp…
|
lib/components/ # Sidebar, DocPanel, VoteView, ProposeView, UnitBlock, DeepList, Seal, Logo, Pillars…
|
||||||
routes/ # / · connexion · invitation · equipe · groupes · g/[group] · g/[group]/docs/[doc] (lecteur,
|
routes/ # / (accueil + choix du groupe) · connexion · invitation · equipe · g/[group] (piliers, en cours) ·
|
||||||
# panneau dans l'URL ?onglet=&article=) · acces (trustinWallet) · decisions/mandats/
|
# g/[group]/docs · g/[group]/docs/[doc] (lecteur,
|
||||||
# protocoles (à venir)
|
# panneau dans l'URL ?onglet=&article=) · g/[group]/decisions · g/[group]/mandats ·
|
||||||
web/e2e/ # Playwright C1–C12 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
|
# g/[group]/protocoles (à venir) · acces (trustinWallet) ; /groupes, /decisions… redirigent
|
||||||
|
web/e2e/ # Playwright C1–C17 + M1 ; serve.sh = serveur jetable /tmp/ld-e2e sur :8092
|
||||||
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
docker/ # app.Dockerfile · app-compose.yml (sonic)
|
||||||
.woodpecker/ # app.yml · maquette.yml
|
.woodpecker/ # app.yml · maquette.yml
|
||||||
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
|
docs/ # chantiers/ · adr/ · dev/BLUEPRINT-V2.md (doctrine de la maquette) · recherche/
|
||||||
@@ -71,7 +73,7 @@ maquette/ # v2 intacte (frontend Nuxt, backend FastAPI, docker, docs-v
|
|||||||
```bash
|
```bash
|
||||||
export PATH=$HOME/.cargo/bin:$PATH
|
export PATH=$HOME/.cargo/bin:$PATH
|
||||||
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
cargo fmt --all --check && cargo clippy --workspace --all-targets -- -D warnings && cargo test --workspace
|
||||||
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 13 contrôles
|
cd web && npm run check && npm test && npm run build && npm run e2e # e2e : 18 contrôles
|
||||||
|
|
||||||
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
LD_DEV_LOGIN=1 cargo run -p ld-server # :8002, sert web/build
|
||||||
cd web && npm run dev # :3002 à chaud
|
cd web && npm run dev # :3002 à chaud
|
||||||
@@ -94,8 +96,9 @@ docker build -f docker/app.Dockerfile --target production . # rejoue toute la
|
|||||||
un `from_secret` absent fait refuser tout le pipeline Woodpecker ; la route hôte `/*` de fabio
|
un `from_secret` absent fait refuser tout le pipeline Woodpecker ; la route hôte `/*` de fabio
|
||||||
masque le webroot ACME → arrêter le service avant `acme.sh --issue` ; Playwright en locale
|
masque le webroot ACME → arrêter le service avant `acme.sh --issue` ; Playwright en locale
|
||||||
`fr-FR` (sinon l'interface part en anglais).
|
`fr-FR` (sinon l'interface part en anglais).
|
||||||
- **Design** : sans bordures, arrondis 16/12/20/14, ambiances `pierre` / `nuit`
|
- **Design** : sans bordures, arrondis 16/12/20/14, quatre ambiances `pierre`, `calcaire`
|
||||||
(`data-mood`, posé avant le premier rendu), sceau hexagramme 井 #48 en SVG (`Seal`, `Stamp` —
|
(claires), `nuit`, `ardoise` (sombres) (`data-mood`, posé avant le premier rendu, clé `ld:mood`), logo
|
||||||
|
« Clé de voûte » (`Logo`, jamais incliné, `--cle` par ambiance), sceau hexagramme 井 #48 en SVG (`Seal` —
|
||||||
jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté.
|
jamais le glyphe, qui s'affiche mal). Mouvement réduit respecté.
|
||||||
- **Domaine** : decision.librodrome.org ; dépôt des textes `git.open.us.org/yvv/decision-depot-textes-reference`
|
- **Domaine** : decision.librodrome.org ; dépôt des textes `git.open.us.org/yvv/decision-depot-textes-reference`
|
||||||
(provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur).
|
(provisoire : puis un dépôt pour l'ouverture, puis le dépôt officiel pour les textes en vigueur).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod assist;
|
|||||||
pub mod compose;
|
pub mod compose;
|
||||||
pub mod edit;
|
pub mod edit;
|
||||||
pub mod names;
|
pub mod names;
|
||||||
|
pub mod pending;
|
||||||
pub mod threshold;
|
pub mod threshold;
|
||||||
|
|
||||||
pub use threshold::{FormulaParams, InertiaPreset, Tally};
|
pub use threshold::{FormulaParams, InertiaPreset, Tally};
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
//! What is left to vote in a document — the line of « Décisions ».
|
||||||
|
//!
|
||||||
|
//! A module is an article (a unit). It stays **to vote** as long as no version
|
||||||
|
//! of it was adopted by a vote: its version in force is a seed a minima not yet
|
||||||
|
//! confirmed, or it has no version in force and at least one open version.
|
||||||
|
//! Variants of an article count once. For a person who votes, a module is
|
||||||
|
//! **without their vote** when they hold no valid vote (one not « to
|
||||||
|
//! reconfirm ») on any of its versions.
|
||||||
|
|
||||||
|
/// One article, as one person sees it.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Module {
|
||||||
|
/// The version in force, if any: `Some(true)` when a vote adopted or
|
||||||
|
/// confirmed it, `Some(false)` for a seed a minima.
|
||||||
|
pub in_force: Option<bool>,
|
||||||
|
/// Open versions of the article (proposals to replace, add or remove it).
|
||||||
|
pub open: u32,
|
||||||
|
/// The person holds a valid vote on one of its versions, in force or open.
|
||||||
|
pub voted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Module {
|
||||||
|
pub fn to_vote(&self) -> bool {
|
||||||
|
match self.in_force {
|
||||||
|
Some(adopted) => !adopted,
|
||||||
|
None => self.open > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct Pending {
|
||||||
|
/// Modules not yet adopted by a vote.
|
||||||
|
pub to_vote: u32,
|
||||||
|
/// Among them, those where the person has no valid vote.
|
||||||
|
pub without_my_vote: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending(modules: &[Module]) -> Pending {
|
||||||
|
modules.iter().filter(|m| m.to_vote()).fold(Pending::default(), |acc, m| Pending {
|
||||||
|
to_vote: acc.to_vote + 1,
|
||||||
|
without_my_vote: acc.without_my_vote + u32::from(!m.voted),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SEED: Option<bool> = Some(false);
|
||||||
|
const ADOPTED: Option<bool> = Some(true);
|
||||||
|
|
||||||
|
fn m(in_force: Option<bool>, open: u32, voted: bool) -> Module {
|
||||||
|
Module { in_force, open, voted }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_seed_a_minima_stays_to_vote_until_a_vote_confirms_it() {
|
||||||
|
assert!(m(SEED, 0, false).to_vote());
|
||||||
|
assert!(m(SEED, 2, true).to_vote());
|
||||||
|
assert!(!m(ADOPTED, 0, false).to_vote());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_adopted_article_is_no_longer_to_vote_even_with_rivals() {
|
||||||
|
assert!(!m(ADOPTED, 3, false).to_vote());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_slot_is_to_vote_only_with_an_open_version() {
|
||||||
|
assert!(m(None, 1, false).to_vote());
|
||||||
|
assert!(!m(None, 0, false).to_vote());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn variants_count_once_and_my_vote_on_any_version_counts() {
|
||||||
|
// Seed of the certification act: 3 seeds a minima, 30 open articles (two with a variant).
|
||||||
|
let mut modules = vec![m(SEED, 0, false); 3];
|
||||||
|
modules.extend(vec![m(None, 1, false); 28]);
|
||||||
|
modules.extend(vec![m(None, 2, false); 2]);
|
||||||
|
assert_eq!(pending(&modules), Pending { to_vote: 33, without_my_vote: 33 });
|
||||||
|
modules[0].voted = true;
|
||||||
|
modules[31].voted = true;
|
||||||
|
assert_eq!(pending(&modules), Pending { to_vote: 33, without_my_vote: 31 });
|
||||||
|
// Once adopted, an article leaves both counts, voted or not.
|
||||||
|
modules[1].in_force = ADOPTED;
|
||||||
|
modules[31].in_force = ADOPTED;
|
||||||
|
assert_eq!(pending(&modules), Pending { to_vote: 31, without_my_vote: 30 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_to_vote_in_an_empty_document() {
|
||||||
|
assert_eq!(pending(&[]), Pending::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -237,7 +237,7 @@ fn safe_return(raw: Option<&str>) -> String {
|
|||||||
{
|
{
|
||||||
p.to_owned()
|
p.to_owned()
|
||||||
}
|
}
|
||||||
_ => "/groupes".to_owned(),
|
_ => "/".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,12 +577,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn return_paths_stay_local() {
|
fn return_paths_stay_local() {
|
||||||
assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre");
|
assert_eq!(safe_return(Some("/g/monnaie-libre")), "/g/monnaie-libre");
|
||||||
assert_eq!(safe_return(Some("//evil.example")), "/groupes");
|
assert_eq!(safe_return(Some("//evil.example")), "/");
|
||||||
assert_eq!(safe_return(Some("https://evil.example")), "/groupes");
|
assert_eq!(safe_return(Some("https://evil.example")), "/");
|
||||||
assert_eq!(safe_return(Some("/\\evil")), "/groupes");
|
assert_eq!(safe_return(Some("/\\evil")), "/");
|
||||||
assert_eq!(safe_return(Some("/\t/evil.example")), "/groupes");
|
assert_eq!(safe_return(Some("/\t/evil.example")), "/");
|
||||||
assert_eq!(safe_return(Some("/\n/evil.example")), "/groupes");
|
assert_eq!(safe_return(Some("/\n/evil.example")), "/");
|
||||||
assert_eq!(safe_return(None), "/groupes");
|
assert_eq!(safe_return(None), "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod auth;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod docs;
|
pub mod docs;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod overview;
|
||||||
pub mod proposals;
|
pub mod proposals;
|
||||||
pub mod publish;
|
pub mod publish;
|
||||||
pub mod seed;
|
pub mod seed;
|
||||||
@@ -42,6 +43,8 @@ pub struct AppState {
|
|||||||
pub http: reqwest::Client,
|
pub http: reqwest::Client,
|
||||||
/// Wakes the publisher as soon as an adoption is recorded.
|
/// Wakes the publisher as soon as an adoption is recorded.
|
||||||
pub publish_wake: Arc<Notify>,
|
pub publish_wake: Arc<Notify>,
|
||||||
|
/// What is coming in the tools of each group (`seeds/upcoming/`), read at start.
|
||||||
|
pub upcoming: Arc<overview::Upcoming>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -50,7 +53,8 @@ impl AppState {
|
|||||||
.timeout(Duration::from_secs(15))
|
.timeout(Duration::from_secs(15))
|
||||||
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
|
.user_agent(concat!("libreDecision/", env!("CARGO_PKG_VERSION")))
|
||||||
.build()?;
|
.build()?;
|
||||||
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()) })
|
let upcoming = Arc::new(overview::Upcoming::load(&cfg.seed_dir).await?);
|
||||||
|
Ok(Self { db, cfg: Arc::new(cfg), http, publish_wake: Arc::new(Notify::new()), upcoming })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +108,8 @@ pub fn api_router() -> Router<AppState> {
|
|||||||
.route("/auth/logout", post(auth::logout))
|
.route("/auth/logout", post(auth::logout))
|
||||||
.route("/me", get(auth::me))
|
.route("/me", get(auth::me))
|
||||||
.route("/groups", get(docs::groups))
|
.route("/groups", get(docs::groups))
|
||||||
.route("/groups/{slug}", get(docs::group))
|
.route("/groups/{slug}", get(overview::group))
|
||||||
|
.route("/groups/{slug}/decisions", get(overview::decisions))
|
||||||
.route("/docs/{id}", get(docs::document))
|
.route("/docs/{id}", get(docs::document))
|
||||||
.route("/docs/{id}/preview", get(docs::preview))
|
.route("/docs/{id}/preview", get(docs::preview))
|
||||||
.route("/docs/{id}/assist", post(proposals::assist))
|
.route("/docs/{id}/assist", post(proposals::assist))
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
//! A group at a glance: what is coming in each tool, and what is left to vote.
|
||||||
|
//!
|
||||||
|
//! What is coming is data, not code: `seeds/upcoming/<group>.json`, one file
|
||||||
|
//! per group, read once at start (titles in fr, en and es). It is served with
|
||||||
|
//! the group (`GET /api/groups/{slug}`, field `upcoming`), only to those who
|
||||||
|
//! open it. What is left to vote (`GET /api/groups/{slug}/decisions`) is one
|
||||||
|
//! line per document, counted by `ld_engine::pending`.
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
use crate::auth::User;
|
||||||
|
use crate::docs;
|
||||||
|
use crate::error::AppResult;
|
||||||
|
use crate::util::{self, LangMap};
|
||||||
|
use anyhow::{Context, bail};
|
||||||
|
use axum::Json;
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use ld_engine::pending::{Module, pending};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
|
||||||
|
/// Something announced in a tool of a group, not open yet.
|
||||||
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct UpcomingItem {
|
||||||
|
pub key: String,
|
||||||
|
pub title: LangMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What is coming in the tools of one group.
|
||||||
|
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct GroupUpcoming {
|
||||||
|
#[serde(default)]
|
||||||
|
pub docs: Vec<UpcomingItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mandats: Vec<UpcomingItem>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub protocoles: Vec<UpcomingItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupUpcoming {
|
||||||
|
fn check(&self) -> anyhow::Result<()> {
|
||||||
|
for (tool, items) in [("docs", &self.docs), ("mandats", &self.mandats), ("protocoles", &self.protocoles)] {
|
||||||
|
let mut keys = HashSet::new();
|
||||||
|
for item in items {
|
||||||
|
if item.key.trim().is_empty() || !keys.insert(item.key.as_str()) {
|
||||||
|
bail!("{tool}: empty or repeated key « {} »", item.key);
|
||||||
|
}
|
||||||
|
for lang in util::LANGS {
|
||||||
|
if item.title.get(lang).is_none_or(|t| t.trim().is_empty()) {
|
||||||
|
bail!("{tool}/{}: no title in {lang}", item.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every group's announcements, by group slug.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Upcoming(BTreeMap<String, GroupUpcoming>);
|
||||||
|
|
||||||
|
impl Upcoming {
|
||||||
|
/// Reads `<seed_dir>/upcoming/*.json`; no directory, nothing coming. A file
|
||||||
|
/// that does not read, or lacks a language, stops the start.
|
||||||
|
pub async fn load(seed_dir: &std::path::Path) -> anyhow::Result<Self> {
|
||||||
|
let dir = seed_dir.join("upcoming");
|
||||||
|
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||||
|
Err(e) => return Err(e).context(dir.display().to_string()),
|
||||||
|
};
|
||||||
|
let mut out = BTreeMap::new();
|
||||||
|
while let Some(entry) = entries.next_entry().await? {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().is_none_or(|x| x != "json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(group) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||||
|
let raw = tokio::fs::read_to_string(&path).await?;
|
||||||
|
let parsed: GroupUpcoming = serde_json::from_str(&raw).with_context(|| path.display().to_string())?;
|
||||||
|
parsed.check().with_context(|| path.display().to_string())?;
|
||||||
|
out.insert(group, parsed);
|
||||||
|
}
|
||||||
|
Ok(Self(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn of(&self, group: &str) -> GroupUpcoming {
|
||||||
|
self.0.get(group).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The group, its tools and documents (`docs::group`), plus what is coming.
|
||||||
|
pub async fn group(State(state): State<AppState>, user: User, Path(slug): Path<String>) -> AppResult<Json<Value>> {
|
||||||
|
let Json(mut view) = docs::group(State(state.clone()), user, Path(slug.clone())).await?;
|
||||||
|
let open = view["group"]["open"].as_bool().unwrap_or(false);
|
||||||
|
let upcoming = if open { state.upcoming.of(&slug) } else { GroupUpcoming::default() };
|
||||||
|
view["upcoming"] = serde_json::to_value(upcoming).unwrap_or(Value::Null);
|
||||||
|
Ok(Json(view))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// « Décisions »: one line per document in progress, with its modules left to vote.
|
||||||
|
pub async fn decisions(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
User(user): User,
|
||||||
|
Path(slug): Path<String>,
|
||||||
|
) -> AppResult<Json<Value>> {
|
||||||
|
let group = docs::load_group(&state, &slug).await?;
|
||||||
|
docs::require_open(&state, &user, &group).await?;
|
||||||
|
let can_vote = docs::may_vote(&state, &user, &group).await?;
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct DocRow {
|
||||||
|
id: String,
|
||||||
|
slug: String,
|
||||||
|
title: String,
|
||||||
|
}
|
||||||
|
let rows =
|
||||||
|
sqlx::query_as::<_, DocRow>("SELECT id, slug, title FROM documents WHERE group_slug = ? ORDER BY created_at")
|
||||||
|
.bind(&slug)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut out = Vec::with_capacity(rows.len());
|
||||||
|
for d in rows {
|
||||||
|
let p = pending(&modules(&state, &d.id, &user.id).await?);
|
||||||
|
out.push(json!({
|
||||||
|
"id": d.id, "slug": d.slug, "title": util::parse_lang(&d.title),
|
||||||
|
"toVote": p.to_vote,
|
||||||
|
"withoutMyVote": can_vote.then_some(p.without_my_vote),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Ok(Json(json!({ "canVote": can_vote, "docs": out })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The articles of a document as `me` sees them: its version in force (adopted
|
||||||
|
/// by a vote, or a seed a minima), its open versions, and whether `me` holds a
|
||||||
|
/// valid vote on one of them. A change of inertia is not a version of the text.
|
||||||
|
async fn modules(state: &AppState, doc_id: &str, me: &str) -> AppResult<Vec<Module>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct VersionRow {
|
||||||
|
unit_id: String,
|
||||||
|
in_force: bool,
|
||||||
|
adopted: bool,
|
||||||
|
voted: bool,
|
||||||
|
}
|
||||||
|
let rows = sqlx::query_as::<_, VersionRow>(
|
||||||
|
"SELECT u.id AS unit_id,
|
||||||
|
IFNULL(p.id = u.in_force_id, 0) AS in_force,
|
||||||
|
(p.entered_at IS NOT NULL OR p.confirmed_at IS NOT NULL OR p.origin <> 'seed') AS adopted,
|
||||||
|
EXISTS (SELECT 1 FROM votes v
|
||||||
|
WHERE v.proposal_id = p.id AND v.identity_id = ? AND v.revision = p.revision) AS voted
|
||||||
|
FROM units u JOIN proposals p ON p.unit_id = u.id
|
||||||
|
WHERE u.doc_id = ? AND p.kind <> 'inertia' AND (p.id = u.in_force_id OR p.status = 'open')
|
||||||
|
ORDER BY u.position, u.id",
|
||||||
|
)
|
||||||
|
.bind(me)
|
||||||
|
.bind(doc_id)
|
||||||
|
.fetch_all(&state.db)
|
||||||
|
.await?;
|
||||||
|
let mut out: Vec<Module> = Vec::new();
|
||||||
|
let mut current: Option<String> = None;
|
||||||
|
for r in rows {
|
||||||
|
if current.as_deref() != Some(r.unit_id.as_str()) {
|
||||||
|
out.push(Module::default());
|
||||||
|
current = Some(r.unit_id);
|
||||||
|
}
|
||||||
|
if let Some(m) = out.last_mut() {
|
||||||
|
if r.in_force {
|
||||||
|
m.in_force = Some(r.adopted);
|
||||||
|
} else {
|
||||||
|
m.open += 1;
|
||||||
|
}
|
||||||
|
m.voted |= r.voted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::Config;
|
||||||
|
|
||||||
|
async fn load(files: &[(&str, &str)]) -> anyhow::Result<Upcoming> {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ld-upcoming-{}", util::id()));
|
||||||
|
tokio::fs::create_dir_all(dir.join("upcoming")).await?;
|
||||||
|
for (name, body) in files {
|
||||||
|
tokio::fs::write(dir.join("upcoming").join(name), body).await?;
|
||||||
|
}
|
||||||
|
let out = Upcoming::load(&dir).await;
|
||||||
|
let _ = tokio::fs::remove_dir_all(&dir).await;
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
const ITEM: &str = r#"{"key": "k", "title": {"fr": "Un", "en": "One", "es": "Uno"}}"#;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_file_per_group_and_nothing_elsewhere() {
|
||||||
|
let up =
|
||||||
|
load(&[("axiom.json", &format!(r#"{{"mandats": [{ITEM}]}}"#)), ("notes.txt", "x")]).await.expect("load");
|
||||||
|
assert_eq!(up.of("axiom").mandats.len(), 1);
|
||||||
|
assert!(up.of("axiom").docs.is_empty());
|
||||||
|
assert!(up.of("librodrome").mandats.is_empty());
|
||||||
|
let none = Upcoming::load(&std::env::temp_dir().join("ld-no-such-dir")).await.expect("load");
|
||||||
|
assert!(none.of("axiom").docs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_missing_language_an_unknown_tool_or_a_repeated_key_stops_the_start() {
|
||||||
|
let no_es = r#"{"docs": [{"key": "k", "title": {"fr": "Un", "en": "One"}}]}"#;
|
||||||
|
assert!(load(&[("axiom.json", no_es)]).await.is_err());
|
||||||
|
assert!(load(&[("axiom.json", &format!(r#"{{"outils": [{ITEM}]}}"#))]).await.is_err());
|
||||||
|
assert!(load(&[("axiom.json", &format!(r#"{{"docs": [{ITEM}, {ITEM}]}}"#))]).await.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_seeds_of_the_repository_read() {
|
||||||
|
let up = Upcoming::load(&Config::for_tests().seed_dir).await.expect("load");
|
||||||
|
assert_eq!(up.of("monnaie-libre").mandats.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
//! A group at a glance: what is coming in each tool, and what is left to vote.
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode, header};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, open_db, seed};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
struct Client {
|
||||||
|
router: axum::Router,
|
||||||
|
cookie: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Client {
|
||||||
|
async fn new() -> Self {
|
||||||
|
let db = open_db("sqlite::memory:").await.expect("db");
|
||||||
|
let state = AppState::new(Config::for_tests(), db).await.expect("state");
|
||||||
|
seed::ensure(&state).await.expect("seed");
|
||||||
|
Self { router: app(state), cookie: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn other(&self) -> Self {
|
||||||
|
Self { router: self.router.clone(), cookie: None }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
||||||
|
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
||||||
|
if let Some(c) = &self.cookie {
|
||||||
|
req = req.header(header::COOKIE, c);
|
||||||
|
}
|
||||||
|
let req = match body {
|
||||||
|
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
||||||
|
None => req.body(Body::empty()),
|
||||||
|
}
|
||||||
|
.expect("request");
|
||||||
|
let res = self.router.clone().oneshot(req).await.expect("response");
|
||||||
|
let status = res.status();
|
||||||
|
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
||||||
|
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
||||||
|
if raw.starts_with("ld_session=") {
|
||||||
|
self.cookie = Some(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
||||||
|
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&mut self, uri: &str) -> Value {
|
||||||
|
let (s, v) = self.call("GET", uri, None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{uri}: {v}");
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn login(&mut self, name: &str, admin: bool) {
|
||||||
|
let (s, v) = self.call("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An invitation identity that opens only `groups`.
|
||||||
|
async fn invited(&mut self, admin: &mut Client, groups: &[&str]) {
|
||||||
|
let (s, v) =
|
||||||
|
admin.call("POST", "/api/access/invitations", Some(json!({ "label": "Invitée", "groups": groups }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
let token = v["link"].as_str().expect("link").split('#').nth(1).expect("token").to_owned();
|
||||||
|
let (s, v) = self.call("POST", "/api/auth/invitation", Some(json!({ "token": token }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn vote(&mut self, proposal: &str, choice: &str) -> Value {
|
||||||
|
let (s, v) =
|
||||||
|
self.call("POST", &format!("/api/proposals/{proposal}/vote"), Some(json!({ "choice": choice }))).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn titles(items: &Value, lang: &str) -> Vec<String> {
|
||||||
|
items.as_array().expect("items").iter().map(|i| i["title"][lang].as_str().expect("title").to_owned()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn the_doc(c: &mut Client, group: &str) -> Value {
|
||||||
|
let v = c.get(&format!("/api/groups/{group}")).await;
|
||||||
|
let id = v["docs"][0]["id"].as_str().expect("doc").to_owned();
|
||||||
|
c.get(&format!("/api/docs/{id}")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version(doc: &Value, code: &str, which: &str) -> String {
|
||||||
|
let unit = doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit");
|
||||||
|
let v = if which == "inForce" { &unit["inForce"] } else { &unit["proposals"][which.parse::<usize>().expect("n")] };
|
||||||
|
v["id"].as_str().expect("id").to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decisions(c: &mut Client, group: &str) -> Value {
|
||||||
|
c.get(&format!("/api/groups/{group}/decisions")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn what_is_coming_is_served_with_the_group_in_three_languages() {
|
||||||
|
let mut c = Client::new().await;
|
||||||
|
c.login("Chiara", false).await;
|
||||||
|
let g = c.get("/api/groups/monnaie-libre").await;
|
||||||
|
let up = &g["upcoming"];
|
||||||
|
assert_eq!(titles(&up["docs"], "fr"), ["Acte d'engagement forgerons"]);
|
||||||
|
assert_eq!(titles(&up["mandats"], "fr"), ["Administration forum Duniter", "Administration forum Monnaie libre"]);
|
||||||
|
assert_eq!(titles(&up["protocoles"], "fr"), ["Runtime upgrade — décision on-chain"]);
|
||||||
|
for tool in ["docs", "mandats", "protocoles"] {
|
||||||
|
for lang in ["en", "es"] {
|
||||||
|
assert!(titles(&up[tool], lang).iter().all(|t| !t.is_empty()), "{tool} {lang}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The docs in progress are still there, and only Monnaie libre has something coming.
|
||||||
|
assert_eq!(g["docs"].as_array().expect("docs").len(), 1);
|
||||||
|
let essai = c.get("/api/groups/essai").await;
|
||||||
|
for tool in ["docs", "mandats", "protocoles"] {
|
||||||
|
assert_eq!(essai["upcoming"][tool], json!([]), "{tool}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_group_one_does_not_open_shows_nothing_of_what_is_coming() {
|
||||||
|
let mut admin = Client::new().await;
|
||||||
|
admin.login("Yvv", true).await;
|
||||||
|
let mut guest = admin.other();
|
||||||
|
guest.invited(&mut admin, &["essai"]).await;
|
||||||
|
let g = guest.get("/api/groups/axiom").await;
|
||||||
|
assert_eq!(g["group"]["open"], false);
|
||||||
|
assert_eq!(g["docs"], json!([]));
|
||||||
|
for tool in ["docs", "mandats", "protocoles"] {
|
||||||
|
assert_eq!(g["upcoming"][tool], json!([]), "{tool}");
|
||||||
|
}
|
||||||
|
let (s, _) = guest.call("GET", "/api/groups/axiom/decisions", None).await;
|
||||||
|
assert_eq!(s, StatusCode::FORBIDDEN);
|
||||||
|
let (s, _) = guest.other().call("GET", "/api/groups/monnaie-libre/decisions", None).await;
|
||||||
|
assert_eq!(s, StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn decisions_one_line_per_doc_with_the_modules_left_to_vote() {
|
||||||
|
let mut c = Client::new().await;
|
||||||
|
c.login("Chiara", false).await;
|
||||||
|
let d = decisions(&mut c, "monnaie-libre").await;
|
||||||
|
assert_eq!(d["canVote"], true);
|
||||||
|
let docs = d["docs"].as_array().expect("docs");
|
||||||
|
assert_eq!(docs.len(), 1);
|
||||||
|
assert_eq!(docs[0]["slug"], "acte-engagement-certification");
|
||||||
|
assert_eq!(docs[0]["title"]["fr"], "Acte d'engagement Certification");
|
||||||
|
// 3 seeds a minima + 30 open articles, the two variants counting once.
|
||||||
|
assert_eq!(docs[0]["toVote"], 33);
|
||||||
|
assert_eq!(docs[0]["withoutMyVote"], 33);
|
||||||
|
|
||||||
|
let doc = the_doc(&mut c, "monnaie-libre").await;
|
||||||
|
c.vote(&version(&doc, "E3", "0"), "for").await;
|
||||||
|
c.vote(&version(&doc, "E3", "1"), "against").await;
|
||||||
|
c.vote(&version(&doc, "I1", "inForce"), "for").await;
|
||||||
|
let d = decisions(&mut c, "monnaie-libre").await;
|
||||||
|
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(33), Some(31)));
|
||||||
|
// Someone else has not voted yet.
|
||||||
|
let mut yvv = c.other();
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
assert_eq!(decisions(&mut yvv, "monnaie-libre").await["docs"][0]["withoutMyVote"], 33);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_adopted_module_leaves_the_count_and_a_vote_to_reconfirm_does_not_count() {
|
||||||
|
let mut yvv = Client::new().await;
|
||||||
|
yvv.login("Yvv", false).await;
|
||||||
|
let mut chiara = yvv.other();
|
||||||
|
chiara.login("Chiara", false).await;
|
||||||
|
// Dev identities are members of every group: W(essai) = 2, C = 1; two « for » adopt.
|
||||||
|
let doc = the_doc(&mut yvv, "essai").await;
|
||||||
|
let e3 = version(&doc, "E3", "0");
|
||||||
|
yvv.vote(&e3, "for").await;
|
||||||
|
let v = chiara.vote(&e3, "for").await;
|
||||||
|
assert_eq!(v["entered"][0]["code"], "E3");
|
||||||
|
let d = decisions(&mut yvv, "essai").await;
|
||||||
|
assert_eq!((d["docs"][0]["toVote"].as_u64(), d["docs"][0]["withoutMyVote"].as_u64()), (Some(32), Some(32)));
|
||||||
|
|
||||||
|
// A new article by Chiara: one more module to vote.
|
||||||
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
||||||
|
let (s, v) = chiara
|
||||||
|
.call(
|
||||||
|
"POST",
|
||||||
|
&format!("/api/docs/{doc_id}/proposals"),
|
||||||
|
Some(json!({
|
||||||
|
"title": "Joignabilité", "body": "Je m'engage à rester joignable par mes certifieurs.", "submit": true,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
let added = v["id"].as_str().expect("id").to_owned();
|
||||||
|
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["toVote"], 33);
|
||||||
|
yvv.vote(&added, "against").await;
|
||||||
|
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 32);
|
||||||
|
// Chiara changes its meaning: Yvv's vote is to reconfirm, it no longer counts as his vote.
|
||||||
|
let (s, v) = chiara
|
||||||
|
.call(
|
||||||
|
"PATCH",
|
||||||
|
&format!("/api/proposals/{added}"),
|
||||||
|
Some(json!({
|
||||||
|
"title": "Joignabilité", "body": "Je m'engage à répondre à mes certifieurs sous huit jours.",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{v}");
|
||||||
|
assert_eq!(v["edit"], "substantive");
|
||||||
|
assert_eq!(decisions(&mut yvv, "essai").await["docs"][0]["withoutMyVote"], 33);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn where_one_does_not_vote_there_is_no_count_of_ones_own() {
|
||||||
|
let mut admin = Client::new().await;
|
||||||
|
admin.login("Yvv", true).await;
|
||||||
|
let mut guest = admin.other();
|
||||||
|
guest.invited(&mut admin, &["monnaie-libre", "essai"]).await;
|
||||||
|
let d = decisions(&mut guest, "monnaie-libre").await;
|
||||||
|
assert_eq!(d["canVote"], false);
|
||||||
|
assert_eq!(d["docs"][0]["toVote"], 33);
|
||||||
|
assert!(d["docs"][0]["withoutMyVote"].is_null());
|
||||||
|
// In the test group, the same person votes.
|
||||||
|
let d = decisions(&mut guest, "essai").await;
|
||||||
|
assert_eq!(d["canVote"], true);
|
||||||
|
assert_eq!(d["docs"][0]["withoutMyVote"], 33);
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
|
|||||||
|
|
||||||
| # | Contrôle | Geste humain | Attendu |
|
| # | Contrôle | Geste humain | Attendu |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| C1 | L'accueil | ouvrir `/` sans être connectée | « Rendre possible la décision collective », la phrase d'outils, les quatre sections ; le menu à gauche avec les quatre outils, trois grisés ; « Se connecter avec sa clé Ğ1 » |
|
| C1 | L'accueil, sans session | ouvrir `/` sans être connectée | « Rendre possible la décision collective », puis le leitmotiv en trois lignes : « contexte et cadrage », « processus et modalités », « boîte à outils » ; « Se connecter avec sa clé Ğ1 » et sa note ; pas de piliers ni de groupes ; le menu à gauche avec les quatre outils, trois éteints |
|
||||||
| C2 | Les groupes | se connecter, ouvrir « Groupes » | Monnaie libre, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal |
|
| C2 | L'accueil, avec session | se connecter : on arrive sur `/` | le titre, le leitmotiv, puis directement le choix du groupe : Monnaie libre en grand, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal ; `/groupes` et « Mes groupes » (menu) ramènent ici |
|
||||||
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (30 lignes, une par article) |
|
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (30 lignes, une par article) |
|
||||||
| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie ; l'URL garde le panneau ; Échap le ferme |
|
| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie ; l'URL garde le panneau ; Échap le ferme |
|
||||||
| C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles |
|
| C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles |
|
||||||
@@ -21,9 +21,14 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
|
|||||||
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
|
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
|
||||||
| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là |
|
| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là |
|
||||||
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
|
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
|
||||||
| C13 | La Raison d'être | Monnaie libre → Acte d'engagement Certification, puis EN, puis ES | en tête du texte en vigueur, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » |
|
| M1 | Mobile | téléphone (ou fenêtre étroite) | le logo dans la barre du haut ; l'accueil montre les groupes ; menu en tiroir (le nombre à côté de « Docs de référence », les quatre pastilles d'ambiance), les trois gestes visibles sans survol, le panneau s'ouvre en feuille |
|
||||||
| C14 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version ouvre son vote ; un nouveau clic sur la ligne la replie |
|
| C13 | Logo et ambiances | ouvrir `/` (appareil en clair, puis en sombre) ; choisir chaque pastille d'ambiance ; recharger | le logo « Clé de voûte » (signe, *libre*, **Decision**) droit, sans tampon ; le sceau 井 en bas à droite ; première visite : pierre (clair) ou nuit (sombre) ; les quatre pastilles (Pierre, Calcaire, Nuit, Ardoise) changent l'ambiance, qui reste après rechargement ; favicon, icône Apple et manifeste servis |
|
||||||
| M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille |
|
| C14 | La page d'un groupe | Monnaie libre | le nom ; le pavé des quatre piliers (Docs de référence « ouvert », les trois autres « à venir ») ; « En cours » : l'Acte d'engagement Certification, qui ouvre son lecteur |
|
||||||
|
| C15 | Le menu | survoler les outils ; ouvrir Docs, puis Mandats ; revenir à l'accueil | « 1 » à côté de « Docs de référence » ; aucun outil surligné sur la page du groupe, seul celui où l'on est ensuite ; au survol, seul Docs réagit ; Mandats s'ouvre quand même (ses « À venir ») ; hors d'un groupe, les outils mènent au dernier groupe ouvert (ou à l'accueil s'il n'y en a pas) |
|
||||||
|
| C16 | En cours, à venir | Monnaie libre → Docs, Mandats, Protocoles ; les anciennes adresses `/decisions`, `/mandats`, `/protocoles` | Docs : « En cours » (Acte d'engagement Certification) et « À venir » (Acte d'engagement forgerons) ; Mandats : Administration forum Duniter, Administration forum Monnaie libre ; Protocoles : Runtime upgrade — décision on-chain ; les « À venir » sont éteints, sans lien ; ils suivent la langue ; les anciennes adresses mènent aux outils du groupe courant |
|
||||||
|
| C17 | Décisions | Monnaie libre → Décisions ; voter sur une version d'un article, recharger | une ligne par doc : « Acte d'engagement Certification · 33 modules à voter · dont 33 sans ton vote », puis « dont 32 » ; la ligne ouvre le lecteur |
|
||||||
|
| C18 | La Raison d'être | Monnaie libre → Acte d'engagement Certification, puis EN, puis ES | en tête du texte en vigueur, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » |
|
||||||
|
| C19 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version ouvre son vote ; un nouveau clic sur la ligne la replie |
|
||||||
|
|
||||||
## Au-delà de la liste
|
## Au-delà de la liste
|
||||||
|
|
||||||
@@ -32,7 +37,7 @@ Ce que l'automate ne voit pas et que Chiara regarde :
|
|||||||
- **la lecture** : le texte est-il beau, aéré, sans bruit ? lit-on un article sans effort ?
|
- **la lecture** : le texte est-il beau, aéré, sans bruit ? lit-on un article sans effort ?
|
||||||
- **la fatigue** : un geste de trop, une étape inutile, un mot qui arrête ;
|
- **la fatigue** : un geste de trop, une étape inutile, un mot qui arrête ;
|
||||||
- **le vocabulaire** : un libellé qui parle du système au lieu de parler de toi ou du groupe ;
|
- **le vocabulaire** : un libellé qui parle du système au lieu de parler de toi ou du groupe ;
|
||||||
- **les deux ambiances** (pierre / nuit) et le clavier seul (Tab, Entrée, Échap) ;
|
- **les quatre ambiances** (pierre, calcaire, nuit, ardoise) et le clavier seul (Tab, Entrée, Échap) ;
|
||||||
- **les langues** : une traduction maladroite, une chaîne restée en français.
|
- **les langues** : une traduction maladroite, une chaîne restée en français.
|
||||||
|
|
||||||
Chaque remarque passe par « Faire un retour » (menu de gauche) : elle arrive datée, avec la
|
Chaque remarque passe par « Faire un retour » (menu de gauche) : elle arrive datée, avec la
|
||||||
@@ -50,3 +55,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
|
|||||||
| 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts |
|
| 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts |
|
||||||
| 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts |
|
| 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts |
|
||||||
| 2026-10-30 | automate | local, lot R (Raison d'être, versions d'un article) | C1–C14, M1 | 15 / 15 verts |
|
| 2026-10-30 | automate | local, lot R (Raison d'être, versions d'un article) | C1–C14, M1 | 15 / 15 verts |
|
||||||
|
| 2026-10-30 | automate | local, lot A (entrée, logo, ambiances, menu, listes, Décisions) | C1–C17, M1 | 18 / 18 verts |
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"docs": [
|
||||||
|
{
|
||||||
|
"key": "acte-engagement-forgerons",
|
||||||
|
"title": {
|
||||||
|
"fr": "Acte d'engagement forgerons",
|
||||||
|
"en": "Smith Commitment Act",
|
||||||
|
"es": "Acta de compromiso de forjadores"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mandats": [
|
||||||
|
{
|
||||||
|
"key": "administration-forum-duniter",
|
||||||
|
"title": {
|
||||||
|
"fr": "Administration forum Duniter",
|
||||||
|
"en": "Duniter forum administration",
|
||||||
|
"es": "Administración del foro Duniter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "administration-forum-monnaie-libre",
|
||||||
|
"title": {
|
||||||
|
"fr": "Administration forum Monnaie libre",
|
||||||
|
"en": "Monnaie libre forum administration",
|
||||||
|
"es": "Administración del foro Monnaie libre"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"protocoles": [
|
||||||
|
{
|
||||||
|
"key": "runtime-upgrade",
|
||||||
|
"title": {
|
||||||
|
"fr": "Runtime upgrade — décision on-chain",
|
||||||
|
"en": "Runtime upgrade — on-chain decision",
|
||||||
|
"es": "Runtime upgrade — decisión on-chain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
|
import { DOC, person, signIn } from './helpers';
|
||||||
|
|
||||||
|
const ML = '/g/monnaie-libre';
|
||||||
|
const path = (p: string) => (u: URL) => u.pathname === p;
|
||||||
|
|
||||||
|
async function background(page: Page, selector: string) {
|
||||||
|
return page.locator(selector).evaluate((el) => getComputedStyle(el).backgroundColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('C13 — logo « Clé de voûte » sans tampon, quatre ambiances, icônes', async ({ browser }) => {
|
||||||
|
// First visit: the device's light or dark setting picks pierre or nuit.
|
||||||
|
for (const [scheme, expected] of [['light', 'pierre'], ['dark', 'nuit']] as const) {
|
||||||
|
const ctx = await browser.newContext({ locale: 'fr-FR', colorScheme: scheme });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-mood', expected);
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
const ctx = await browser.newContext({ locale: 'fr-FR', colorScheme: 'light', viewport: { width: 1360, height: 900 } });
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto('/');
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||||||
|
const brand = nav.locator('a.brand');
|
||||||
|
await expect(brand).toHaveAccessibleName('libreDecision');
|
||||||
|
await expect(brand.locator('svg')).toBeVisible();
|
||||||
|
await expect(page.locator('.stamp')).toHaveCount(0);
|
||||||
|
expect(await brand.locator('svg').evaluate((el) => getComputedStyle(el).transform)).toBe('none');
|
||||||
|
// The seal stays where it is.
|
||||||
|
await expect(page.locator('svg.seal')).toBeVisible();
|
||||||
|
const moods = nav.getByRole('radiogroup', { name: 'Ambiance' });
|
||||||
|
for (const [name, mood, cle] of [
|
||||||
|
['Calcaire', 'calcaire', '#a8661e'],
|
||||||
|
['Ardoise', 'ardoise', '#e0a65c'],
|
||||||
|
['Nuit', 'nuit', '#6ec1c6'],
|
||||||
|
['Pierre', 'pierre', '#16808c'],
|
||||||
|
]) {
|
||||||
|
await moods.getByRole('radio', { name }).click();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-mood', mood);
|
||||||
|
await expect(moods.getByRole('radio', { name })).toHaveAttribute('aria-checked', 'true');
|
||||||
|
expect(await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--cle').trim())).toBe(cle);
|
||||||
|
}
|
||||||
|
await moods.getByRole('radio', { name: 'Ardoise' }).click();
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('html')).toHaveAttribute('data-mood', 'ardoise');
|
||||||
|
// Favicon and icons.
|
||||||
|
for (const href of ['/favicon.svg', '/apple-touch-icon.png', '/icon-192.png', '/icon-512.png', '/manifest.webmanifest']) {
|
||||||
|
expect((await page.request.get(href)).status(), href).toBe(200);
|
||||||
|
}
|
||||||
|
await expect(page.locator('link[rel="icon"][type="image/svg+xml"]')).toHaveAttribute('href', /favicon\.svg$/);
|
||||||
|
await expect(page.locator('link[rel="apple-touch-icon"]')).toHaveAttribute('href', /apple-touch-icon\.png$/);
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C14 — la page d’un groupe : les quatre piliers, puis « En cours »', async ({ page }) => {
|
||||||
|
await signIn(page, 'Chiara');
|
||||||
|
await page.goto(ML);
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Monnaie libre');
|
||||||
|
const pillars = page.locator('.pillars .pillar');
|
||||||
|
await expect(pillars).toHaveCount(4);
|
||||||
|
for (const name of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) {
|
||||||
|
await expect(pillars.filter({ hasText: name })).toHaveCount(1);
|
||||||
|
}
|
||||||
|
await expect(pillars.filter({ hasText: 'Docs de référence' })).toContainText('ouvert');
|
||||||
|
await expect(pillars.filter({ hasText: 'à venir' })).toHaveCount(3);
|
||||||
|
const current = page.locator('section', { has: page.getByRole('heading', { name: 'En cours' }) });
|
||||||
|
await current.getByRole('link', { name: /Acte d'engagement Certification/ }).click();
|
||||||
|
await expect(page).toHaveURL(path(DOC));
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C15 — le menu : le nombre de docs, surligné seulement dedans, seul Docs réagit au survol', async ({ browser }) => {
|
||||||
|
const page = await person(browser, 'Lina');
|
||||||
|
// Nothing remembered yet: the tools lead home.
|
||||||
|
await page.goto('/');
|
||||||
|
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||||||
|
const tool = (name: string) => nav.locator('.tool', { hasText: name });
|
||||||
|
await expect(tool('Docs de référence')).toHaveAttribute('href', '/');
|
||||||
|
await page.goto(ML);
|
||||||
|
await expect(tool('Docs de référence').locator('.count')).toHaveText('1');
|
||||||
|
await expect(tool('Docs de référence')).toHaveAccessibleName('Docs de référence, 1 doc en cours');
|
||||||
|
await expect(nav.locator('.tool[aria-current="page"]')).toHaveCount(0);
|
||||||
|
await expect(nav.locator('.tool.off')).toHaveCount(3);
|
||||||
|
// Hover: Docs reacts, a tool to come does not.
|
||||||
|
const docsBefore = await background(page, 'nav .tool.docs');
|
||||||
|
await tool('Docs de référence').hover();
|
||||||
|
await expect.poll(() => background(page, 'nav .tool.docs')).not.toBe(docsBefore);
|
||||||
|
const mandatesBefore = await background(page, 'nav .tool.mandats');
|
||||||
|
await tool('Mandats').hover();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
expect(await background(page, 'nav .tool.mandats')).toBe(mandatesBefore);
|
||||||
|
// Inside a tool, it alone is highlighted.
|
||||||
|
await tool('Docs de référence').click();
|
||||||
|
await expect(page).toHaveURL(path(`${ML}/docs`));
|
||||||
|
await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Docs de référence/);
|
||||||
|
await page.goto(DOC);
|
||||||
|
await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Docs de référence/);
|
||||||
|
// A tool to come opens its page, which shows what is coming, dimmed.
|
||||||
|
await tool('Mandats').click();
|
||||||
|
await expect(page).toHaveURL(path(`${ML}/mandats`));
|
||||||
|
await expect(nav.locator('.tool[aria-current="page"]')).toHaveText(/Mandats/);
|
||||||
|
// Outside a group, the tools aim at the last group opened.
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(tool('Docs de référence')).toHaveAttribute('href', `${ML}/docs`);
|
||||||
|
await expect(tool('Protocoles')).toHaveAttribute('href', `${ML}/protocoles`);
|
||||||
|
await expect(nav.locator('.tool[aria-current="page"]')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C16 — Docs, Mandats, Protocoles : « En cours » et « À venir », éteints', async ({ browser }) => {
|
||||||
|
const page = await person(browser, 'Noé');
|
||||||
|
// Old addresses, before any group: home.
|
||||||
|
await page.goto('/mandats');
|
||||||
|
await expect(page).toHaveURL(path('/'));
|
||||||
|
await page.goto(`${ML}/docs`);
|
||||||
|
const section = (name: string) => page.locator('section', { has: page.getByRole('heading', { name, exact: true }) });
|
||||||
|
await expect(section('En cours').getByRole('link', { name: /Acte d'engagement Certification/ })).toBeVisible();
|
||||||
|
const soon = section('À venir');
|
||||||
|
await expect(soon.locator('li')).toHaveText(["Acte d'engagement forgerons"]);
|
||||||
|
await expect(soon.getByRole('link')).toHaveCount(0);
|
||||||
|
await page.goto(`${ML}/mandats`);
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Mandats');
|
||||||
|
await expect(section('À venir').locator('li')).toHaveText(['Administration forum Duniter', 'Administration forum Monnaie libre']);
|
||||||
|
await expect(section('À venir').getByRole('link')).toHaveCount(0);
|
||||||
|
await page.goto(`${ML}/protocoles`);
|
||||||
|
await expect(section('À venir').locator('li')).toHaveText(['Runtime upgrade — décision on-chain']);
|
||||||
|
// Old addresses lead to the tools of the current group.
|
||||||
|
for (const tool of ['decisions', 'mandats', 'protocoles']) {
|
||||||
|
await page.goto(`/${tool}`);
|
||||||
|
await expect(page).toHaveURL(path(`${ML}/${tool}`));
|
||||||
|
}
|
||||||
|
// In English and Spanish, what is coming follows.
|
||||||
|
await page.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('button', { name: 'ES' }).click();
|
||||||
|
await page.goto(`${ML}/protocoles`);
|
||||||
|
await expect(page.locator('section.soon li')).toHaveText(['Runtime upgrade — decisión on-chain']);
|
||||||
|
await page.getByRole('navigation', { name: 'Caja de herramientas' }).getByRole('button', { name: 'FR' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans ton vote', async ({ browser }) => {
|
||||||
|
const page = await person(browser, 'Maëlle');
|
||||||
|
await page.goto(`${ML}/decisions`);
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Décisions');
|
||||||
|
const rows = page.locator('ul.lines > li');
|
||||||
|
await expect(rows).toHaveCount(1);
|
||||||
|
await expect(rows.first()).toContainText("Acte d'engagement Certification");
|
||||||
|
await expect(rows.first()).toContainText('33 modules à voter');
|
||||||
|
await expect(rows.first()).toContainText('dont 33 sans ton vote');
|
||||||
|
// A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre »
|
||||||
|
// counts as much, and leaves the previews of C9 unchanged (the server is shared).
|
||||||
|
const group = await (await page.request.get('/api/groups/monnaie-libre')).json();
|
||||||
|
const doc = await (await page.request.get(`/api/docs/${group.docs[0].id}`)).json();
|
||||||
|
const e3 = doc.units.find((u: { code: string }) => u.code === 'E3');
|
||||||
|
const r = await page.request.post(`/api/proposals/${e3.proposals[1].id}/vote`, {
|
||||||
|
data: { choice: 'against' },
|
||||||
|
headers: { 'x-requested-with': 'libredecision' },
|
||||||
|
});
|
||||||
|
expect(r.ok()).toBe(true);
|
||||||
|
await page.reload();
|
||||||
|
await expect(rows.first()).toContainText('33 modules à voter');
|
||||||
|
await expect(rows.first()).toContainText('dont 32 sans ton vote');
|
||||||
|
await rows.first().getByRole('link').click();
|
||||||
|
await expect(page).toHaveURL(path(DOC));
|
||||||
|
});
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { DOC, signIn } from './helpers';
|
import { DOC, signIn } from './helpers';
|
||||||
|
|
||||||
test('M1 — mobile : menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => {
|
test('M1 — mobile : logo dans la barre, menu en tiroir, actions visibles, panneau en feuille', async ({ page }) => {
|
||||||
await signIn(page, 'Chiara');
|
await signIn(page, 'Chiara');
|
||||||
|
await page.goto('/');
|
||||||
|
const brand = page.locator('header.topbar a.brand');
|
||||||
|
await expect(brand).toHaveAccessibleName('libreDecision');
|
||||||
|
await expect(brand.locator('svg')).toBeVisible();
|
||||||
|
await expect(page.locator('a.root')).toContainText('Monnaie libre');
|
||||||
await page.goto(DOC);
|
await page.goto(DOC);
|
||||||
await page.getByRole('button', { name: 'Menu' }).click();
|
await page.getByRole('button', { name: 'Menu' }).click();
|
||||||
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
|
const drawer = page.getByRole('dialog', { name: 'Boîte à outils' });
|
||||||
await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible();
|
await expect(drawer.locator('.tool', { hasText: 'Protocoles' })).toBeVisible();
|
||||||
|
await expect(drawer.locator('.tool', { hasText: 'Docs de référence' }).locator('.count')).toHaveText('1');
|
||||||
|
await expect(drawer.getByRole('radiogroup', { name: 'Ambiance' }).getByRole('radio')).toHaveCount(4);
|
||||||
await drawer.getByRole('button', { name: 'Fermer' }).first().click();
|
await drawer.getByRole('button', { name: 'Fermer' }).first().click();
|
||||||
const first = page.locator('section.text article.unit').first();
|
const first = page.locator('section.text article.unit').first();
|
||||||
await first.getByRole('button', { name: 'Le vote' }).click();
|
await first.getByRole('button', { name: 'Le vote' }).click();
|
||||||
|
|||||||
@@ -1,25 +1,37 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import { DOC, person, signIn, TEST_DOC } from './helpers';
|
import { DOC, person, signIn, TEST_DOC } from './helpers';
|
||||||
|
|
||||||
test('C1 — l’accueil : l’intro, les quatre sections, le menu complet', async ({ page }) => {
|
test('C1 — l’accueil sans session : le titre, le leitmotiv en trois lignes, la connexion, le menu complet', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective');
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective');
|
||||||
await expect(page.getByText('en fonction du contexte, processus et modalités, boîte à outils')).toBeVisible();
|
await expect(page.locator('.leitmotiv li')).toHaveText(['contexte et cadrage', 'processus et modalités', 'boîte à outils']);
|
||||||
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||||||
for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) {
|
for (const item of ['Décisions', 'Docs de référence', 'Mandats', 'Protocoles']) {
|
||||||
await expect(nav.locator('.tool', { hasText: item })).toBeVisible();
|
await expect(nav.locator('.tool', { hasText: item })).toBeVisible();
|
||||||
}
|
}
|
||||||
await expect(nav.locator('.tool.disabled')).toHaveCount(3);
|
await expect(nav.locator('.tool.off')).toHaveCount(3);
|
||||||
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toBeVisible();
|
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toBeVisible();
|
||||||
|
// The four pillars live on the page of a group, no longer here.
|
||||||
|
await expect(page.locator('.pillars')).toHaveCount(0);
|
||||||
|
await expect(page.getByText('Monnaie libre', { exact: true })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('C2 — les groupes de la même clé ; pas l’Atelier du canal', async ({ page }) => {
|
test('C2 — l’accueil avec session : le choix du groupe, ceux de la même clé ; pas l’Atelier du canal', async ({ page }) => {
|
||||||
await signIn(page, 'Chiara');
|
await signIn(page, 'Chiara');
|
||||||
await page.goto('/groupes');
|
await page.goto('/');
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Rendre possible la décision collective');
|
||||||
|
await expect(page.locator('a.root')).toContainText('Monnaie libre');
|
||||||
for (const g of ['Monnaie libre', 'Axiom', 'Librodrome', 'Groupe local Vallée Drôme Crestois']) {
|
for (const g of ['Monnaie libre', 'Axiom', 'Librodrome', 'Groupe local Vallée Drôme Crestois']) {
|
||||||
await expect(page.getByText(g, { exact: true })).toBeVisible();
|
await expect(page.getByText(g, { exact: true })).toBeVisible();
|
||||||
}
|
}
|
||||||
await expect(page.getByText('Atelier du canal')).toHaveCount(0);
|
await expect(page.getByText('Atelier du canal')).toHaveCount(0);
|
||||||
|
await expect(page.getByRole('link', { name: 'Se connecter avec sa clé Ğ1' })).toHaveCount(0);
|
||||||
|
// The old address and « Mes groupes » lead here.
|
||||||
|
await page.goto('/groupes');
|
||||||
|
await expect(page).toHaveURL((u) => u.pathname === '/');
|
||||||
|
await page.goto('/g/monnaie-libre');
|
||||||
|
await page.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Mes groupes' }).click();
|
||||||
|
await expect(page).toHaveURL((u) => u.pathname === '/');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a minima', async ({ page }) => {
|
test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a minima', async ({ page }) => {
|
||||||
@@ -146,7 +158,7 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de
|
|||||||
const ctx = await browser.newContext({ locale: 'fr-FR' });
|
const ctx = await browser.newContext({ locale: 'fr-FR' });
|
||||||
const invited = await ctx.newPage();
|
const invited = await ctx.newPage();
|
||||||
await invited.goto(`/invitation#${link.split('#')[1]}`);
|
await invited.goto(`/invitation#${link.split('#')[1]}`);
|
||||||
await expect(invited).toHaveURL(/\/groupes$/);
|
await expect(invited).toHaveURL((u) => u.pathname === '/');
|
||||||
await invited.goto(DOC);
|
await invited.goto(DOC);
|
||||||
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
||||||
const panel = invited.locator('aside.panel');
|
const panel = invited.locator('aside.panel');
|
||||||
@@ -174,7 +186,7 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident
|
|||||||
await expect(page.getByText('« Équipe de test » t’ouvre', { exact: false })).toBeVisible();
|
await expect(page.getByText('« Équipe de test » t’ouvre', { exact: false })).toBeVisible();
|
||||||
await page.getByLabel('Ton nom').fill(name);
|
await page.getByLabel('Ton nom').fill(name);
|
||||||
await page.getByRole('button', { name: 'Rejoindre' }).click();
|
await page.getByRole('button', { name: 'Rejoindre' }).click();
|
||||||
await expect(page).toHaveURL(/\/groupes$/);
|
await expect(page).toHaveURL((u) => u.pathname === '/');
|
||||||
await ctx.close();
|
await ctx.close();
|
||||||
}
|
}
|
||||||
// A shared browser: the person signed in steps aside, the next one gives a name.
|
// A shared browser: the person signed in steps aside, the next one gives a name.
|
||||||
@@ -190,7 +202,7 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident
|
|||||||
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
|
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('C13 — la Raison d’être ouvre le texte en vigueur, en trois langues', async ({ page }) => {
|
test('C18 — la Raison d’être ouvre le texte en vigueur, en trois langues', async ({ page }) => {
|
||||||
await signIn(page, 'Chiara');
|
await signIn(page, 'Chiara');
|
||||||
await page.goto(DOC);
|
await page.goto(DOC);
|
||||||
const first = page.locator('section.text article.unit').first();
|
const first = page.locator('section.text article.unit').first();
|
||||||
@@ -207,7 +219,7 @@ test('C13 — la Raison d’être ouvre le texte en vigueur, en trois langues',
|
|||||||
await expect(first.locator('h2')).toHaveText('Razón de ser');
|
await expect(first.locator('h2')).toHaveText('Razón de ser');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('C14 — un article, une ligne : ses versions se déplient', async ({ page }) => {
|
test('C19 — un article, une ligne : ses versions se déplient', async ({ page }) => {
|
||||||
await signIn(page, 'Chiara');
|
await signIn(page, 'Chiara');
|
||||||
await page.goto(DOC);
|
await page.goto(DOC);
|
||||||
const e3 = page.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' });
|
const e3 = page.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' });
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/* libreDecision v3 — tokens and base.
|
/* libreDecision v3 — tokens and base.
|
||||||
Two moods: « pierre » (cool stone, light) and « nuit » (well at night, dark).
|
Four moods, two light and two dark: « pierre » (cool stone) and « nuit » (well at night)
|
||||||
|
carry the water; « calcaire » (warm limestone) and « ardoise » (dark slate) carry the ochre.
|
||||||
|
`--water` is the accent of the mood (links, buttons, current place), whatever its hue;
|
||||||
|
`--cle` is the keystone of the logo (Clé de voûte, DA 2026-10-28).
|
||||||
Borderless: depth comes from background and shadow, never from strokes.
|
Borderless: depth comes from background and shadow, never from strokes.
|
||||||
Radii: cards 16px · inputs 12px · pills and buttons 20px · icon holders 14px. */
|
Radii: cards 16px · inputs 12px · pills and buttons 20px · icon holders 14px. */
|
||||||
|
|
||||||
@@ -23,12 +26,38 @@
|
|||||||
--ochre: #a8661e;
|
--ochre: #a8661e;
|
||||||
--ochre-wash: rgb(168 102 30 / 0.12);
|
--ochre-wash: rgb(168 102 30 / 0.12);
|
||||||
--on-water: #f6f8f7;
|
--on-water: #f6f8f7;
|
||||||
|
--cle: #16808c;
|
||||||
--shadow: 0 1px 2px rgb(20 40 40 / 0.06), 0 8px 28px rgb(20 40 40 / 0.07);
|
--shadow: 0 1px 2px rgb(20 40 40 / 0.06), 0 8px 28px rgb(20 40 40 / 0.07);
|
||||||
--shadow-strong: 0 2px 6px rgb(20 40 40 / 0.1), 0 18px 48px rgb(20 40 40 / 0.16);
|
--shadow-strong: 0 2px 6px rgb(20 40 40 / 0.1), 0 18px 48px rgb(20 40 40 / 0.16);
|
||||||
--veil: rgb(16 30 31 / 0.28);
|
--veil: rgb(16 30 31 / 0.28);
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Calcaire: warm limestone, the ochre. The accent is darker than the keystone
|
||||||
|
(#a8661e, 3.5:1) so that a link or a label reads at 4.5:1 or more; warnings
|
||||||
|
turn to brick, away from the accent. */
|
||||||
|
[data-mood='calcaire'] {
|
||||||
|
--bg: #f2ede4;
|
||||||
|
--surface: #fbf8f2;
|
||||||
|
--surface-2: #e8e0d2;
|
||||||
|
--ink: #2a241e;
|
||||||
|
--ink-soft: #5c5247;
|
||||||
|
--ink-faint: #857868;
|
||||||
|
--water: #8a5214;
|
||||||
|
--water-light: #e6c79c;
|
||||||
|
--water-wash: rgb(138 82 20 / 0.08);
|
||||||
|
--moss: #446b38;
|
||||||
|
--moss-wash: rgb(68 107 56 / 0.12);
|
||||||
|
--ochre: #a3432b;
|
||||||
|
--ochre-wash: rgb(163 67 43 / 0.1);
|
||||||
|
--on-water: #fbf8f2;
|
||||||
|
--cle: #a8661e;
|
||||||
|
--shadow: 0 1px 2px rgb(60 42 20 / 0.06), 0 8px 28px rgb(60 42 20 / 0.07);
|
||||||
|
--shadow-strong: 0 2px 6px rgb(60 42 20 / 0.1), 0 18px 48px rgb(60 42 20 / 0.16);
|
||||||
|
--veil: rgb(42 32 20 / 0.28);
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
[data-mood='nuit'] {
|
[data-mood='nuit'] {
|
||||||
--bg: #0e1819;
|
--bg: #0e1819;
|
||||||
--surface: #142224;
|
--surface: #142224;
|
||||||
@@ -44,12 +73,36 @@
|
|||||||
--ochre: #e0a458;
|
--ochre: #e0a458;
|
||||||
--ochre-wash: rgb(224 164 88 / 0.12);
|
--ochre-wash: rgb(224 164 88 / 0.12);
|
||||||
--on-water: #0e1819;
|
--on-water: #0e1819;
|
||||||
|
--cle: #6ec1c6;
|
||||||
--shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 28px rgb(0 0 0 / 0.28);
|
--shadow: 0 1px 2px rgb(0 0 0 / 0.3), 0 8px 28px rgb(0 0 0 / 0.28);
|
||||||
--shadow-strong: 0 2px 6px rgb(0 0 0 / 0.4), 0 18px 48px rgb(0 0 0 / 0.5);
|
--shadow-strong: 0 2px 6px rgb(0 0 0 / 0.4), 0 18px 48px rgb(0 0 0 / 0.5);
|
||||||
--veil: rgb(0 0 0 / 0.5);
|
--veil: rgb(0 0 0 / 0.5);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ardoise: dark slate, the ochre; warnings in a light brick. */
|
||||||
|
[data-mood='ardoise'] {
|
||||||
|
--bg: #171b20;
|
||||||
|
--surface: #1d2228;
|
||||||
|
--surface-2: #262c34;
|
||||||
|
--ink: #e4e6e2;
|
||||||
|
--ink-soft: #a9afb5;
|
||||||
|
--ink-faint: #7a828b;
|
||||||
|
--water: #e0a65c;
|
||||||
|
--water-light: #6b4c26;
|
||||||
|
--water-wash: rgb(224 166 92 / 0.09);
|
||||||
|
--moss: #93c08a;
|
||||||
|
--moss-wash: rgb(147 192 138 / 0.12);
|
||||||
|
--ochre: #e98d72;
|
||||||
|
--ochre-wash: rgb(233 141 114 / 0.12);
|
||||||
|
--on-water: #171b20;
|
||||||
|
--cle: #e0a65c;
|
||||||
|
--shadow: 0 1px 2px rgb(0 0 0 / 0.32), 0 8px 28px rgb(0 0 0 / 0.3);
|
||||||
|
--shadow-strong: 0 2px 6px rgb(0 0 0 / 0.42), 0 18px 48px rgb(0 0 0 / 0.52);
|
||||||
|
--veil: rgb(0 0 0 / 0.5);
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--font-ui: 'Nunito Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
--font-ui: 'Nunito Variable', system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
--font-text: 'Literata Variable', 'Iowan Old Style', Georgia, serif;
|
--font-text: 'Literata Variable', 'Iowan Old Style', Georgia, serif;
|
||||||
@@ -166,7 +219,7 @@ button {
|
|||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: var(--water);
|
background: var(--water);
|
||||||
color: var(--on-water);
|
color: var(--on-water);
|
||||||
box-shadow: 0 6px 18px rgb(31 111 120 / 0.22);
|
box-shadow: 0 6px 18px color-mix(in srgb, var(--water) 22%, transparent);
|
||||||
}
|
}
|
||||||
.btn.quiet {
|
.btn.quiet {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
@@ -4,15 +4,27 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="theme-color" content="#e9eeec" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon-48.png" type="image/png" sizes="48x48" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon-32.png" type="image/png" sizes="32x32" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon-16.png" type="image/png" sizes="16x16" />
|
||||||
|
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" sizes="180x180" />
|
||||||
|
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
|
||||||
<title>libreDecision</title>
|
<title>libreDecision</title>
|
||||||
<script>
|
<script>
|
||||||
// Mood before first paint: no flash.
|
// Mood before first paint: no flash. Four moods, two light (pierre, calcaire) and two dark
|
||||||
try {
|
// (nuit, ardoise); a first visit follows the device's light or dark setting.
|
||||||
const m = localStorage.getItem('ld:mood');
|
(() => {
|
||||||
const dark = m ? m === 'nuit' : matchMedia('(prefers-color-scheme: dark)').matches;
|
const bg = { pierre: '#e9eeec', calcaire: '#f2ede4', nuit: '#0e1819', ardoise: '#171b20' };
|
||||||
document.documentElement.dataset.mood = dark ? 'nuit' : 'pierre';
|
let mood = 'pierre';
|
||||||
} catch { document.documentElement.dataset.mood = 'pierre'; }
|
try {
|
||||||
|
const saved = localStorage.getItem('ld:mood');
|
||||||
|
mood = Object.hasOwn(bg, saved ?? '') ? saved : matchMedia('(prefers-color-scheme: dark)').matches ? 'nuit' : 'pierre';
|
||||||
|
} catch {}
|
||||||
|
document.documentElement.dataset.mood = mood;
|
||||||
|
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg[mood]);
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,59 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The documents in progress of a group, each a way into its reader.
|
||||||
|
import type { GroupView } from '$lib/group';
|
||||||
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
|
import type { LangMap } from '$lib/types';
|
||||||
|
|
||||||
|
let { group, docs, summary = true }: { group: string; docs: GroupView['docs']; summary?: boolean } = $props();
|
||||||
|
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if docs.length}
|
||||||
|
<ul class="docs">
|
||||||
|
{#each docs as d (d.id)}
|
||||||
|
<li>
|
||||||
|
<a class="doc card" href="/g/{group}/docs/{d.slug}">
|
||||||
|
<span class="title">{pick(d.title)}</span>
|
||||||
|
{#if summary}<span class="summary">{pick(d.summary)}</span>{/if}
|
||||||
|
<span class="meta faint">{i18n.plural('docs.inForce', d.inForce)} · {i18n.plural('docs.open', d.open)}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="muted">{t('group.current.none')}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.docs {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.doc {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 1.35rem 1.5rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.doc:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The choice of a group: Monnaie libre first and large, then the groups that use the same
|
||||||
|
// key, then the test groups; a group one does not open shows its lock.
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import Guard from '$lib/components/Guard.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
import { Remote } from '$lib/load.svelte';
|
||||||
|
import type { GroupSummary } from '$lib/types';
|
||||||
|
|
||||||
|
const data = new Remote(() => api.get<{ groups: GroupSummary[] }>('/groups'));
|
||||||
|
onMount(() => void data.load());
|
||||||
|
|
||||||
|
const all = $derived(data.value?.groups ?? []);
|
||||||
|
const root = $derived(all.find((g) => g.slug === 'monnaie-libre'));
|
||||||
|
const sameKey = $derived(all.filter((g) => g.parent === 'monnaie-libre'));
|
||||||
|
const others = $derived(all.filter((g) => g.slug !== 'monnaie-libre' && g.parent !== 'monnaie-libre'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="choice" aria-labelledby="groups-title">
|
||||||
|
<h2 class="eyebrow" id="groups-title">{t('groups.title')}</h2>
|
||||||
|
|
||||||
|
{#if data.error}
|
||||||
|
<Guard code={data.error} onretry={() => data.load()} />
|
||||||
|
{:else if data.value}
|
||||||
|
{#if root}
|
||||||
|
<a class="root card" class:locked={!root.open} href={root.open ? `/g/${root.slug}` : undefined}>
|
||||||
|
<span class="eyebrow">Ğ1</span>
|
||||||
|
<span class="name">{root.name}</span>
|
||||||
|
{#if root.open}<span class="go" aria-hidden="true"><Icon name="chevron" size={20} /></span>{/if}
|
||||||
|
{#if !root.open}<span class="lock"><Icon name="lock" size={16} />{t('groups.locked')}</span>{/if}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if sameKey.length}
|
||||||
|
<h3 class="eyebrow sub">{t('groups.same.key')}</h3>
|
||||||
|
<ul class="list">
|
||||||
|
{#each sameKey as g (g.slug)}
|
||||||
|
<li>
|
||||||
|
{#if g.open}
|
||||||
|
<a class="row card" href="/g/{g.slug}"><span class="name">{g.name}</span><Icon name="chevron" size={16} /></a>
|
||||||
|
{:else}
|
||||||
|
<div class="row card locked" title={t('groups.locked.long')}>
|
||||||
|
<span class="name">{g.name}</span>
|
||||||
|
<span class="lock"><Icon name="lock" size={15} />{t('groups.locked')}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{#if sameKey.some((g) => !g.open)}<p class="faint note">{t('groups.locked.long')}</p>{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if others.length}
|
||||||
|
<h3 class="eyebrow sub">{t('groups.test')}</h3>
|
||||||
|
<ul class="list">
|
||||||
|
{#each others as g (g.slug)}
|
||||||
|
<li>
|
||||||
|
<a class="row card" href={g.open ? `/g/${g.slug}` : undefined} class:locked={!g.open}>
|
||||||
|
<span class="name">{g.name}</span><Icon name={g.open ? 'chevron' : 'lock'} size={16} />
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !all.length}<p class="muted">{t('groups.empty')}</p>{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.choice {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.choice > .eyebrow {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.root {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 1.6rem 1.6rem 1.7rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
a.root[href]:hover,
|
||||||
|
a.row[href]:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
.root .name {
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-size: clamp(1.7rem, 1.4rem + 1.2vw, 2.2rem);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.go {
|
||||||
|
position: absolute;
|
||||||
|
right: 1.4rem;
|
||||||
|
top: 50%;
|
||||||
|
translate: 0 -50%;
|
||||||
|
color: var(--water);
|
||||||
|
}
|
||||||
|
.sub {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.row .name {
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.locked {
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: inset 0 0 0 1px var(--surface-2);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.lock {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The logotype « Clé de voûte » (DA, validated by Yvv on 2026-10-28): the sign — stones in
|
||||||
|
// the ink of the mood, the keystone in `--cle` — then *libre* in Literata italic, the colour
|
||||||
|
// of the stones, and **Decision** in roman, the colour of the keystone. Never tilted.
|
||||||
|
// Proportions of the DA's lockup (prod_cle.py), for a font size S: the stones run from 0.98 S
|
||||||
|
// above the baseline to 0.06 S below it, then a gap of 0.3 S. The SVG keeps the DA's 3-unit
|
||||||
|
// margin around 59 units of stone height, hence the sizes below.
|
||||||
|
import sign from '$lib/assets/cle-de-voute.svg?raw';
|
||||||
|
|
||||||
|
let { size = 1.45 }: { size?: number } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="logo" style:--s="{size}rem">
|
||||||
|
<span class="sign" aria-hidden="true">{@html sign}</span><span class="word"><span class="libre">libre</span><span class="decision">Decision</span></span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.logo {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: var(--s);
|
||||||
|
line-height: 1.2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sign {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--ink);
|
||||||
|
/* 1.04 S of stones, plus the 3-unit margin above and below (65 / 59); the bottom of the
|
||||||
|
stones 0.06 S under the baseline. */
|
||||||
|
height: calc(1.04em * 65 / 59);
|
||||||
|
margin-right: calc(0.3em - 1.04em * 3 / 59);
|
||||||
|
vertical-align: calc(-0.06em - 1.04em * 3 / 59);
|
||||||
|
}
|
||||||
|
.sign :global(svg) {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.word {
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-variation-settings: 'opsz' 72;
|
||||||
|
}
|
||||||
|
.libre {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 420;
|
||||||
|
letter-spacing: -0.004em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.decision {
|
||||||
|
font-weight: 620;
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
margin-left: 0.02em;
|
||||||
|
color: var(--cle);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The four pillars of a group: name, one sentence, « ouvert » or « à venir ». Each leads to its
|
||||||
|
// tool; the open one answers the pointer, the others stay dim, like in the menu.
|
||||||
|
import { TOOLS, type Tool } from '$lib/group';
|
||||||
|
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||||
|
|
||||||
|
let { group, tools }: { group: string; tools: { key: string; active: boolean }[] } = $props();
|
||||||
|
|
||||||
|
const NAMES: Record<Tool, Key> = { decisions: 'nav.decisions', docs: 'nav.docs', mandats: 'nav.mandates', protocoles: 'nav.protocols' };
|
||||||
|
const TEXTS: Record<Tool, Key> = {
|
||||||
|
decisions: 'pillar.decisions',
|
||||||
|
docs: 'pillar.docs',
|
||||||
|
mandats: 'pillar.mandates',
|
||||||
|
protocoles: 'pillar.protocols',
|
||||||
|
};
|
||||||
|
const active = $derived(new Set(tools.filter((x) => x.active).map((x) => x.key)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ul class="pillars">
|
||||||
|
{#each TOOLS as tool (tool)}
|
||||||
|
{@const open = active.has(tool)}
|
||||||
|
<li>
|
||||||
|
<a class="pillar" class:open href="/g/{group}/{tool}">
|
||||||
|
<span class="head">
|
||||||
|
<h3>{t(NAMES[tool])}</h3>
|
||||||
|
<span class="tag" class:water={open}>{open ? t('pillar.open') : t('pillar.soon')}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text">{t(TEXTS[tool])}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pillars {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
.pillar {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-content: start;
|
||||||
|
padding: 1.4rem 1.5rem 1.5rem;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.pillar.open {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.pillar.open:hover {
|
||||||
|
background: color-mix(in srgb, var(--water) 5%, var(--surface));
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.08rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.open h3 {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
font-size: 0.96rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pillars {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,61 +1,70 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
import { TOOLS, type Tool } from '$lib/group';
|
||||||
import { mood } from '$lib/mood.svelte';
|
import { groups, toolHref } from '$lib/groups.svelte';
|
||||||
|
import { i18n, LANGS, t, type Key } from '$lib/i18n/index.svelte';
|
||||||
|
import { MOODS, mood } from '$lib/mood.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import Icon from './Icon.svelte';
|
import Icon from './Icon.svelte';
|
||||||
import Stamp from './Stamp.svelte';
|
import Logo from './Logo.svelte';
|
||||||
|
|
||||||
let { onfeedback, onnavigate }: { onfeedback: () => void; onnavigate?: () => void } = $props();
|
let { onfeedback, onnavigate }: { onfeedback: () => void; onnavigate?: () => void } = $props();
|
||||||
|
|
||||||
const group = $derived(page.params.group ?? null);
|
const LABELS: Record<Tool, Key> = {
|
||||||
const docsHref = $derived(
|
decisions: 'nav.decisions',
|
||||||
group ? `/g/${group}/docs` : session.me ? '/groupes' : '/connexion?return=/groupes',
|
docs: 'nav.docs',
|
||||||
);
|
mandats: 'nav.mandates',
|
||||||
|
protocoles: 'nav.protocols',
|
||||||
|
};
|
||||||
const path = $derived(page.url.pathname);
|
const path = $derived(page.url.pathname);
|
||||||
const tools = $derived([
|
/** The group of the page, if any. */
|
||||||
{ key: 'decisions', label: t('nav.decisions'), href: null },
|
const here = $derived(page.params.group ?? null);
|
||||||
{ key: 'docs', label: t('nav.docs'), href: docsHref },
|
/** Whose tools the menu shows: the group of the page, or else the last one opened. */
|
||||||
{ key: 'mandats', label: t('nav.mandates'), href: null },
|
const target = $derived(here ?? (session.me ? groups.last : null));
|
||||||
{ key: 'protocoles', label: t('nav.protocols'), href: null },
|
$effect(() => {
|
||||||
]);
|
if (session.me && target) groups.ensure(target);
|
||||||
|
});
|
||||||
|
const view = $derived(target ? groups.views[target] : undefined);
|
||||||
|
/** Documents in progress in that group, next to « Docs de référence ». */
|
||||||
|
const count = $derived(view?.group.open ? view.docs.length : null);
|
||||||
|
/** A tool is highlighted only when one is in it. */
|
||||||
|
const inTool = (tool: Tool) => here !== null && (path === `/g/${here}/${tool}` || path.startsWith(`/g/${here}/${tool}/`));
|
||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await session.logout();
|
await session.logout();
|
||||||
|
groups.reset();
|
||||||
onnavigate?.();
|
onnavigate?.();
|
||||||
await goto('/');
|
await goto('/');
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="sidebar" aria-label={t('nav.label')}>
|
<nav class="sidebar" aria-label={t('nav.label')}>
|
||||||
<a class="brand" href="/" onclick={() => onnavigate?.()}>
|
<a class="brand" href="/" onclick={() => onnavigate?.()}><Logo /></a>
|
||||||
<Stamp />
|
|
||||||
<span class="word">libre<b>Decision</b></span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{#if session.me}
|
{#if session.me}
|
||||||
<a class="groups" href="/groupes" class:current={path === '/groupes'} onclick={() => onnavigate?.()}>
|
<a class="groups" href="/" class:current={path === '/'} aria-current={path === '/' ? 'page' : undefined} onclick={() => onnavigate?.()}>
|
||||||
<span class="eyebrow">{t('nav.groups')}</span>
|
<span class="eyebrow">{t('nav.groups')}</span>
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ul class="tools">
|
<ul class="tools">
|
||||||
{#each tools as tool (tool.key)}
|
{#each TOOLS as tool (tool)}
|
||||||
|
{@const on = inTool(tool)}
|
||||||
|
{@const counted = tool === 'docs' && count !== null}
|
||||||
<li>
|
<li>
|
||||||
{#if tool.href}
|
<a
|
||||||
<a
|
href={toolHref(tool, target)}
|
||||||
href={tool.href}
|
class="tool {tool}"
|
||||||
class="tool"
|
class:off={tool !== 'docs'}
|
||||||
class:current={path.includes('/docs')}
|
class:current={on}
|
||||||
aria-current={path.includes('/docs') ? 'page' : undefined}
|
aria-current={on ? 'page' : undefined}
|
||||||
onclick={() => onnavigate?.()}>{tool.label}</a
|
aria-label={counted ? i18n.plural('nav.docs.count', count ?? 0, { label: t(LABELS[tool]) }) : undefined}
|
||||||
>
|
onclick={() => onnavigate?.()}
|
||||||
{:else}
|
>
|
||||||
<span class="tool disabled" aria-disabled="true" title={t('group.tool.soon')}>
|
<span>{t(LABELS[tool])}</span>
|
||||||
{tool.label}<span class="soon">{t('nav.soon')}</span>
|
{#if counted}<span class="count" aria-hidden="true">{count}</span>{/if}
|
||||||
</span>
|
</a>
|
||||||
{/if}
|
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -76,15 +85,21 @@
|
|||||||
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
|
<button type="button" class:on={i18n.lang === l} aria-pressed={i18n.lang === l} onclick={() => i18n.set(l)}>{l.toUpperCase()}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="moods" role="radiogroup" aria-label={t('nav.mood')}>
|
||||||
class="icon-btn"
|
{#each MOODS as m (m)}
|
||||||
type="button"
|
<!-- Each swatch carries its own mood: its colours are that mood's tokens. -->
|
||||||
onclick={() => mood.toggle()}
|
<button
|
||||||
aria-label={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
|
type="button"
|
||||||
title={mood.current === 'nuit' ? t('nav.mood.toStone') : t('nav.mood.toNight')}
|
role="radio"
|
||||||
>
|
class="swatch"
|
||||||
<Icon name={mood.current === 'nuit' ? 'sun' : 'moon'} size={17} />
|
data-mood={m}
|
||||||
</button>
|
aria-checked={mood.current === m}
|
||||||
|
aria-label={t(`mood.${m}`)}
|
||||||
|
title={t(`mood.${m}`)}
|
||||||
|
onclick={() => mood.set(m)}
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if session.me}
|
{#if session.me}
|
||||||
@@ -109,17 +124,8 @@
|
|||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.7rem;
|
|
||||||
color: var(--ink);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.2rem 0.4rem;
|
padding: 0.2rem 0.4rem;
|
||||||
}
|
text-decoration: none;
|
||||||
.word {
|
|
||||||
font-size: 1.08rem;
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
.word b {
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
}
|
||||||
.groups {
|
.groups {
|
||||||
padding: 0 0.6rem;
|
padding: 0 0.6rem;
|
||||||
@@ -135,35 +141,47 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
}
|
}
|
||||||
|
/* The four tools look alike; only « Docs de référence » is lit and answers the pointer.
|
||||||
|
The three others stay dim, without any hover effect, and still lead to their page. */
|
||||||
.tool {
|
.tool {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.55rem 0.7rem;
|
min-height: 2.5rem;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
border-radius: var(--r-input);
|
border-radius: var(--r-input);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background-color 0.15s ease;
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
}
|
}
|
||||||
a.tool:hover {
|
.tool.off {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.tool.docs:hover,
|
||||||
|
.tool.current {
|
||||||
background: var(--water-wash);
|
background: var(--water-wash);
|
||||||
}
|
}
|
||||||
a.tool.current {
|
.tool.docs.current {
|
||||||
background: var(--water-wash);
|
|
||||||
color: var(--water);
|
color: var(--water);
|
||||||
}
|
}
|
||||||
.tool.disabled {
|
.tool.off.current {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-soft);
|
||||||
font-weight: 600;
|
|
||||||
cursor: default;
|
|
||||||
}
|
}
|
||||||
.soon {
|
.count {
|
||||||
font-size: 0.7rem;
|
min-width: 1.6rem;
|
||||||
font-weight: 700;
|
padding: 0.05rem 0.45rem;
|
||||||
letter-spacing: 0.04em;
|
border-radius: var(--r-pill);
|
||||||
color: var(--ink-faint);
|
background: var(--surface-2);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tool.current .count {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--water);
|
||||||
}
|
}
|
||||||
.foot {
|
.foot {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
@@ -197,6 +215,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 0.4rem;
|
||||||
padding: 0.2rem 0.3rem;
|
padding: 0.2rem 0.3rem;
|
||||||
}
|
}
|
||||||
.langs {
|
.langs {
|
||||||
@@ -209,7 +228,7 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border-radius: var(--r-pill);
|
border-radius: var(--r-pill);
|
||||||
min-width: 2.25rem;
|
min-width: 2.05rem;
|
||||||
min-height: 2rem;
|
min-height: 2rem;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -220,19 +239,33 @@
|
|||||||
color: var(--water);
|
color: var(--water);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
.icon-btn {
|
.moods {
|
||||||
display: grid;
|
display: inline-flex;
|
||||||
place-items: center;
|
align-items: center;
|
||||||
width: 2.25rem;
|
gap: 0.3rem;
|
||||||
height: 2.25rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--r-icon);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
}
|
||||||
.icon-btn:hover {
|
.swatch {
|
||||||
background: var(--water-wash);
|
position: relative;
|
||||||
color: var(--ink);
|
width: 1.3rem;
|
||||||
|
height: 1.3rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
/* var(--bg), var(--ink) and var(--cle) are those of the swatch's own mood. */
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--ink) 32%, transparent);
|
||||||
|
}
|
||||||
|
.swatch::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 32%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--cle);
|
||||||
|
}
|
||||||
|
.swatch[aria-checked='true'] {
|
||||||
|
/* currentColor is the ink of the page, inherited. */
|
||||||
|
outline: 2px solid currentColor;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
.who {
|
.who {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// « À venir »: what is announced in a tool of the group — dim, and not a way in yet.
|
||||||
|
import type { UpcomingItem } from '$lib/group';
|
||||||
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
|
import type { LangMap } from '$lib/types';
|
||||||
|
|
||||||
|
let { items }: { items: UpcomingItem[] } = $props();
|
||||||
|
const id = $props.id();
|
||||||
|
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="soon" aria-labelledby="{id}-soon">
|
||||||
|
<h2 class="eyebrow" id="{id}-soon">{t('group.upcoming')}</h2>
|
||||||
|
{#if items.length}
|
||||||
|
<ul>
|
||||||
|
{#each items as item (item.key)}
|
||||||
|
<li>{pick(item.title)}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="faint">{t('group.upcoming.none')}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.soon {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
padding: 1.05rem 1.5rem;
|
||||||
|
border-radius: var(--r-card);
|
||||||
|
background: color-mix(in srgb, var(--surface) 45%, transparent);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
// The logo stamp: the six lines of hexagram 48 (The Well), small, tilted.
|
|
||||||
let { size = 2.2 }: { size?: number } = $props();
|
|
||||||
const lines: boolean[] = [false, true, true, false, true, false];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span class="stamp" style:--s="{size}rem" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 112 105">
|
|
||||||
{#each lines as yang, i (i)}
|
|
||||||
{@const y = 91 - i * 17}
|
|
||||||
{#if yang}<rect x="6" {y} width="100" height="9" />{:else}<rect x="6" {y} width="42" height="9" /><rect x="64" {y} width="42" height="9" />{/if}
|
|
||||||
{/each}
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.stamp {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
flex: none;
|
|
||||||
width: var(--s);
|
|
||||||
height: var(--s);
|
|
||||||
border-radius: var(--r-icon);
|
|
||||||
background: var(--water-wash);
|
|
||||||
transform: rotate(-10deg);
|
|
||||||
transition: transform 0.2s var(--ease);
|
|
||||||
}
|
|
||||||
svg {
|
|
||||||
width: 56%;
|
|
||||||
fill: var(--water);
|
|
||||||
}
|
|
||||||
:global(a:hover) > .stamp {
|
|
||||||
transform: rotate(-2deg) scale(1.06);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// The page of a tool not open yet in a group (Mandats, Protocoles): what it is for, and what is
|
||||||
|
// announced there, dim.
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Guard from '$lib/components/Guard.svelte';
|
||||||
|
import SoonList from '$lib/components/SoonList.svelte';
|
||||||
|
import { groups } from '$lib/groups.svelte';
|
||||||
|
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||||
|
import { Remote } from '$lib/load.svelte';
|
||||||
|
|
||||||
|
let { tool }: { tool: 'mandats' | 'protocoles' } = $props();
|
||||||
|
const NAMES: Record<typeof tool, Key> = { mandats: 'nav.mandates', protocoles: 'nav.protocols' };
|
||||||
|
const TEXTS: Record<typeof tool, Key> = { mandats: 'pillar.mandates', protocoles: 'pillar.protocols' };
|
||||||
|
|
||||||
|
const slug = $derived(page.params.group ?? '');
|
||||||
|
const data = new Remote(() => groups.load(slug));
|
||||||
|
$effect(() => {
|
||||||
|
void slug;
|
||||||
|
void data.load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if data.error}
|
||||||
|
<Guard code={data.error} onretry={() => data.load()} />
|
||||||
|
{:else if data.value}
|
||||||
|
{@const g = data.value.group}
|
||||||
|
<div class="page">
|
||||||
|
<a class="eyebrow back" href="/g/{g.slug}">{g.name}</a>
|
||||||
|
<h1>{t(NAMES[tool])}</h1>
|
||||||
|
{#if !g.open}
|
||||||
|
<p class="muted">{t('groups.locked.long')}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="lead">{t(TEXTS[tool])}</p>
|
||||||
|
<SoonList items={data.value.upcoming[tool]} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
max-width: 44ch;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 1.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,24 @@
|
|||||||
import type { LangMap } from './types';
|
import type { LangMap } from './types';
|
||||||
|
|
||||||
|
/** The tools of a group, in the order of the menu. */
|
||||||
|
export const TOOLS = ['decisions', 'docs', 'mandats', 'protocoles'] as const;
|
||||||
|
export type Tool = (typeof TOOLS)[number];
|
||||||
|
|
||||||
|
/** Announced in a tool, not open yet (`seeds/upcoming/<group>.json` on the server). */
|
||||||
|
export interface UpcomingItem {
|
||||||
|
key: string;
|
||||||
|
title: LangMap;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GroupView {
|
export interface GroupView {
|
||||||
group: { slug: string; name: string; parent: string | null; policy: string; open: boolean };
|
group: { slug: string; name: string; parent: string | null; policy: string; open: boolean };
|
||||||
tools: { key: string; active: boolean }[];
|
tools: { key: string; active: boolean }[];
|
||||||
docs: { id: string; slug: string; title: LangMap; summary: LangMap; inForce: number; open: number }[];
|
docs: { id: string; slug: string; title: LangMap; summary: LangMap; inForce: number; open: number }[];
|
||||||
|
upcoming: { docs: UpcomingItem[]; mandats: UpcomingItem[]; protocoles: UpcomingItem[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** « Décisions »: one line per document in progress. */
|
||||||
|
export interface DecisionsView {
|
||||||
|
canVote: boolean;
|
||||||
|
docs: { id: string; slug: string; title: LangMap; toVote: number; withoutMyVote: number | null }[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// The groups as the menu and the pages share them: the last group opened on this
|
||||||
|
// device (outside a group, the tools of the menu aim at it), and the views
|
||||||
|
// already read (the menu counts the documents of the current group).
|
||||||
|
import { api, ApiError } from './api';
|
||||||
|
import type { GroupView, Tool } from './group';
|
||||||
|
|
||||||
|
const KEY = 'ld:group';
|
||||||
|
|
||||||
|
/** The last group opened, read straight from storage (usable before any mount). */
|
||||||
|
export function lastGroup(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a tool leads: its page in the group, or home when no group is known. */
|
||||||
|
export function toolHref(tool: Tool, group: string | null): string {
|
||||||
|
return group ? `/g/${group}/${tool}` : '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
class Groups {
|
||||||
|
last = $state<string | null>(null);
|
||||||
|
views = $state<Record<string, GroupView>>({});
|
||||||
|
private asked = new Set<string>();
|
||||||
|
private running = new Map<string, Promise<GroupView>>();
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.last = lastGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a group (a read already under way is shared); one that opens becomes the last group. */
|
||||||
|
load(slug: string): Promise<GroupView> {
|
||||||
|
const running = this.running.get(slug);
|
||||||
|
if (running) return running;
|
||||||
|
this.asked.add(slug);
|
||||||
|
const p: Promise<GroupView> = this.fetch(slug).finally(() => {
|
||||||
|
if (this.running.get(slug) === p) this.running.delete(slug);
|
||||||
|
});
|
||||||
|
this.running.set(slug, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(slug: string): Promise<GroupView> {
|
||||||
|
try {
|
||||||
|
const v = await api.get<GroupView>(`/groups/${slug}`);
|
||||||
|
this.views[slug] = v;
|
||||||
|
if (v.group.open) this.remember(slug);
|
||||||
|
else if (this.last === slug) this.forget();
|
||||||
|
return v;
|
||||||
|
} catch (e) {
|
||||||
|
// A group that no longer shows or opens is no longer the last group; a network hiccup keeps it.
|
||||||
|
if (this.last === slug && e instanceof ApiError && (e.code === 'not_found' || e.code === 'forbidden')) this.forget();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a group once, for the menu; errors stay silent there. */
|
||||||
|
ensure(slug: string) {
|
||||||
|
if (this.views[slug] || this.asked.has(slug)) return;
|
||||||
|
this.load(slug).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
remember(slug: string) {
|
||||||
|
this.last = slug;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, slug);
|
||||||
|
} catch {
|
||||||
|
/* no storage: the menu simply leads home */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On sign-out, or when the last group no longer opens. */
|
||||||
|
forget() {
|
||||||
|
this.last = null;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(KEY);
|
||||||
|
} catch {
|
||||||
|
/* no storage */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** On sign-out: nothing read under the previous identity is kept. */
|
||||||
|
reset() {
|
||||||
|
this.forget();
|
||||||
|
this.views = {};
|
||||||
|
this.asked.clear();
|
||||||
|
this.running.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const groups = new Groups();
|
||||||
@@ -10,30 +10,29 @@ export const en: Messages = {
|
|||||||
'nav.docs': 'Reference documents',
|
'nav.docs': 'Reference documents',
|
||||||
'nav.mandates': 'Mandates',
|
'nav.mandates': 'Mandates',
|
||||||
'nav.protocols': 'Protocols',
|
'nav.protocols': 'Protocols',
|
||||||
'nav.soon': 'coming soon',
|
|
||||||
'nav.groups': 'My groups',
|
'nav.groups': 'My groups',
|
||||||
|
'nav.docs.count.one': '{label}, {n} document in progress',
|
||||||
|
'nav.docs.count.other': '{label}, {n} documents in progress',
|
||||||
'nav.access': 'Access',
|
'nav.access': 'Access',
|
||||||
'nav.access.hint': 'trustinWallet',
|
'nav.access.hint': 'trustinWallet',
|
||||||
'nav.feedback': 'Give feedback',
|
'nav.feedback': 'Give feedback',
|
||||||
'nav.signin': 'Sign in',
|
'nav.signin': 'Sign in',
|
||||||
'nav.signout': 'Sign out',
|
'nav.signout': 'Sign out',
|
||||||
'nav.mood.toNight': 'Night mood',
|
'nav.mood': 'Mood',
|
||||||
'nav.mood.toStone': 'Stone mood',
|
'mood.pierre': 'Stone',
|
||||||
|
'mood.calcaire': 'Limestone',
|
||||||
|
'mood.nuit': 'Night',
|
||||||
|
'mood.ardoise': 'Slate',
|
||||||
'nav.lang': 'Language',
|
'nav.lang': 'Language',
|
||||||
'nav.menu': 'Menu',
|
'nav.menu': 'Menu',
|
||||||
'nav.close': 'Close',
|
'nav.close': 'Close',
|
||||||
|
|
||||||
'home.title': 'Making collective decisions possible',
|
'home.title': 'Making collective decisions possible',
|
||||||
'home.subtitle': 'depending on the context, processes and methods, a toolbox',
|
'home.leitmotiv.context': 'context and framing',
|
||||||
'home.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.',
|
'home.leitmotiv.process': 'processes and methods',
|
||||||
'home.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.',
|
'home.leitmotiv.tools': 'a toolbox',
|
||||||
'home.mandates': 'Create and revoke any kind of mandate.',
|
|
||||||
'home.protocols': 'Workflows to organize without a statutory body: rotating roles and technical automations.',
|
|
||||||
'home.open': 'open',
|
|
||||||
'home.soon': 'coming soon',
|
|
||||||
'home.signin': 'Sign in with your Ğ1 key',
|
'home.signin': 'Sign in with your Ğ1 key',
|
||||||
'home.signin.note': 'The same access as the Monnaie libre forum: you sign with Ğecko, no password needed.',
|
'home.signin.note': 'The same access as the Monnaie libre forum: you sign with Ğecko, no password needed.',
|
||||||
'home.continue': 'Open my groups',
|
|
||||||
|
|
||||||
'signin.title': 'Sign in',
|
'signin.title': 'Sign in',
|
||||||
'signin.g1': 'Sign in with Ğ1',
|
'signin.g1': 'Sign in with Ğ1',
|
||||||
@@ -77,8 +76,23 @@ export const en: Messages = {
|
|||||||
'groups.empty': 'No group is visible yet.',
|
'groups.empty': 'No group is visible yet.',
|
||||||
|
|
||||||
'group.tools': 'Tools',
|
'group.tools': 'Tools',
|
||||||
'group.tool.soon': 'This tool is coming later.',
|
'group.current': 'In progress',
|
||||||
'group.docs.empty': 'No reference documents in this group yet.',
|
'group.current.none': 'No document in progress in this group yet.',
|
||||||
|
'group.upcoming': 'Coming up',
|
||||||
|
'group.upcoming.none': 'Nothing is announced here yet.',
|
||||||
|
|
||||||
|
'pillar.decisions': 'A guided path to choose the right decision methods, and to keep the decision log.',
|
||||||
|
'pillar.docs': 'Commitment act, license, charter, constitution: living texts, always open for voting.',
|
||||||
|
'pillar.mandates': 'Create and revoke any kind of mandate.',
|
||||||
|
'pillar.protocols': 'Workflows to organize without a statutory body: rotating roles and technical automations.',
|
||||||
|
'pillar.open': 'open',
|
||||||
|
'pillar.soon': 'coming soon',
|
||||||
|
|
||||||
|
'decisions.note': 'A module is an article of the document. It remains to be voted on until one of its versions has been adopted by a vote.',
|
||||||
|
'decisions.toVote.one': '{n} module to vote on',
|
||||||
|
'decisions.toVote.other': '{n} modules to vote on',
|
||||||
|
'decisions.withoutMyVote.one': 'of which {n} without your vote',
|
||||||
|
'decisions.withoutMyVote.other': 'of which {n} without your vote',
|
||||||
|
|
||||||
'docs.title': 'Reference documents',
|
'docs.title': 'Reference documents',
|
||||||
'docs.inForce.one': '{n} article in force',
|
'docs.inForce.one': '{n} article in force',
|
||||||
@@ -336,8 +350,6 @@ export const en: Messages = {
|
|||||||
'access.publication.queued': 'Publication started again.',
|
'access.publication.queued': 'Publication started again.',
|
||||||
'access.denied': 'Access management is for trustinWallet administrators only.',
|
'access.denied': 'Access management is for trustinWallet administrators only.',
|
||||||
|
|
||||||
'soon.title': 'Coming soon',
|
|
||||||
'soon.body': 'This tool is coming later. Tools open one at a time: reference documents first.',
|
|
||||||
|
|
||||||
'error.unauthenticated': 'Sign in to continue.',
|
'error.unauthenticated': 'Sign in to continue.',
|
||||||
'error.forbidden': "You don't have access to this page.",
|
'error.forbidden': "You don't have access to this page.",
|
||||||
|
|||||||
@@ -10,30 +10,29 @@ export const es: Messages = {
|
|||||||
'nav.docs': 'Documentos de referencia',
|
'nav.docs': 'Documentos de referencia',
|
||||||
'nav.mandates': 'Mandatos',
|
'nav.mandates': 'Mandatos',
|
||||||
'nav.protocols': 'Protocolos',
|
'nav.protocols': 'Protocolos',
|
||||||
'nav.soon': 'próximamente',
|
|
||||||
'nav.groups': 'Mis grupos',
|
'nav.groups': 'Mis grupos',
|
||||||
|
'nav.docs.count.one': '{label}, {n} documento en curso',
|
||||||
|
'nav.docs.count.other': '{label}, {n} documentos en curso',
|
||||||
'nav.access': 'Accesos',
|
'nav.access': 'Accesos',
|
||||||
'nav.access.hint': 'trustinWallet',
|
'nav.access.hint': 'trustinWallet',
|
||||||
'nav.feedback': 'Dejar un comentario',
|
'nav.feedback': 'Dejar un comentario',
|
||||||
'nav.signin': 'Iniciar sesión',
|
'nav.signin': 'Iniciar sesión',
|
||||||
'nav.signout': 'Cerrar sesión',
|
'nav.signout': 'Cerrar sesión',
|
||||||
'nav.mood.toNight': 'Ambiente noche',
|
'nav.mood': 'Ambiente',
|
||||||
'nav.mood.toStone': 'Ambiente piedra',
|
'mood.pierre': 'Piedra',
|
||||||
|
'mood.calcaire': 'Caliza',
|
||||||
|
'mood.nuit': 'Noche',
|
||||||
|
'mood.ardoise': 'Pizarra',
|
||||||
'nav.lang': 'Idioma',
|
'nav.lang': 'Idioma',
|
||||||
'nav.menu': 'Menú',
|
'nav.menu': 'Menú',
|
||||||
'nav.close': 'Cerrar',
|
'nav.close': 'Cerrar',
|
||||||
|
|
||||||
'home.title': 'Hacer posible la decisión colectiva',
|
'home.title': 'Hacer posible la decisión colectiva',
|
||||||
'home.subtitle': 'según el contexto, procesos y modalidades, caja de herramientas',
|
'home.leitmotiv.context': 'contexto y encuadre',
|
||||||
'home.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.',
|
'home.leitmotiv.process': 'procesos y modalidades',
|
||||||
'home.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.',
|
'home.leitmotiv.tools': 'caja de herramientas',
|
||||||
'home.mandates': 'Crear y revocar todo tipo de mandato.',
|
|
||||||
'home.protocols': 'Flujos de trabajo para organizarse sin órgano estatutario: roles rotativos y automatizaciones técnicas.',
|
|
||||||
'home.open': 'abierto',
|
|
||||||
'home.soon': 'próximamente',
|
|
||||||
'home.signin': 'Iniciar sesión con tu clave Ğ1',
|
'home.signin': 'Iniciar sesión con tu clave Ğ1',
|
||||||
'home.signin.note': 'El mismo acceso que el foro Monnaie libre: firmas en Ğecko, sin contraseña.',
|
'home.signin.note': 'El mismo acceso que el foro Monnaie libre: firmas en Ğecko, sin contraseña.',
|
||||||
'home.continue': 'Abrir mis grupos',
|
|
||||||
|
|
||||||
'signin.title': 'Iniciar sesión',
|
'signin.title': 'Iniciar sesión',
|
||||||
'signin.g1': 'Iniciar sesión con Ğ1',
|
'signin.g1': 'Iniciar sesión con Ğ1',
|
||||||
@@ -77,8 +76,23 @@ export const es: Messages = {
|
|||||||
'groups.empty': 'Por ahora no hay ningún grupo visible.',
|
'groups.empty': 'Por ahora no hay ningún grupo visible.',
|
||||||
|
|
||||||
'group.tools': 'Herramientas',
|
'group.tools': 'Herramientas',
|
||||||
'group.tool.soon': 'Esta herramienta llegará más adelante.',
|
'group.current': 'En curso',
|
||||||
'group.docs.empty': 'Por ahora no hay ningún documento de referencia en este grupo.',
|
'group.current.none': 'Por ahora no hay ningún documento en curso en este grupo.',
|
||||||
|
'group.upcoming': 'Próximamente',
|
||||||
|
'group.upcoming.none': 'Por ahora no hay nada anunciado aquí.',
|
||||||
|
|
||||||
|
'pillar.decisions': 'Un recorrido guiado para elegir las modalidades de decisión adecuadas y llevar el registro de las decisiones.',
|
||||||
|
'pillar.docs': 'Acta de compromiso, licencia, carta, constitución: textos vivos, en votación permanente.',
|
||||||
|
'pillar.mandates': 'Crear y revocar todo tipo de mandato.',
|
||||||
|
'pillar.protocols': 'Flujos de trabajo para organizarse sin órgano estatutario: roles rotativos y automatizaciones técnicas.',
|
||||||
|
'pillar.open': 'abierto',
|
||||||
|
'pillar.soon': 'próximamente',
|
||||||
|
|
||||||
|
'decisions.note': 'Un módulo es un artículo del documento. Queda por votar mientras ninguna de sus versiones haya sido adoptada por votación.',
|
||||||
|
'decisions.toVote.one': '{n} módulo por votar',
|
||||||
|
'decisions.toVote.other': '{n} módulos por votar',
|
||||||
|
'decisions.withoutMyVote.one': 'de ellos, {n} sin tu voto',
|
||||||
|
'decisions.withoutMyVote.other': 'de ellos, {n} sin tu voto',
|
||||||
|
|
||||||
'docs.title': 'Documentos de referencia',
|
'docs.title': 'Documentos de referencia',
|
||||||
'docs.inForce.one': '{n} artículo en vigor',
|
'docs.inForce.one': '{n} artículo en vigor',
|
||||||
@@ -336,8 +350,6 @@ export const es: Messages = {
|
|||||||
'access.publication.queued': 'Publicación relanzada.',
|
'access.publication.queued': 'Publicación relanzada.',
|
||||||
'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.',
|
'access.denied': 'La gestión de accesos está reservada a los administradores de trustinWallet.',
|
||||||
|
|
||||||
'soon.title': 'Próximamente',
|
|
||||||
'soon.body': 'Esta herramienta llegará más adelante. Las herramientas se abren una a una: primero, los documentos de referencia.',
|
|
||||||
|
|
||||||
'error.unauthenticated': 'Inicia sesión para continuar.',
|
'error.unauthenticated': 'Inicia sesión para continuar.',
|
||||||
'error.forbidden': 'No tienes acceso a esta página.',
|
'error.forbidden': 'No tienes acceso a esta página.',
|
||||||
|
|||||||
@@ -13,30 +13,29 @@ export const fr = {
|
|||||||
'nav.docs': 'Docs de référence',
|
'nav.docs': 'Docs de référence',
|
||||||
'nav.mandates': 'Mandats',
|
'nav.mandates': 'Mandats',
|
||||||
'nav.protocols': 'Protocoles',
|
'nav.protocols': 'Protocoles',
|
||||||
'nav.soon': 'à venir',
|
|
||||||
'nav.groups': 'Mes groupes',
|
'nav.groups': 'Mes groupes',
|
||||||
|
'nav.docs.count.one': '{label}, {n} doc en cours',
|
||||||
|
'nav.docs.count.other': '{label}, {n} docs en cours',
|
||||||
'nav.access': 'Accès',
|
'nav.access': 'Accès',
|
||||||
'nav.access.hint': 'trustinWallet',
|
'nav.access.hint': 'trustinWallet',
|
||||||
'nav.feedback': 'Faire un retour',
|
'nav.feedback': 'Faire un retour',
|
||||||
'nav.signin': 'Se connecter',
|
'nav.signin': 'Se connecter',
|
||||||
'nav.signout': 'Se déconnecter',
|
'nav.signout': 'Se déconnecter',
|
||||||
'nav.mood.toNight': 'Ambiance nuit',
|
'nav.mood': 'Ambiance',
|
||||||
'nav.mood.toStone': 'Ambiance pierre',
|
'mood.pierre': 'Pierre',
|
||||||
|
'mood.calcaire': 'Calcaire',
|
||||||
|
'mood.nuit': 'Nuit',
|
||||||
|
'mood.ardoise': 'Ardoise',
|
||||||
'nav.lang': 'Langue',
|
'nav.lang': 'Langue',
|
||||||
'nav.menu': 'Menu',
|
'nav.menu': 'Menu',
|
||||||
'nav.close': 'Fermer',
|
'nav.close': 'Fermer',
|
||||||
|
|
||||||
'home.title': 'Rendre possible la décision collective',
|
'home.title': 'Rendre possible la décision collective',
|
||||||
'home.subtitle': 'en fonction du contexte, processus et modalités, boîte à outils',
|
'home.leitmotiv.context': 'contexte et cadrage',
|
||||||
'home.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.",
|
'home.leitmotiv.process': 'processus et modalités',
|
||||||
'home.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.",
|
'home.leitmotiv.tools': 'boîte à outils',
|
||||||
'home.mandates': 'Créer et révoquer tout type de mandat.',
|
|
||||||
'home.protocols': "Des workflows pour s'organiser sans organe statutaire : rôles tournants et automatisations techniques.",
|
|
||||||
'home.open': 'ouvert',
|
|
||||||
'home.soon': 'à venir',
|
|
||||||
'home.signin': 'Se connecter avec sa clé Ğ1',
|
'home.signin': 'Se connecter avec sa clé Ğ1',
|
||||||
'home.signin.note': 'Le même accès que le forum Monnaie libre : tu signes dans Ğecko, sans mot de passe.',
|
'home.signin.note': 'Le même accès que le forum Monnaie libre : tu signes dans Ğecko, sans mot de passe.',
|
||||||
'home.continue': 'Ouvrir mes groupes',
|
|
||||||
|
|
||||||
'signin.title': 'Se connecter',
|
'signin.title': 'Se connecter',
|
||||||
'signin.g1': 'Se connecter avec Ğ1',
|
'signin.g1': 'Se connecter avec Ğ1',
|
||||||
@@ -80,8 +79,23 @@ export const fr = {
|
|||||||
'groups.empty': "Aucun groupe n'est visible pour l'instant.",
|
'groups.empty': "Aucun groupe n'est visible pour l'instant.",
|
||||||
|
|
||||||
'group.tools': 'Outils',
|
'group.tools': 'Outils',
|
||||||
'group.tool.soon': 'Cet outil arrive plus tard.',
|
'group.current': 'En cours',
|
||||||
'group.docs.empty': "Aucun document de référence dans ce groupe pour l'instant.",
|
'group.current.none': "Aucun doc en cours dans ce groupe pour l'instant.",
|
||||||
|
'group.upcoming': 'À venir',
|
||||||
|
'group.upcoming.none': "Rien n'est annoncé ici pour l'instant.",
|
||||||
|
|
||||||
|
'pillar.decisions': "Un parcours assisté pour choisir les bonnes modalités de décision, et tenir le journal des décisions.",
|
||||||
|
'pillar.docs': "Acte d'engagement, licence, charte, constitution : des textes vivants, sous vote permanent.",
|
||||||
|
'pillar.mandates': 'Créer et révoquer tout type de mandat.',
|
||||||
|
'pillar.protocols': "Des workflows pour s'organiser sans organe statutaire : rôles tournants et automatisations techniques.",
|
||||||
|
'pillar.open': 'ouvert',
|
||||||
|
'pillar.soon': 'à venir',
|
||||||
|
|
||||||
|
'decisions.note': "Un module est un article du doc. Il reste à voter tant qu'aucune de ses versions n'a été adoptée par un vote.",
|
||||||
|
'decisions.toVote.one': '{n} module à voter',
|
||||||
|
'decisions.toVote.other': '{n} modules à voter',
|
||||||
|
'decisions.withoutMyVote.one': 'dont {n} sans ton vote',
|
||||||
|
'decisions.withoutMyVote.other': 'dont {n} sans ton vote',
|
||||||
|
|
||||||
'docs.title': 'Docs de référence',
|
'docs.title': 'Docs de référence',
|
||||||
'docs.inForce.one': '{n} article en vigueur',
|
'docs.inForce.one': '{n} article en vigueur',
|
||||||
@@ -339,8 +353,6 @@ export const fr = {
|
|||||||
'access.publication.queued': 'Publication relancée.',
|
'access.publication.queued': 'Publication relancée.',
|
||||||
'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.',
|
'access.denied': 'La gestion des accès est réservée aux administrateurs de trustinWallet.',
|
||||||
|
|
||||||
'soon.title': 'À venir',
|
|
||||||
'soon.body': "Cet outil arrive plus tard. Les chantiers s'ouvrent un par un : d'abord les docs de référence.",
|
|
||||||
|
|
||||||
'error.unauthenticated': 'Connecte-toi pour continuer.',
|
'error.unauthenticated': 'Connecte-toi pour continuer.',
|
||||||
'error.forbidden': "Tu n'as pas accès à cette page.",
|
'error.forbidden': "Tu n'as pas accès à cette page.",
|
||||||
|
|||||||
@@ -1,19 +1,27 @@
|
|||||||
// Two moods, light and dark: « pierre » (stone) and « nuit » (night).
|
// Four moods, two light and two dark: « pierre » and « nuit » (the water),
|
||||||
export type Mood = 'pierre' | 'nuit';
|
// « calcaire » and « ardoise » (the ochre). Set before the first paint by
|
||||||
|
// app.html; a first visit follows the device's light or dark setting.
|
||||||
|
export const MOODS = ['pierre', 'calcaire', 'nuit', 'ardoise'] as const;
|
||||||
|
export type Mood = (typeof MOODS)[number];
|
||||||
|
|
||||||
|
const isMood = (m: string | undefined): m is Mood => MOODS.includes(m as Mood);
|
||||||
|
|
||||||
class MoodState {
|
class MoodState {
|
||||||
current = $state<Mood>('pierre');
|
current = $state<Mood>('pierre');
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
const m = document.documentElement.dataset.mood;
|
const m = document.documentElement.dataset.mood;
|
||||||
this.current = m === 'nuit' ? 'nuit' : 'pierre';
|
this.current = isMood(m) ? m : 'pierre';
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
set(m: Mood) {
|
||||||
this.current = this.current === 'nuit' ? 'pierre' : 'nuit';
|
this.current = m;
|
||||||
document.documentElement.dataset.mood = this.current;
|
const root = document.documentElement;
|
||||||
|
root.dataset.mood = m;
|
||||||
|
const bg = getComputedStyle(root).getPropertyValue('--bg').trim();
|
||||||
|
if (bg) document.querySelector('meta[name="theme-color"]')?.setAttribute('content', bg);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem('ld:mood', this.current);
|
localStorage.setItem('ld:mood', m);
|
||||||
} catch {
|
} catch {
|
||||||
/* private mode: the mood just won't persist */
|
/* private mode: the mood just won't persist */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,10 @@
|
|||||||
import FeedbackDialog from '$lib/components/FeedbackDialog.svelte';
|
import FeedbackDialog from '$lib/components/FeedbackDialog.svelte';
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
import Seal from '$lib/components/Seal.svelte';
|
import Seal from '$lib/components/Seal.svelte';
|
||||||
|
import Logo from '$lib/components/Logo.svelte';
|
||||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
import Stamp from '$lib/components/Stamp.svelte';
|
|
||||||
import Toasts from '$lib/components/Toasts.svelte';
|
import Toasts from '$lib/components/Toasts.svelte';
|
||||||
|
import { groups } from '$lib/groups.svelte';
|
||||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
import { mood } from '$lib/mood.svelte';
|
import { mood } from '$lib/mood.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
onMount(() => {
|
onMount(() => {
|
||||||
i18n.init();
|
i18n.init();
|
||||||
mood.init();
|
mood.init();
|
||||||
|
groups.init();
|
||||||
void session.load();
|
void session.load();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,7 +41,7 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<a class="brand" href="/"><Stamp size={2} /><span>libre<b>Decision</b></span></a>
|
<a class="brand" href="/"><Logo size={1.2} /></a>
|
||||||
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
|
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
|
||||||
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
|
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -151,13 +153,8 @@
|
|||||||
.brand {
|
.brand {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.6rem;
|
padding: 0.15rem 0.1rem;
|
||||||
color: var(--ink);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1.02rem;
|
|
||||||
}
|
|
||||||
.brand b {
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
}
|
||||||
.menu {
|
.menu {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -1,48 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import GroupChoice from '$lib/components/GroupChoice.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
|
|
||||||
const sections = $derived([
|
|
||||||
{ name: t('nav.decisions'), text: t('home.decisions'), open: false },
|
|
||||||
{ name: t('nav.docs'), text: t('home.docs'), open: true },
|
|
||||||
{ name: t('nav.mandates'), text: t('home.mandates'), open: false },
|
|
||||||
{ name: t('nav.protocols'), text: t('home.protocols'), open: false },
|
|
||||||
]);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="home">
|
<div class="home">
|
||||||
<header class="intro">
|
<header class="intro">
|
||||||
<h1>{t('home.title')}</h1>
|
<h1>{t('home.title')}</h1>
|
||||||
<p class="sub">{t('home.subtitle')}</p>
|
<ul class="leitmotiv">
|
||||||
|
<li>{t('home.leitmotiv.context')}</li>
|
||||||
|
<li>{t('home.leitmotiv.process')}</li>
|
||||||
|
<li>{t('home.leitmotiv.tools')}</li>
|
||||||
|
</ul>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul class="sections">
|
{#if session.me}
|
||||||
{#each sections as s (s.name)}
|
<GroupChoice />
|
||||||
<li class="section" class:open={s.open}>
|
{:else}
|
||||||
<div class="head">
|
<div class="enter">
|
||||||
<h2>{s.name}</h2>
|
|
||||||
<span class="tag" class:water={s.open}>{s.open ? t('home.open') : t('home.soon')}</span>
|
|
||||||
</div>
|
|
||||||
<p>{s.text}</p>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="enter">
|
|
||||||
{#if session.me}
|
|
||||||
<a class="btn primary" href="/groupes">{t('home.continue')}</a>
|
|
||||||
{:else}
|
|
||||||
<a class="btn primary" href="/connexion">{t('home.signin')}</a>
|
<a class="btn primary" href="/connexion">{t('home.signin')}</a>
|
||||||
<p class="faint">{t('home.signin.note')}</p>
|
<p class="faint">{t('home.signin.note')}</p>
|
||||||
{/if}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.home {
|
.home {
|
||||||
max-width: 50rem;
|
max-width: 44rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: clamp(3rem, 10vh, 7rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
|
padding: clamp(3rem, 10vh, 6.5rem) clamp(1.2rem, 5vw, 3.5rem) 5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: clamp(2.4rem, 6vh, 3.6rem);
|
||||||
}
|
}
|
||||||
.intro h1 {
|
.intro h1 {
|
||||||
font-family: var(--font-text);
|
font-family: var(--font-text);
|
||||||
@@ -54,56 +42,35 @@
|
|||||||
max-width: 15ch;
|
max-width: 15ch;
|
||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
.sub {
|
/* The leitmotiv: three lines, each set on a small stone of the keystone's colour. */
|
||||||
margin-top: 1.1rem;
|
.leitmotiv {
|
||||||
font-family: var(--font-text);
|
|
||||||
font-style: italic;
|
|
||||||
font-size: clamp(1.1rem, 1rem + 0.5vw, 1.35rem);
|
|
||||||
color: var(--ink-soft);
|
|
||||||
max-width: 30ch;
|
|
||||||
}
|
|
||||||
.sections {
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: clamp(2.8rem, 7vh, 4.5rem) 0 0;
|
margin: 1.4rem 0 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
gap: 0.2rem;
|
||||||
gap: 1px;
|
font-family: var(--font-text);
|
||||||
background: var(--surface-2);
|
font-style: italic;
|
||||||
border-radius: var(--r-card);
|
font-size: clamp(1.12rem, 1rem + 0.55vw, 1.4rem);
|
||||||
overflow: hidden;
|
line-height: 1.45;
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.section {
|
|
||||||
background: var(--surface);
|
|
||||||
padding: 1.4rem 1.5rem 1.5rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.55rem;
|
|
||||||
align-content: start;
|
|
||||||
}
|
|
||||||
.section:not(.open) {
|
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
.head {
|
.leitmotiv li {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
gap: 0.7rem;
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
}
|
||||||
h2 {
|
.leitmotiv li::before {
|
||||||
font-size: 1.08rem;
|
content: '';
|
||||||
font-weight: 800;
|
flex: none;
|
||||||
color: var(--ink);
|
width: 0.62em;
|
||||||
}
|
height: 0.34em;
|
||||||
.section:not(.open) h2 {
|
border-radius: 0.1em;
|
||||||
color: var(--ink-soft);
|
background: var(--cle);
|
||||||
}
|
opacity: 0.75;
|
||||||
.section p {
|
translate: 0 -0.12em;
|
||||||
font-size: 0.96rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
}
|
||||||
.enter {
|
.enter {
|
||||||
margin-top: 2.6rem;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -113,9 +80,4 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
max-width: 34ch;
|
max-width: 34ch;
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
|
||||||
.sections {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import type { Me } from '$lib/types';
|
import type { Me } from '$lib/types';
|
||||||
|
|
||||||
const back = $derived(page.url.searchParams.get('return') ?? '/groupes');
|
const back = $derived(page.url.searchParams.get('return') ?? '/');
|
||||||
const error = $derived(page.url.searchParams.get('erreur'));
|
const error = $derived(page.url.searchParams.get('erreur'));
|
||||||
let devName = $state('');
|
let devName = $state('');
|
||||||
let devAdmin = $state(false);
|
let devAdmin = $state(false);
|
||||||
|
|||||||
@@ -1,23 +1 @@
|
|||||||
<script lang="ts">
|
<!-- Redirects, see +page.ts. -->
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<h1>{t('soon.title')}</h1>
|
|
||||||
<p class="muted">{t('soon.body')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
max-width: 34rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-family: var(--font-text);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 2.2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Old address: the tool now lives in each group — the current one, or home.
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { lastGroup, toolHref } from '$lib/groups.svelte';
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
redirect(307, toolHref('decisions', lastGroup()));
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
try {
|
try {
|
||||||
const r = await api.post<{ me: Me }>('/auth/team', link?.signedInAs ? { token } : { token, name });
|
const r = await api.post<{ me: Me }>('/auth/team', link?.signedInAs ? { token } : { token, name });
|
||||||
session.me = r.me;
|
session.me = r.me;
|
||||||
await goto('/groupes', { replaceState: true });
|
await goto('/', { replaceState: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof ApiError ? err.code : 'internal';
|
const code = err instanceof ApiError ? err.code : 'internal';
|
||||||
if (code === 'invitation_invalid') phase = 'invalid';
|
if (code === 'invitation_invalid') phase = 'invalid';
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import DocCards from '$lib/components/DocCards.svelte';
|
||||||
import Guard from '$lib/components/Guard.svelte';
|
import Guard from '$lib/components/Guard.svelte';
|
||||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
import Pillars from '$lib/components/Pillars.svelte';
|
||||||
|
import { groups } from '$lib/groups.svelte';
|
||||||
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { Remote } from '$lib/load.svelte';
|
import { Remote } from '$lib/load.svelte';
|
||||||
import type { GroupView } from '$lib/group';
|
|
||||||
|
|
||||||
const slug = $derived(page.params.group ?? '');
|
const slug = $derived(page.params.group ?? '');
|
||||||
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
|
const data = new Remote(() => groups.load(slug));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void slug;
|
void slug;
|
||||||
void data.load();
|
void data.load();
|
||||||
});
|
});
|
||||||
const labels: Record<string, Key> = { decisions: 'nav.decisions', docs: 'nav.docs', mandats: 'nav.mandates', protocoles: 'nav.protocols' };
|
|
||||||
const texts: Record<string, Key> = { decisions: 'home.decisions', docs: 'home.docs', mandats: 'home.mandates', protocoles: 'home.protocols' };
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data.error}
|
{#if data.error}
|
||||||
@@ -21,36 +20,24 @@
|
|||||||
{:else if data.value}
|
{:else if data.value}
|
||||||
{@const g = data.value.group}
|
{@const g = data.value.group}
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<a class="eyebrow back" href="/groupes">{t('nav.groups')}</a>
|
<a class="eyebrow back" href="/">{t('nav.groups')}</a>
|
||||||
<h1>{g.name}</h1>
|
<h1>{g.name}</h1>
|
||||||
{#if !g.open}
|
{#if !g.open}
|
||||||
<p class="muted">{t('groups.locked.long')}</p>
|
<p class="muted">{t('groups.locked.long')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<h2 class="eyebrow sub">{t('group.tools')}</h2>
|
<h2 class="visually-hidden">{t('group.tools')}</h2>
|
||||||
<ul class="tools">
|
<Pillars group={g.slug} tools={data.value.tools} />
|
||||||
{#each data.value.tools as tool (tool.key)}
|
<section class="current" aria-labelledby="current-title">
|
||||||
<li>
|
<h2 class="eyebrow" id="current-title">{t('group.current')}</h2>
|
||||||
{#if tool.active}
|
<DocCards group={g.slug} docs={data.value.docs} summary={false} />
|
||||||
<a class="tool card" href="/g/{g.slug}/docs">
|
</section>
|
||||||
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}</span>
|
|
||||||
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<div class="tool off" aria-disabled="true">
|
|
||||||
<span class="name">{t(labels[tool.key] ?? 'nav.docs')}<span class="tag">{t('home.soon')}</span></span>
|
|
||||||
<span class="text">{t(texts[tool.key] ?? 'home.docs')}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
max-width: 46rem;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -63,56 +50,14 @@
|
|||||||
font-family: var(--font-text);
|
font-family: var(--font-text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
}
|
}
|
||||||
.sub {
|
.current {
|
||||||
margin-top: 1.6rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
.tools {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
|
margin-top: 2.2rem;
|
||||||
}
|
}
|
||||||
.tool {
|
.current h2 {
|
||||||
display: grid;
|
|
||||||
gap: 0.45rem;
|
|
||||||
padding: 1.3rem 1.4rem;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--ink);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
a.tool {
|
|
||||||
transition: transform 0.12s ease;
|
|
||||||
}
|
|
||||||
a.tool:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
.tool.off {
|
|
||||||
border-radius: var(--r-card);
|
|
||||||
box-shadow: inset 0 0 0 1px var(--surface-2);
|
|
||||||
color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
.name {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
.text {
|
|
||||||
font-size: 0.93rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
}
|
}
|
||||||
.off .text {
|
|
||||||
color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.tools {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// « Décisions »: the documents in progress, one line each, with what is left to vote.
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import Guard from '$lib/components/Guard.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import type { DecisionsView } from '$lib/group';
|
||||||
|
import { groups } from '$lib/groups.svelte';
|
||||||
|
import { i18n, t } from '$lib/i18n/index.svelte';
|
||||||
|
import { Remote } from '$lib/load.svelte';
|
||||||
|
import type { LangMap } from '$lib/types';
|
||||||
|
|
||||||
|
const slug = $derived(page.params.group ?? '');
|
||||||
|
const data = new Remote(async () => {
|
||||||
|
const view = await groups.load(slug);
|
||||||
|
const decisions = view.group.open ? await api.get<DecisionsView>(`/groups/${slug}/decisions`) : null;
|
||||||
|
return { view, decisions };
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
void slug;
|
||||||
|
void data.load();
|
||||||
|
});
|
||||||
|
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if data.error}
|
||||||
|
<Guard code={data.error} onretry={() => data.load()} />
|
||||||
|
{:else if data.value}
|
||||||
|
{@const g = data.value.view.group}
|
||||||
|
{@const decisions = data.value.decisions}
|
||||||
|
<div class="page">
|
||||||
|
<a class="eyebrow back" href="/g/{g.slug}">{g.name}</a>
|
||||||
|
<h1>{t('nav.decisions')}</h1>
|
||||||
|
{#if !decisions}
|
||||||
|
<p class="muted">{t('groups.locked.long')}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="lead">{t('decisions.note')}</p>
|
||||||
|
{#if decisions.docs.length}
|
||||||
|
<ul class="lines">
|
||||||
|
{#each decisions.docs as d (d.id)}
|
||||||
|
<li>
|
||||||
|
<a class="line card" href="/g/{g.slug}/docs/{d.slug}">
|
||||||
|
<span class="title">{pick(d.title)}</span>
|
||||||
|
<span class="counts">
|
||||||
|
<span class="to-vote">{i18n.plural('decisions.toVote', d.toVote)}</span>
|
||||||
|
{#if d.withoutMyVote !== null && d.toVote > 0}
|
||||||
|
<span class="mine">{i18n.plural('decisions.withoutMyVote', d.withoutMyVote)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="go" aria-hidden="true"><Icon name="chevron" size={16} /></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="muted">{t('group.current.none')}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.page {
|
||||||
|
max-width: 48rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.back {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
||||||
|
}
|
||||||
|
.lead {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
max-width: 52ch;
|
||||||
|
line-height: 1.55;
|
||||||
|
margin-bottom: 1.4rem;
|
||||||
|
}
|
||||||
|
.lines {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.line {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4rem 1.4rem;
|
||||||
|
padding: 1.15rem 3rem 1.15rem 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: transform 0.12s ease;
|
||||||
|
}
|
||||||
|
.line:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
flex: 1 1 16rem;
|
||||||
|
font-family: var(--font-text);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.counts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.2rem 0.6rem;
|
||||||
|
}
|
||||||
|
.to-vote {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--water);
|
||||||
|
}
|
||||||
|
.mine {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
.go {
|
||||||
|
position: absolute;
|
||||||
|
right: 1.1rem;
|
||||||
|
top: 50%;
|
||||||
|
translate: 0 -50%;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api } from '$lib/api';
|
import DocCards from '$lib/components/DocCards.svelte';
|
||||||
import Guard from '$lib/components/Guard.svelte';
|
import Guard from '$lib/components/Guard.svelte';
|
||||||
import { i18n, t } from '$lib/i18n/index.svelte';
|
import SoonList from '$lib/components/SoonList.svelte';
|
||||||
|
import { groups } from '$lib/groups.svelte';
|
||||||
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { Remote } from '$lib/load.svelte';
|
import { Remote } from '$lib/load.svelte';
|
||||||
import type { GroupView } from '$lib/group';
|
|
||||||
import type { LangMap } from '$lib/types';
|
|
||||||
|
|
||||||
const slug = $derived(page.params.group ?? '');
|
const slug = $derived(page.params.group ?? '');
|
||||||
const data = new Remote(() => api.get<GroupView>(`/groups/${slug}`));
|
const data = new Remote(() => groups.load(slug));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void slug;
|
void slug;
|
||||||
void data.load();
|
void data.load();
|
||||||
});
|
});
|
||||||
const pick = (m: LangMap) => m[i18n.lang] ?? m.fr ?? '';
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if data.error}
|
{#if data.error}
|
||||||
@@ -25,27 +24,19 @@
|
|||||||
<h1>{t('docs.title')}</h1>
|
<h1>{t('docs.title')}</h1>
|
||||||
{#if !g.open}
|
{#if !g.open}
|
||||||
<p class="muted">{t('groups.locked.long')}</p>
|
<p class="muted">{t('groups.locked.long')}</p>
|
||||||
{:else if !data.value.docs.length}
|
|
||||||
<p class="muted">{t('group.docs.empty')}</p>
|
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="docs">
|
<section class="current" aria-labelledby="current-title">
|
||||||
{#each data.value.docs as d (d.id)}
|
<h2 class="eyebrow" id="current-title">{t('group.current')}</h2>
|
||||||
<li>
|
<DocCards group={g.slug} docs={data.value.docs} />
|
||||||
<a class="doc card" href="/g/{g.slug}/docs/{d.slug}">
|
</section>
|
||||||
<span class="title">{pick(d.title)}</span>
|
<SoonList items={data.value.upcoming.docs} />
|
||||||
<span class="summary">{pick(d.summary)}</span>
|
|
||||||
<span class="meta faint">{i18n.plural('docs.inForce', d.inForce)} · {i18n.plural('docs.open', d.open)}</span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
max-width: 46rem;
|
max-width: 48rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -60,35 +51,12 @@
|
|||||||
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
font-size: clamp(2rem, 1.5rem + 2vw, 2.8rem);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
.docs {
|
.current {
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.8rem;
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 1.8rem;
|
||||||
}
|
}
|
||||||
.doc {
|
.current h2 {
|
||||||
display: grid;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1.5rem 1.6rem;
|
|
||||||
color: var(--ink);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: transform 0.12s ease;
|
|
||||||
}
|
|
||||||
.doc:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
font-family: var(--font-text);
|
|
||||||
font-size: 1.45rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.summary {
|
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
.meta {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ToolSoon from '$lib/components/ToolSoon.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToolSoon tool="mandats" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import ToolSoon from '$lib/components/ToolSoon.svelte';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ToolSoon tool="protocoles" />
|
||||||
@@ -1,139 +1 @@
|
|||||||
<script lang="ts">
|
<!-- Redirects, see +page.ts. -->
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { api } from '$lib/api';
|
|
||||||
import Guard from '$lib/components/Guard.svelte';
|
|
||||||
import Icon from '$lib/components/Icon.svelte';
|
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
|
||||||
import { Remote } from '$lib/load.svelte';
|
|
||||||
import type { GroupSummary } from '$lib/types';
|
|
||||||
|
|
||||||
const data = new Remote(() => api.get<{ groups: GroupSummary[] }>('/groups'));
|
|
||||||
onMount(() => void data.load());
|
|
||||||
|
|
||||||
const groups = $derived(data.value?.groups ?? []);
|
|
||||||
const root = $derived(groups.find((g) => g.slug === 'monnaie-libre'));
|
|
||||||
const sameKey = $derived(groups.filter((g) => g.parent === 'monnaie-libre'));
|
|
||||||
const others = $derived(groups.filter((g) => g.slug !== 'monnaie-libre' && g.parent !== 'monnaie-libre'));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if data.error}
|
|
||||||
<Guard code={data.error} onretry={() => data.load()} />
|
|
||||||
{:else if data.value}
|
|
||||||
<div class="page">
|
|
||||||
<h1>{t('groups.title')}</h1>
|
|
||||||
|
|
||||||
{#if root}
|
|
||||||
<a class="root card" class:locked={!root.open} href={root.open ? `/g/${root.slug}` : undefined}>
|
|
||||||
<span class="eyebrow">Ğ1</span>
|
|
||||||
<span class="name">{root.name}</span>
|
|
||||||
{#if !root.open}<span class="lock"><Icon name="lock" size={16} />{t('groups.locked')}</span>{/if}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if sameKey.length}
|
|
||||||
<h2 class="eyebrow sub">{t('groups.same.key')}</h2>
|
|
||||||
<ul class="list">
|
|
||||||
{#each sameKey as g (g.slug)}
|
|
||||||
<li>
|
|
||||||
{#if g.open}
|
|
||||||
<a class="row card" href="/g/{g.slug}"><span class="name">{g.name}</span><Icon name="chevron" size={16} /></a>
|
|
||||||
{:else}
|
|
||||||
<div class="row card locked" title={t('groups.locked.long')}>
|
|
||||||
<span class="name">{g.name}</span>
|
|
||||||
<span class="lock"><Icon name="lock" size={15} />{t('groups.locked')}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{#if sameKey.some((g) => !g.open)}<p class="faint note">{t('groups.locked.long')}</p>{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if others.length}
|
|
||||||
<h2 class="eyebrow sub">{t('groups.test')}</h2>
|
|
||||||
<ul class="list">
|
|
||||||
{#each others as g (g.slug)}
|
|
||||||
<li>
|
|
||||||
<a class="row card" href={g.open ? `/g/${g.slug}` : undefined} class:locked={!g.open}>
|
|
||||||
<span class="name">{g.name}</span><Icon name={g.open ? 'chevron' : 'lock'} size={16} />
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if !groups.length}<p class="muted">{t('groups.empty')}</p>{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
max-width: 42rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: clamp(2.5rem, 8vh, 5rem) 1.4rem 5rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-family: var(--font-text);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: clamp(1.9rem, 1.5rem + 1.5vw, 2.5rem);
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
}
|
|
||||||
.root {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.3rem;
|
|
||||||
padding: 1.6rem 1.6rem 1.7rem;
|
|
||||||
color: var(--ink);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: transform 0.12s ease;
|
|
||||||
}
|
|
||||||
a.root:hover,
|
|
||||||
a.row:hover {
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
.root .name {
|
|
||||||
font-family: var(--font-text);
|
|
||||||
font-size: 1.7rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.sub {
|
|
||||||
margin-top: 1.4rem;
|
|
||||||
color: var(--ink-soft);
|
|
||||||
}
|
|
||||||
.list {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
display: grid;
|
|
||||||
gap: 0.6rem;
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem 1.2rem;
|
|
||||||
color: var(--ink);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: transform 0.12s ease;
|
|
||||||
}
|
|
||||||
.row .name {
|
|
||||||
font-weight: 750;
|
|
||||||
}
|
|
||||||
.locked {
|
|
||||||
background: transparent;
|
|
||||||
box-shadow: inset 0 0 0 1px var(--surface-2);
|
|
||||||
color: var(--ink-faint);
|
|
||||||
}
|
|
||||||
.lock {
|
|
||||||
display: inline-flex;
|
|
||||||
gap: 0.35rem;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 0.82rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.note {
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Old address: the choice of a group is on the home page.
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
redirect(307, '/');
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
try {
|
try {
|
||||||
const r = await api.post<{ me: Me }>('/auth/invitation', { token });
|
const r = await api.post<{ me: Me }>('/auth/invitation', { token });
|
||||||
session.me = r.me;
|
session.me = r.me;
|
||||||
await goto('/groupes', { replaceState: true });
|
await goto('/', { replaceState: true });
|
||||||
} catch {
|
} catch {
|
||||||
state = 'invalid';
|
state = 'invalid';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1 @@
|
|||||||
<script lang="ts">
|
<!-- Redirects, see +page.ts. -->
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<h1>{t('soon.title')}</h1>
|
|
||||||
<p class="muted">{t('soon.body')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
max-width: 34rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-family: var(--font-text);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 2.2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Old address: the tool now lives in each group — the current one, or home.
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { lastGroup, toolHref } from '$lib/groups.svelte';
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
redirect(307, toolHref('mandats', lastGroup()));
|
||||||
|
}
|
||||||
@@ -1,23 +1 @@
|
|||||||
<script lang="ts">
|
<!-- Redirects, see +page.ts. -->
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<h1>{t('soon.title')}</h1>
|
|
||||||
<p class="muted">{t('soon.body')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page {
|
|
||||||
max-width: 34rem;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: clamp(3rem, 12vh, 7rem) 1.4rem;
|
|
||||||
display: grid;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
font-family: var(--font-text);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 2.2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// Old address: the tool now lives in each group — the current one, or home.
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { lastGroup, toolHref } from '$lib/groups.svelte';
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
redirect(307, toolHref('protocoles', lastGroup()));
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 410 B |
|
After Width: | Height: | Size: 937 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 535 B After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "libreDecision",
|
||||||
|
"short_name": "libreDecision",
|
||||||
|
"description": "Rendre possible la décision collective",
|
||||||
|
"lang": "fr",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#e9eeec",
|
||||||
|
"theme_color": "#e9eeec",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }
|
||||||
|
]
|
||||||
|
}
|
||||||