Files
decision/frontend/app/components/common/LdWorkspaceSelector.vue
T
YvvandClaude Fable 5 d886302b59 v2 : couche données complète + shell + primitives communes
- 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>
2026-08-11 10:30:35 +02:00

243 lines
5.7 KiB
Vue

<script setup lang="ts">
/**
* Sélecteur de collectif — pastille couleur + nom + finalité (clause A1 du Pacte)
* en une ligne. Menu léger : collectifs locaux, « Créer un collectif », « Données locales ».
* Accès défensifs (?.) : le store peut ne pas être hydraté.
*/
import { useCollectiveStore } from '~/stores/collective'
const store = useCollectiveStore()
const route = useRoute()
const open = ref(false)
const rootEl = ref<HTMLElement | null>(null)
onClickOutside(rootEl, () => { open.value = false })
watch(() => route.path, () => { open.value = false })
const current = computed(() => store.current?.collective ?? null)
const index = computed(() => store.index ?? [])
/** Finalité du collectif = contenu de la version courante de la clause A1 du Pacte. */
const purpose = computed(() => {
const state = store.current
if (!state) return ''
const clause = state.clauses?.find(c => c.code === 'A1')
if (!clause?.currentVersionId) return ''
return state.versions?.find(v => v.id === clause.currentVersionId)?.content ?? ''
})
async function pick(id: string) {
open.value = false
if (id !== store.activeId) await store.switchTo?.(id)
}
</script>
<template>
<!-- ld-v2 -->
<div ref="rootEl" class="ws">
<!-- Aucun collectif : inviter à créer -->
<NuxtLink v-if="!current" to="/creer" class="ld-btn ld-btn--ghost ws__create">
<UIcon name="i-lucide-sparkles" />
<span>Créer</span>
</NuxtLink>
<template v-else>
<button
class="ws__trigger"
:aria-expanded="open"
aria-haspopup="menu"
@click="open = !open"
>
<span class="ws__dot" :style="{ background: current.color }" />
<span class="ws__text">
<span class="ws__name">{{ current.name }}</span>
<span v-if="purpose" class="ws__purpose">{{ purpose }}</span>
</span>
<UIcon
name="i-lucide-chevron-down"
class="ws__chevron"
:class="{ 'ws__chevron--open': open }"
/>
</button>
<Transition name="ws-pop">
<div v-if="open" class="ws__menu ld-card" role="menu">
<button
v-for="entry in index"
:key="entry.id"
class="ws__item"
:class="{ 'ws__item--active': entry.id === store.activeId }"
role="menuitem"
@click="pick(entry.id)"
>
<span class="ws__dot" :style="{ background: entry.color }" />
<span class="ws__item-name">{{ entry.name }}</span>
<UIcon
v-if="entry.id === store.activeId"
name="i-lucide-check"
class="ws__item-check"
/>
</button>
<div class="ws__sep" />
<NuxtLink to="/creer" class="ws__item" role="menuitem">
<UIcon name="i-lucide-sparkles" class="ws__item-icon" />
<span class="ws__item-name">Créer un collectif</span>
</NuxtLink>
<NuxtLink to="/donnees" class="ws__item" role="menuitem">
<UIcon name="i-lucide-hard-drive" class="ws__item-icon" />
<span class="ws__item-name">Données locales</span>
</NuxtLink>
</div>
</Transition>
</template>
</div>
</template>
<style scoped>
.ws {
position: relative;
display: inline-flex;
max-width: 100%;
}
.ws__create {
font-size: 0.875rem;
padding: 0.4rem 1rem;
}
.ws__trigger {
display: flex;
align-items: center;
gap: 0.625rem;
max-width: 20rem;
padding: 0.375rem 0.75rem;
background: none;
border-radius: var(--r-input);
cursor: pointer;
text-align: left;
transition: background 0.12s ease;
}
.ws__trigger:hover {
background: var(--mood-accent-soft);
}
.ws__dot {
width: 0.75rem;
height: 0.75rem;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 1px 3px var(--mood-shadow);
}
.ws__text {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.ws__name {
font-size: 0.9375rem;
font-weight: 700;
color: var(--mood-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws__purpose {
font-size: 0.75rem;
color: var(--mood-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 16rem;
}
.ws__chevron {
font-size: 0.875rem;
color: var(--mood-text-muted);
flex-shrink: 0;
transition: transform 0.15s ease;
}
.ws__chevron--open {
transform: rotate(180deg);
}
/* --- Menu --- */
.ws__menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
z-index: 40;
min-width: 15rem;
max-width: 20rem;
padding: 0.375rem;
box-shadow: var(--shadow-raised);
}
.ws__item {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
min-height: 2.25rem;
padding: 0.5rem 0.75rem;
background: none;
border-radius: var(--r-input);
font-size: 0.875rem;
font-weight: 600;
color: var(--mood-text);
text-decoration: none;
cursor: pointer;
text-align: left;
transition: background 0.12s ease;
}
.ws__item:hover {
background: var(--mood-accent-soft);
}
.ws__item--active {
color: var(--mood-accent);
font-weight: 700;
}
.ws__item-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ws__item-check {
font-size: 0.875rem;
color: var(--mood-accent);
flex-shrink: 0;
}
.ws__item-icon {
font-size: 1rem;
color: var(--mood-text-muted);
flex-shrink: 0;
}
.ws__sep {
height: 1px;
margin: 0.375rem 0.5rem;
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
}
/* --- Transition --- */
.ws-pop-enter-active,
.ws-pop-leave-active {
transition: opacity 0.12s ease, transform 0.12s ease;
}
.ws-pop-enter-from,
.ws-pop-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>