forked from yvv/decision
- Aujourd'hui (Fil 13 sections + capture sticky) + Le chemin (tunnel 2 gestes, Q0 inline, 3 chips, dérogation asymétrique, alternatives réglage/consignation) - Registre + fiche décision (timeline, périmètre premier/second lieu auditable, affluence non-ignorable, S'instruire condensé, éléments + cartographie de clôture, épreuve du réel, Remettre en question, PV A4, gravure) - Salle de vote 5 modalités (consentement, nuancé+histogramme, binaire hérité avec jauge inertielle, Réglage collectif complet — faisceau, médiane basse, Pour moi, Explorer, cristallisation-geste —, élection à départage humain) - Textes (Pacte en clair, document vivant, diff, vue projetée, Atelier des formules porté du v1 sur le moteur unique) + Mandats (faits comptés, feux de la rampe, wizard 3 étapes) + Observatoire (consigner→observer→protocoliser) - Onboarding 7 gabarits + Données locales (export/import, attributs, atelier) - voting→framing gardé (Reformuler d'un réglage figé non cristallisé) - Pages v1 supprimées (login, documents, mandates, protocols, sanctuary, tools, decisions/new) — 342 tests verts, build zéro erreur Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
323 lines
11 KiB
Vue
323 lines
11 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* « / — Aujourd'hui » : capture + Fil personnel — la boîte de réception de la
|
||
* démocratie. Tout est sélecteur Pinia pur (useFeed) ; les sections vides sont
|
||
* masquées ; le compteur est nu — jamais de rouge.
|
||
*/
|
||
import type { Decision } from '~/types/domain'
|
||
import {
|
||
BOUNDARY_SUSPENDED,
|
||
CRYSTALLIZE_CARD,
|
||
DOSSIER_COMPLETE_CARD,
|
||
FEED_HEADER,
|
||
FIRST_DECISION,
|
||
ICONS,
|
||
MATURATION_CARD,
|
||
REVIEW_TITLE,
|
||
SCOPE_KEEP,
|
||
SCOPE_WIDEN,
|
||
} from '~/lexicon'
|
||
import { useCollectiveStore } from '~/stores/collective'
|
||
import { useDecisionsStore } from '~/stores/decisions'
|
||
import { useFeed } from '~/composables/useFeed'
|
||
|
||
const col = useCollectiveStore()
|
||
const decisions = useDecisionsStore()
|
||
const {
|
||
objectionWindows, adviceRequests, openVotes, toCrystallize, dossiersComplete,
|
||
tiesToBreak, reviewsDue, mandateReportsDue, boundaryObjections,
|
||
overflowingScopes, prioritiesAsked, suggestions, collectiveActivity, myCount,
|
||
} = useFeed()
|
||
|
||
const dossierError = ref('')
|
||
|
||
const allEmpty = computed(
|
||
() => myCount.value === 0 && suggestions.value.length === 0
|
||
&& collectiveActivity.value.length === 0,
|
||
)
|
||
|
||
/** The proposed founding decision, still in preparation. */
|
||
const pactPending = computed(() =>
|
||
col.decisions.some(d => d.title === FIRST_DECISION && d.status === 'draft'),
|
||
)
|
||
|
||
const decisionById = (id: string): Decision | undefined =>
|
||
col.decisions.find(d => d.id === id)
|
||
const personName = (id: string): string =>
|
||
col.people.find(p => p.id === id)?.displayName ?? 'Quelqu\'un'
|
||
const dateFr = (iso: string): string =>
|
||
new Date(iso).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })
|
||
|
||
/** Steward gesture: framing → closed once every element is terminal. */
|
||
function closeDossier(decision: Decision) {
|
||
const res = decisions.transition(decision.id, 'closed')
|
||
dossierError.value = res.ok ? '' : res.reason
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<!-- ld-v2 -->
|
||
<div class="feed-page">
|
||
<FeedCapture />
|
||
|
||
<header class="feed-header">
|
||
<h1 class="feed-header__title">{{ FEED_HEADER }}</h1>
|
||
<span v-if="myCount > 0" class="feed-header__count">{{ myCount }}</span>
|
||
</header>
|
||
|
||
<!-- État vide global — invitation chaleureuse -->
|
||
<div v-if="allEmpty" class="ld-card feed-empty">
|
||
<UIcon name="i-lucide-sun-medium" class="feed-empty__icon" />
|
||
<p class="feed-empty__line">
|
||
Rien ne t'attend — c'est le bon moment pour capturer ta première décision.
|
||
</p>
|
||
<p class="feed-empty__hint">
|
||
Une phrase suffit, là-haut. Le chemin s'occupe du reste.
|
||
</p>
|
||
<NuxtLink
|
||
v-if="pactPending"
|
||
class="ld-btn"
|
||
:to="{ path: '/decider', query: { titre: FIRST_DECISION } }"
|
||
>
|
||
<UIcon name="i-lucide-feather" />
|
||
<span>{{ FIRST_DECISION }}</span>
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<!-- Frontières contestées — priorité haute, en tête -->
|
||
<FeedSection
|
||
v-if="boundaryObjections.length > 0"
|
||
title="Frontières contestées — à traiter d'abord"
|
||
icon="i-lucide-octagon-pause"
|
||
priority
|
||
>
|
||
<FeedItemCard
|
||
v-for="o in boundaryObjections"
|
||
:key="o.id"
|
||
:title="decisionById(o.decisionId)?.title ?? 'Décision'"
|
||
icon="i-lucide-octagon-pause"
|
||
tint="var(--mood-status-fenetre)"
|
||
:to="`/decisions/${o.decisionId}`"
|
||
>
|
||
<template #meta>
|
||
<span class="status-pill status-suspended">{{ BOUNDARY_SUSPENDED }}</span>
|
||
</template>
|
||
<p class="feed-quote">{{ personName(o.personId) }} : « {{ o.argument }} »</p>
|
||
</FeedItemCard>
|
||
</FeedSection>
|
||
|
||
<!-- Fenêtres d'objection -->
|
||
<FeedSection
|
||
v-if="objectionWindows.length > 0"
|
||
title="Fenêtres d'objection"
|
||
icon="i-lucide-timer"
|
||
>
|
||
<FeedWindowCard v-for="d in objectionWindows" :key="d.id" :decision="d" />
|
||
</FeedSection>
|
||
|
||
<!-- Avis demandés -->
|
||
<FeedSection
|
||
v-if="adviceRequests.length > 0"
|
||
title="Avis demandés"
|
||
icon="i-lucide-message-circle"
|
||
>
|
||
<FeedAdviceCard v-for="d in adviceRequests" :key="d.id" :decision="d" />
|
||
</FeedSection>
|
||
|
||
<!-- Votes ouverts -->
|
||
<FeedSection v-if="openVotes.length > 0" title="Votes ouverts" icon="i-lucide-users">
|
||
<FeedVoteCard
|
||
v-for="item in openVotes"
|
||
:key="item.session.id"
|
||
:session="item.session"
|
||
:decision="item.decision"
|
||
/>
|
||
</FeedSection>
|
||
|
||
<!-- À cristalliser — le geste du garant -->
|
||
<FeedSection v-if="toCrystallize.length > 0" title="À cristalliser" :icon="ICONS.crystallize">
|
||
<FeedItemCard
|
||
v-for="item in toCrystallize"
|
||
:key="item.session.id"
|
||
:title="item.decision?.title ?? 'Réglage collectif'"
|
||
:icon="ICONS.crystallize"
|
||
tint="var(--mood-accent)"
|
||
:to="item.decision ? `/decisions/${item.decision.id}/vote` : undefined"
|
||
>
|
||
<p class="feed-line">{{ CRYSTALLIZE_CARD }}</p>
|
||
</FeedItemCard>
|
||
</FeedSection>
|
||
|
||
<!-- Dossiers complets — clore est un geste -->
|
||
<FeedSection v-if="dossiersComplete.length > 0" title="Dossiers complets" icon="i-lucide-folder-check">
|
||
<FeedItemCard
|
||
v-for="d in dossiersComplete"
|
||
:key="d.id"
|
||
:title="d.title"
|
||
icon="i-lucide-folder-check"
|
||
tint="var(--mood-status-vigueur)"
|
||
:to="`/decisions/${d.id}`"
|
||
>
|
||
<p class="feed-line">{{ DOSSIER_COMPLETE_CARD }}</p>
|
||
<p v-if="dossierError" class="feed-error">{{ dossierError }}</p>
|
||
<template #actions>
|
||
<button class="ld-btn feed-btn" type="button" @click="closeDossier(d)">
|
||
<UIcon name="i-lucide-stamp" />
|
||
<span>Clore et publier</span>
|
||
</button>
|
||
</template>
|
||
</FeedItemCard>
|
||
</FeedSection>
|
||
|
||
<!-- Égalités à départager — jamais par l'outil -->
|
||
<FeedSection v-if="tiesToBreak.length > 0" title="Égalités à départager" icon="i-lucide-scale">
|
||
<FeedItemCard
|
||
v-for="item in tiesToBreak"
|
||
:key="item.session.id"
|
||
:title="item.decision?.title ?? 'Élection'"
|
||
icon="i-lucide-scale"
|
||
tint="var(--mood-status-vote)"
|
||
:to="item.decision ? `/decisions/${item.decision.id}` : undefined"
|
||
>
|
||
<p class="feed-line">Égalité constatée — vous départagez, jamais l'outil.</p>
|
||
</FeedItemCard>
|
||
</FeedSection>
|
||
|
||
<!-- L'épreuve du réel -->
|
||
<FeedSection v-if="reviewsDue.length > 0" :title="REVIEW_TITLE" icon="i-lucide-flask-conical">
|
||
<FeedReviewCard v-for="d in reviewsDue" :key="d.id" :decision="d" />
|
||
</FeedSection>
|
||
|
||
<!-- Rapports de mandat -->
|
||
<FeedSection v-if="mandateReportsDue.length > 0" title="Rapports de mandat" icon="i-lucide-key-round">
|
||
<FeedItemCard
|
||
v-for="item in mandateReportsDue"
|
||
:key="item.mandate.id"
|
||
:title="item.mandate.title"
|
||
icon="i-lucide-key-round"
|
||
tint="var(--route-mandate)"
|
||
:to="`/mandats/${item.mandate.id}`"
|
||
>
|
||
<p class="feed-line">Un rapport est attendu depuis le {{ dateFr(item.dueAt) }}.</p>
|
||
</FeedItemCard>
|
||
</FeedSection>
|
||
|
||
<!-- Périmètres qui débordent -->
|
||
<FeedSection v-if="overflowingScopes.length > 0" title="Périmètres qui débordent" icon="i-lucide-expand">
|
||
<FeedItemCard
|
||
v-for="d in overflowingScopes"
|
||
:key="d.id"
|
||
:title="d.title"
|
||
icon="i-lucide-expand"
|
||
tint="var(--mood-status-fenetre)"
|
||
>
|
||
<p class="feed-line">Plus de monde se déclare concerné que prévu — décide du périmètre.</p>
|
||
<template #actions>
|
||
<NuxtLink class="ld-btn feed-btn" :to="`/decisions/${d.id}?geste=elargir`">
|
||
{{ SCOPE_WIDEN }}
|
||
</NuxtLink>
|
||
<NuxtLink class="ld-btn ld-btn--ghost feed-btn" :to="`/decisions/${d.id}?geste=maintenir`">
|
||
{{ SCOPE_KEEP }}
|
||
</NuxtLink>
|
||
</template>
|
||
</FeedItemCard>
|
||
</FeedSection>
|
||
|
||
<!-- Pondère tes enjeux -->
|
||
<FeedSection v-if="prioritiesAsked.length > 0" title="Pondère tes enjeux" icon="i-lucide-sliders-horizontal">
|
||
<FeedItemCard
|
||
v-for="c in prioritiesAsked"
|
||
:key="c.id"
|
||
:title="decisionById(c.decisionId)?.title ?? 'Élément de dossier'"
|
||
icon="i-lucide-sliders-horizontal"
|
||
tint="var(--mood-tertiary)"
|
||
:to="`/decisions/${c.decisionId}`"
|
||
>
|
||
<p class="feed-line">Dis au dossier combien cet élément compte pour toi (0–3).</p>
|
||
</FeedItemCard>
|
||
</FeedSection>
|
||
|
||
<!-- Boucle apprenante — cartes discrètes -->
|
||
<FeedSection v-if="suggestions.length > 0" title="Boucle apprenante" icon="i-lucide-repeat" discrete>
|
||
<template v-for="(s, i) in suggestions" :key="i">
|
||
<FeedItemCard
|
||
v-if="s.kind === 'claim-mandate'"
|
||
:title="`${s.count}ᵉ fois sur « ${s.tags[0]} » — réclame un mandat`"
|
||
:icon="ICONS.recurrence"
|
||
to="/mandats/nouveau"
|
||
/>
|
||
<FeedItemCard
|
||
v-else-if="s.kind === 'protocolize'"
|
||
:title="MATURATION_CARD"
|
||
icon="i-lucide-sprout"
|
||
to="/textes"
|
||
>
|
||
<p class="feed-line">{{ s.count }} consignations proches sur « {{ s.tags[0] }} ».</p>
|
||
</FeedItemCard>
|
||
<FeedItemCard
|
||
v-else
|
||
:title="`Le protocole « ${s.protocol.name} » n'a jamais servi — élague ?`"
|
||
icon="i-lucide-scissors"
|
||
to="/textes/formules"
|
||
/>
|
||
</template>
|
||
</FeedSection>
|
||
|
||
<!-- Activité du collectif -->
|
||
<FeedSection v-if="collectiveActivity.length > 0" title="Activité du collectif" icon="i-lucide-activity">
|
||
<FeedActivityCard v-for="d in collectiveActivity" :key="d.id" :decision="d" />
|
||
</FeedSection>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.feed-page {
|
||
width: 100%;
|
||
max-width: 44rem;
|
||
margin: 0 auto;
|
||
}
|
||
.feed-header {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.625rem;
|
||
margin: 0.75rem 0 1.25rem;
|
||
}
|
||
.feed-header__title {
|
||
margin: 0;
|
||
font-size: clamp(1.375rem, 4vw, 1.75rem);
|
||
font-weight: 800;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.feed-header__count {
|
||
font-size: 1.0625rem;
|
||
font-weight: 700;
|
||
color: var(--mood-text-muted);
|
||
}
|
||
.feed-empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: clamp(2rem, 6vw, 3.5rem) 1.5rem;
|
||
text-align: center;
|
||
}
|
||
.feed-empty__icon { font-size: 2rem; color: var(--mood-accent); }
|
||
.feed-empty__line {
|
||
margin: 0;
|
||
font-size: 1.125rem;
|
||
font-weight: 700;
|
||
max-width: 26rem;
|
||
}
|
||
.feed-empty__hint {
|
||
margin: 0;
|
||
font-size: 0.9375rem;
|
||
color: var(--mood-text-muted);
|
||
}
|
||
.feed-line { margin: 0; font-size: 0.875rem; color: var(--mood-text-muted); }
|
||
.feed-quote { margin: 0; font-size: 0.875rem; font-style: italic; }
|
||
.feed-error { margin: 0; font-size: 0.8125rem; color: var(--mood-error); }
|
||
.feed-btn { font-size: 0.875rem; padding: 0.4rem 1rem; text-decoration: none; }
|
||
</style>
|