forked from yvv/decision
- stores/collective.ts (état local-first, gabarits, import/export, seeds) + stores/decisions.ts (cycle de vie complet : capture→chemin→fenêtres→ sessions→cristallisation→épreuve du réel→révocation) - data/templates.ts : 7 gabarits (Page blanche observatoire-d'abord, 5 points de départ, Institution symétrique) - composables useFeed (13 sections du Fil en sélecteurs purs) + useSearch (index unique Cmd+K/Q0) - shell : layouts default/bare, app.vue mince, useMood v2 (Source/Margelle/ Nappe/Minuit), sceau 井 = logo, LdWorkspaceSelector avec finalité A1, LdAvatarStack (premier/second lieu), LdCountdown (suspension striée) - 338 tests vitest verts, npm run build zéro erreur Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
609 lines
15 KiB
Vue
609 lines
15 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Shell v2 — header sticky, sidebar 4 entrées, drawer mobile, FAB « Décider »,
|
|
* sceau 井 bas-droite, footer. Rien tant que le store n'est pas prêt (flash évité) ;
|
|
* plein-écran d'accueil quand aucun collectif n'existe encore.
|
|
*/
|
|
import { useCollectiveStore } from '~/stores/collective'
|
|
import { CLOSING_LINE, CREATE_SIGNATURE, OPENING_DREAM } from '~/lexicon'
|
|
|
|
const store = useCollectiveStore()
|
|
const route = useRoute()
|
|
|
|
// ── Navigation : 4 entrées, pas une de plus ──
|
|
const navigationItems = [
|
|
{ label: "Aujourd'hui", icon: 'i-lucide-sun-medium', to: '/' },
|
|
{ label: 'Décisions', icon: 'i-lucide-scale', to: '/decisions' },
|
|
{ label: 'Textes', icon: 'i-lucide-book-open', to: '/textes' },
|
|
{ label: 'Mandats', icon: 'i-lucide-key-round', to: '/mandats' },
|
|
]
|
|
|
|
function isActive(to: string) {
|
|
if (to === '/') return route.path === '/'
|
|
return route.path === to || route.path.startsWith(to + '/')
|
|
}
|
|
|
|
// ── Drawer mobile ──
|
|
const mobileMenuOpen = ref(false)
|
|
watch(() => route.path, () => { mobileMenuOpen.value = false })
|
|
|
|
// ── Sidebar repliable, persistée ──
|
|
const SIDEBAR_KEY = 'ld2-sidebar'
|
|
const sidebarCollapsed = ref(false)
|
|
watch(sidebarCollapsed, val => localStorage.setItem(SIDEBAR_KEY, String(val)))
|
|
onMounted(() => {
|
|
const saved = localStorage.getItem(SIDEBAR_KEY)
|
|
if (saved !== null) sidebarCollapsed.value = saved === 'true'
|
|
})
|
|
|
|
// ── Accueil : explorer un collectif d'exemple (tolérant si loadSeed absent) ──
|
|
const seeds = [
|
|
{ id: 'duniter-g1', label: 'Explorer Duniter Ğ1' },
|
|
{ id: 'atelier-du-canal', label: "Explorer l'Atelier du Canal" },
|
|
]
|
|
const loadingSeed = ref<string | null>(null)
|
|
async function explore(id: string) {
|
|
const s = store as unknown as { loadSeed?: (id: string) => unknown }
|
|
if (typeof s.loadSeed === 'function') {
|
|
loadingSeed.value = id
|
|
try {
|
|
await s.loadSeed(id)
|
|
} finally {
|
|
loadingSeed.value = null
|
|
}
|
|
} else {
|
|
await navigateTo('/creer')
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<!-- ld-v2 -->
|
|
<div v-if="store.ready" class="app-shell">
|
|
<!-- ════ Accueil plein écran — aucun collectif ════ -->
|
|
<div v-if="!store.current" class="app-welcome">
|
|
<div class="app-welcome__logo">
|
|
<span class="logo-stamp logo-stamp--lg">
|
|
<LdSeal :size="26" />
|
|
</span>
|
|
<span class="logo-text">
|
|
<span class="logo-text__libre">libre</span><span class="logo-text__decision">Decision</span>
|
|
</span>
|
|
</div>
|
|
|
|
<h1 class="app-welcome__dream">{{ OPENING_DREAM }}</h1>
|
|
<p class="app-welcome__line">{{ CLOSING_LINE }}</p>
|
|
|
|
<div class="app-welcome__actions">
|
|
<NuxtLink to="/creer" class="ld-btn">
|
|
<UIcon name="i-lucide-sparkles" />
|
|
<span>Créer un collectif</span>
|
|
</NuxtLink>
|
|
<button
|
|
v-for="seed in seeds"
|
|
:key="seed.id"
|
|
class="ld-btn ld-btn--ghost"
|
|
:disabled="loadingSeed !== null"
|
|
@click="explore(seed.id)"
|
|
>
|
|
<UIcon name="i-lucide-compass" />
|
|
<span>{{ seed.label }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<LdSeal class="app-seal app-welcome__seal" />
|
|
</div>
|
|
|
|
<!-- ════ Shell ordinaire ════ -->
|
|
<template v-else>
|
|
<!-- Header -->
|
|
<header class="app-header">
|
|
<div class="app-header__inner">
|
|
<div class="app-header__left">
|
|
<button
|
|
class="app-header__menu-btn"
|
|
aria-label="Ouvrir le menu"
|
|
@click="mobileMenuOpen = true"
|
|
>
|
|
<UIcon name="i-lucide-menu" class="app-header__menu-icon" />
|
|
</button>
|
|
<NuxtLink to="/" class="app-header__logo">
|
|
<span class="logo-stamp">
|
|
<LdSeal :size="18" />
|
|
</span>
|
|
<span class="logo-text">
|
|
<span class="logo-text__libre">libre</span><span class="logo-text__decision">Decision</span>
|
|
</span>
|
|
</NuxtLink>
|
|
</div>
|
|
|
|
<div class="app-header__center">
|
|
<LdWorkspaceSelector />
|
|
<LdMoodSwitcher />
|
|
</div>
|
|
|
|
<div class="app-header__right">
|
|
<NuxtLink to="/decider" class="ld-btn app-header__decide">
|
|
Décider
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Drawer mobile -->
|
|
<USlideover
|
|
v-model:open="mobileMenuOpen"
|
|
side="left"
|
|
title="Menu"
|
|
:ui="{ width: 'max-w-xs' }"
|
|
>
|
|
<template #body>
|
|
<nav class="app-mobile-nav">
|
|
<NuxtLink
|
|
v-for="item in navigationItems"
|
|
:key="item.to"
|
|
:to="item.to"
|
|
class="app-mobile-nav__link"
|
|
:class="{ 'app-mobile-nav__link--active': isActive(item.to) }"
|
|
@click="mobileMenuOpen = false"
|
|
>
|
|
<UIcon :name="item.icon" class="app-mobile-nav__icon" />
|
|
<span>{{ item.label }}</span>
|
|
</NuxtLink>
|
|
</nav>
|
|
<div class="app-mobile-row">
|
|
<span class="app-mobile-row__label">Collectif</span>
|
|
<LdWorkspaceSelector />
|
|
</div>
|
|
<div class="app-mobile-row">
|
|
<span class="app-mobile-row__label">Ambiance</span>
|
|
<LdMoodSwitcher />
|
|
</div>
|
|
</template>
|
|
</USlideover>
|
|
|
|
<!-- Corps : sidebar + page -->
|
|
<div class="app-body">
|
|
<aside class="app-sidebar" :class="{ 'app-sidebar--collapsed': sidebarCollapsed }">
|
|
<nav class="app-sidebar__nav">
|
|
<NuxtLink
|
|
v-for="item in navigationItems"
|
|
:key="item.to"
|
|
:to="item.to"
|
|
class="app-sidebar__link"
|
|
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
|
|
>
|
|
<UIcon :name="item.icon" class="app-sidebar__icon" />
|
|
<span class="app-sidebar__link-label">{{ item.label }}</span>
|
|
</NuxtLink>
|
|
<div class="app-sidebar__divider" />
|
|
<button
|
|
class="app-sidebar__toggle"
|
|
:title="sidebarCollapsed ? 'Déplier le menu' : 'Replier le menu'"
|
|
@click="sidebarCollapsed = !sidebarCollapsed"
|
|
>
|
|
<UIcon
|
|
:name="sidebarCollapsed ? 'i-lucide-panel-left-open' : 'i-lucide-panel-left-close'"
|
|
class="app-sidebar__icon"
|
|
/>
|
|
<span class="app-sidebar__link-label">Replier</span>
|
|
</button>
|
|
</nav>
|
|
</aside>
|
|
|
|
<main class="app-main">
|
|
<slot />
|
|
<LdSeal class="app-seal app-main__seal" />
|
|
</main>
|
|
</div>
|
|
|
|
<!-- FAB mobile « Décider » — au-dessus du sceau, qui reste visible -->
|
|
<NuxtLink to="/decider" class="app-fab" aria-label="Décider">
|
|
<UIcon name="i-lucide-zap" class="app-fab__icon" />
|
|
</NuxtLink>
|
|
|
|
<!-- Footer -->
|
|
<footer class="app-footer">
|
|
<span>libreDecision v2</span>
|
|
<span class="app-footer__sep">·</span>
|
|
<span>{{ CREATE_SIGNATURE }}</span>
|
|
</footer>
|
|
</template>
|
|
</div>
|
|
<!-- !ready : rien — le flash est évité -->
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* === Shell === */
|
|
.app-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100vh;
|
|
min-height: 100dvh;
|
|
background: var(--mood-bg);
|
|
color: var(--mood-text);
|
|
}
|
|
|
|
/* === Logo (partagé header / accueil) === */
|
|
.logo-stamp {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
background: var(--mood-accent);
|
|
color: var(--mood-accent-text);
|
|
border-radius: 8px;
|
|
transform: rotate(-10deg);
|
|
transition: transform 0.2s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
.app-header__logo:hover .logo-stamp {
|
|
transform: rotate(-16deg) scale(1.08);
|
|
}
|
|
.logo-stamp--lg {
|
|
width: 2.75rem;
|
|
height: 2.75rem;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.logo-text {
|
|
display: flex;
|
|
align-items: baseline;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
.logo-text__libre {
|
|
font-size: 1.0625rem;
|
|
font-weight: 400;
|
|
font-style: italic;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
.logo-text__decision {
|
|
font-size: 1.125rem;
|
|
font-weight: 700;
|
|
color: var(--mood-text);
|
|
}
|
|
|
|
/* === Accueil plein écran === */
|
|
.app-welcome {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 1.25rem;
|
|
padding: clamp(1.5rem, 5vw, 4rem);
|
|
text-align: center;
|
|
background: var(--mood-gradient);
|
|
}
|
|
|
|
.app-welcome__logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.875rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.app-welcome__logo .logo-text__libre { font-size: 1.375rem; }
|
|
.app-welcome__logo .logo-text__decision { font-size: 1.5rem; }
|
|
|
|
.app-welcome__dream {
|
|
max-width: 34rem;
|
|
font-size: clamp(1.5rem, 4.5vw, 2.25rem);
|
|
font-weight: 800;
|
|
line-height: 1.2;
|
|
letter-spacing: -0.02em;
|
|
margin: 0;
|
|
}
|
|
|
|
.app-welcome__line {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
font-style: italic;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
|
|
.app-welcome__actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.75rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.app-welcome__seal {
|
|
position: fixed;
|
|
right: 1.25rem;
|
|
bottom: 1rem;
|
|
}
|
|
|
|
/* === Header === */
|
|
.app-header {
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 30;
|
|
background: var(--mood-surface);
|
|
box-shadow: 0 1px 2px var(--mood-shadow);
|
|
}
|
|
|
|
.app-header__inner {
|
|
max-width: 80rem;
|
|
margin: 0 auto;
|
|
padding: 0 1.25rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
height: 3.5rem;
|
|
}
|
|
|
|
.app-header__left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.app-header__logo {
|
|
text-decoration: none;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.app-header__menu-btn {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
background: none;
|
|
color: var(--mood-text-muted);
|
|
cursor: pointer;
|
|
border-radius: var(--r-input);
|
|
}
|
|
.app-header__menu-btn:hover {
|
|
color: var(--mood-text);
|
|
background: var(--mood-accent-soft);
|
|
}
|
|
.app-header__menu-icon { font-size: 1.25rem; }
|
|
|
|
.app-header__center {
|
|
display: none;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
flex: 1;
|
|
justify-content: center;
|
|
min-width: 0;
|
|
}
|
|
|
|
.app-header__right {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.app-header__decide {
|
|
display: none;
|
|
}
|
|
|
|
@media (min-width: 768px) {
|
|
.app-header__menu-btn { display: none; }
|
|
.app-header__center { display: flex; }
|
|
.app-header__decide { display: inline-flex; }
|
|
}
|
|
|
|
/* === Corps + sidebar === */
|
|
.app-body {
|
|
display: flex;
|
|
flex: 1;
|
|
}
|
|
|
|
.app-sidebar {
|
|
width: 13rem;
|
|
flex-shrink: 0;
|
|
background: var(--mood-surface);
|
|
display: none;
|
|
transition: width 0.22s ease;
|
|
overflow: hidden;
|
|
}
|
|
.app-sidebar--collapsed { width: 3.75rem; }
|
|
|
|
@media (min-width: 768px) {
|
|
.app-sidebar { display: block; }
|
|
}
|
|
|
|
.app-sidebar__nav {
|
|
position: sticky;
|
|
top: 3.5rem;
|
|
padding: 1rem 0.5rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
}
|
|
|
|
.app-sidebar__link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.625rem;
|
|
padding: 0.625rem 0.75rem;
|
|
font-size: 0.9375rem;
|
|
font-weight: 600;
|
|
color: var(--mood-text-muted);
|
|
text-decoration: none;
|
|
border-radius: var(--r-input);
|
|
transition: all 0.12s ease;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
}
|
|
.app-sidebar__link:hover {
|
|
color: var(--mood-text);
|
|
background: var(--mood-accent-soft);
|
|
}
|
|
.app-sidebar__link--active {
|
|
color: var(--mood-accent);
|
|
background: var(--mood-accent-soft);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.app-sidebar__icon {
|
|
font-size: 1.125rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.app-sidebar__link-label {
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
transition: opacity 0.18s ease, max-width 0.22s ease;
|
|
max-width: 10rem;
|
|
}
|
|
.app-sidebar--collapsed .app-sidebar__link-label {
|
|
opacity: 0;
|
|
max-width: 0;
|
|
}
|
|
.app-sidebar--collapsed .app-sidebar__link {
|
|
justify-content: center;
|
|
padding: 0.625rem;
|
|
}
|
|
|
|
.app-sidebar__divider {
|
|
height: 1px;
|
|
background: color-mix(in srgb, var(--mood-accent) 10%, transparent);
|
|
margin: 0.375rem 0.25rem;
|
|
}
|
|
|
|
.app-sidebar__toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.625rem;
|
|
padding: 0.5rem 0.75rem;
|
|
font-size: 0.8125rem;
|
|
font-weight: 600;
|
|
color: var(--mood-text-muted);
|
|
background: none;
|
|
cursor: pointer;
|
|
border-radius: var(--r-input);
|
|
width: 100%;
|
|
transition: all 0.12s ease;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
}
|
|
.app-sidebar__toggle:hover {
|
|
color: var(--mood-text);
|
|
background: var(--mood-accent-soft);
|
|
}
|
|
.app-sidebar--collapsed .app-sidebar__toggle {
|
|
justify-content: center;
|
|
padding: 0.5rem;
|
|
}
|
|
|
|
/* === Drawer mobile === */
|
|
.app-mobile-nav {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
.app-mobile-nav__link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.875rem;
|
|
padding: 1rem 1.25rem;
|
|
font-size: 1.0625rem;
|
|
font-weight: 600;
|
|
color: var(--mood-text-muted);
|
|
text-decoration: none;
|
|
border-radius: var(--r-icon);
|
|
min-height: 3rem;
|
|
}
|
|
.app-mobile-nav__link:hover,
|
|
.app-mobile-nav__link:active {
|
|
background: var(--mood-accent-soft);
|
|
color: var(--mood-text);
|
|
}
|
|
.app-mobile-nav__link--active {
|
|
color: var(--mood-accent);
|
|
background: var(--mood-accent-soft);
|
|
font-weight: 700;
|
|
}
|
|
.app-mobile-nav__icon { font-size: 1.125rem; }
|
|
|
|
.app-mobile-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
padding: 1rem 1.25rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
.app-mobile-row__label {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: var(--mood-text-muted);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* === Main + sceau === */
|
|
.app-main {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding: 1.5rem 1.25rem;
|
|
}
|
|
|
|
@media (min-width: 640px) {
|
|
.app-main { padding: 2rem 1.75rem; }
|
|
}
|
|
@media (min-width: 1024px) {
|
|
.app-main { padding: 2rem 2.5rem; }
|
|
}
|
|
|
|
.app-main__seal {
|
|
margin: auto 0 0.5rem auto;
|
|
padding-top: 1.5rem;
|
|
box-sizing: content-box;
|
|
}
|
|
|
|
/* === FAB mobile === */
|
|
.app-fab {
|
|
position: fixed;
|
|
right: 1rem;
|
|
bottom: 4.75rem; /* décalage vertical : le sceau reste visible dessous */
|
|
z-index: 35;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 50%;
|
|
background: var(--mood-accent);
|
|
color: var(--mood-accent-text);
|
|
box-shadow: var(--shadow-raised);
|
|
text-decoration: none;
|
|
transition: transform 0.1s ease, box-shadow 0.1s ease;
|
|
}
|
|
.app-fab:hover {
|
|
transform: translateY(-1px);
|
|
}
|
|
.app-fab:active {
|
|
transform: translateY(0);
|
|
}
|
|
.app-fab__icon { font-size: 1.5rem; }
|
|
|
|
@media (min-width: 768px) {
|
|
.app-fab { display: none; }
|
|
}
|
|
|
|
/* === Footer === */
|
|
.app-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0.5rem;
|
|
padding: 1rem;
|
|
font-size: 0.8125rem;
|
|
color: var(--mood-text-muted);
|
|
}
|
|
.app-footer__sep { opacity: 0.3; }
|
|
</style>
|