forked from yvv/decision
Menu : Boîte à outils en premier + sidebar collapsible icônes
- Boîte à outils (/tools) en 1ère position dans la nav (desktop + mobile)
- Sidebar desktop repliable (toggle pictos-only, 14rem → 3.75rem)
- labels masqués par transition CSS fluide (max-width + opacity)
- état persisté en localStorage (libred-sidebar-collapsed)
- Page document : toggle Vue structurée / Aperçu document
- sous-toggle En vigueur / Selon les votes
- composant DocumentPreview (rendu PDF-like)
- filigrane discret, items ordonnés par sort_order
- mode projection : proposed_text substitu + encadrement orange
- footer horodaté, print-friendly
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,11 @@ const route = useRoute()
|
|||||||
const { initMood } = useMood()
|
const { initMood } = useMood()
|
||||||
|
|
||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
|
{
|
||||||
|
label: 'Boîte à outils',
|
||||||
|
icon: 'i-lucide-wrench',
|
||||||
|
to: '/tools',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Documents',
|
label: 'Documents',
|
||||||
icon: 'i-lucide-book-open',
|
icon: 'i-lucide-book-open',
|
||||||
@@ -34,6 +39,9 @@ const navigationItems = [
|
|||||||
/** Mobile drawer state. */
|
/** Mobile drawer state. */
|
||||||
const mobileMenuOpen = ref(false)
|
const mobileMenuOpen = ref(false)
|
||||||
|
|
||||||
|
/** Sidebar collapse state (icons-only mode). */
|
||||||
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
||||||
/** Close mobile menu on route change. */
|
/** Close mobile menu on route change. */
|
||||||
watch(() => route.path, () => {
|
watch(() => route.path, () => {
|
||||||
mobileMenuOpen.value = false
|
mobileMenuOpen.value = false
|
||||||
@@ -43,8 +51,14 @@ watch(() => route.path, () => {
|
|||||||
const ws = useWebSocket()
|
const ws = useWebSocket()
|
||||||
const { setupWsNotifications } = useNotifications()
|
const { setupWsNotifications } = useNotifications()
|
||||||
|
|
||||||
|
watch(sidebarCollapsed, (val) => {
|
||||||
|
localStorage.setItem('libred-sidebar-collapsed', String(val))
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
initMood()
|
initMood()
|
||||||
|
const savedCollapsed = localStorage.getItem('libred-sidebar-collapsed')
|
||||||
|
if (savedCollapsed !== null) sidebarCollapsed.value = savedCollapsed === 'true'
|
||||||
auth.hydrateFromStorage()
|
auth.hydrateFromStorage()
|
||||||
if (auth.token) {
|
if (auth.token) {
|
||||||
try {
|
try {
|
||||||
@@ -177,7 +191,7 @@ function isActive(to: string) {
|
|||||||
<!-- Main content with sidebar -->
|
<!-- Main content with sidebar -->
|
||||||
<div class="app-body">
|
<div class="app-body">
|
||||||
<!-- Desktop sidebar -->
|
<!-- Desktop sidebar -->
|
||||||
<aside class="app-sidebar">
|
<aside class="app-sidebar" :class="{ 'app-sidebar--collapsed': sidebarCollapsed }">
|
||||||
<nav class="app-sidebar__nav">
|
<nav class="app-sidebar__nav">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="item in navigationItems"
|
v-for="item in navigationItems"
|
||||||
@@ -186,9 +200,21 @@ function isActive(to: string) {
|
|||||||
class="app-sidebar__link"
|
class="app-sidebar__link"
|
||||||
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
|
:class="{ 'app-sidebar__link--active': isActive(item.to) }"
|
||||||
>
|
>
|
||||||
<UIcon :name="item.icon" class="text-lg" />
|
<UIcon :name="item.icon" class="text-lg flex-shrink-0" />
|
||||||
<span>{{ item.label }}</span>
|
<span class="app-sidebar__link-label">{{ item.label }}</span>
|
||||||
</NuxtLink>
|
</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="text-base flex-shrink-0"
|
||||||
|
/>
|
||||||
|
<span class="app-sidebar__link-label">Replier</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -459,6 +485,12 @@ function isActive(to: string) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--mood-surface);
|
background: var(--mood-surface);
|
||||||
display: none;
|
display: none;
|
||||||
|
transition: width 0.22s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar--collapsed {
|
||||||
|
width: 3.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -470,7 +502,7 @@ function isActive(to: string) {
|
|||||||
.app-sidebar__nav {
|
.app-sidebar__nav {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 3.5rem;
|
top: 3.5rem;
|
||||||
padding: 1rem 0.75rem;
|
padding: 1rem 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@@ -480,13 +512,15 @@ function isActive(to: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
padding: 0.625rem 0.875rem;
|
padding: 0.625rem 0.75rem;
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--mood-text-muted);
|
color: var(--mood-text-muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: all 0.12s ease;
|
transition: all 0.12s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-sidebar__link:hover {
|
.app-sidebar__link:hover {
|
||||||
@@ -500,6 +534,56 @@ function isActive(to: string) {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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: 12px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* === Mobile nav === */
|
/* === Mobile nav === */
|
||||||
.app-mobile-nav {
|
.app-mobile-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
347
frontend/app/components/documents/DocumentPreview.vue
Normal file
347
frontend/app/components/documents/DocumentPreview.vue
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* DocumentPreview — clean "PDF-like" viewer for a reference document.
|
||||||
|
*
|
||||||
|
* Two modes:
|
||||||
|
* - current: shows current_text for every item (document en vigueur)
|
||||||
|
* - projected: applies latest "vote" or "proposed" version per item,
|
||||||
|
* highlighting changed clauses (document tel qu'il serait si les
|
||||||
|
* votes en cours passaient)
|
||||||
|
*/
|
||||||
|
import type { Document, DocumentItem, ItemVersion } from '~/stores/documents'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
document: Document
|
||||||
|
items: DocumentItem[]
|
||||||
|
mode: 'current' | 'projected'
|
||||||
|
versionMap: Record<string, ItemVersion | null>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sortedItems = computed(() =>
|
||||||
|
[...props.items].sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
)
|
||||||
|
|
||||||
|
const changedCount = computed(() =>
|
||||||
|
props.mode === 'projected'
|
||||||
|
? Object.values(props.versionMap).filter(Boolean).length
|
||||||
|
: 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
function getDisplayText(item: DocumentItem): string {
|
||||||
|
if (props.mode === 'projected' && props.versionMap[item.id]) {
|
||||||
|
return props.versionMap[item.id]!.proposed_text
|
||||||
|
}
|
||||||
|
return item.current_text
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChanged(item: DocumentItem): boolean {
|
||||||
|
return props.mode === 'projected' && !!props.versionMap[item.id]
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemTypeLabel(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'clause': return 'Engagement'
|
||||||
|
case 'rule': return 'Variable'
|
||||||
|
case 'verification': return 'Application'
|
||||||
|
case 'preamble': return 'Préambule'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date().toLocaleDateString('fr-FR', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="doc-preview">
|
||||||
|
<!-- ══ Document header ══ -->
|
||||||
|
<div class="doc-preview__header">
|
||||||
|
<div class="doc-preview__watermark" aria-hidden="true">
|
||||||
|
{{ mode === 'projected' ? 'PROJECTION' : 'EN VIGUEUR' }}
|
||||||
|
</div>
|
||||||
|
<h1 class="doc-preview__title">{{ document.title }}</h1>
|
||||||
|
<div class="doc-preview__meta">
|
||||||
|
<span>Version {{ document.version }}</span>
|
||||||
|
<span class="doc-preview__sep">·</span>
|
||||||
|
<span>{{ document.items_count }} items</span>
|
||||||
|
<span v-if="mode === 'projected'" class="doc-preview__proj-badge">
|
||||||
|
<UIcon name="i-lucide-flask-conical" class="text-xs" />
|
||||||
|
{{ changedCount }} modification{{ changedCount > 1 ? 's' : '' }} projetée{{ changedCount > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="doc-preview__current-badge">
|
||||||
|
<UIcon name="i-lucide-circle-check" class="text-xs" />
|
||||||
|
Texte officiel
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══ Items ══ -->
|
||||||
|
<div class="doc-preview__body">
|
||||||
|
<template v-for="item in sortedItems" :key="item.id">
|
||||||
|
<!-- Section heading -->
|
||||||
|
<div v-if="item.item_type === 'section'" class="doc-preview__section">
|
||||||
|
<h2 class="doc-preview__section-title">
|
||||||
|
<UIcon name="i-lucide-bookmark" class="text-sm" style="color: var(--mood-accent)" />
|
||||||
|
{{ item.title || item.current_text }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preamble -->
|
||||||
|
<div v-else-if="item.item_type === 'preamble'" class="doc-preview__preamble">
|
||||||
|
<MarkdownRenderer :content="getDisplayText(item)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Regular clause / rule / verification -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="doc-preview__item"
|
||||||
|
:class="{ 'doc-preview__item--changed': isChanged(item) }"
|
||||||
|
>
|
||||||
|
<div class="doc-preview__item-head">
|
||||||
|
<span class="doc-preview__item-pos">{{ item.position }}</span>
|
||||||
|
<span v-if="item.title" class="doc-preview__item-title">{{ item.title }}</span>
|
||||||
|
<span v-if="itemTypeLabel(item.item_type)" class="doc-preview__item-type">
|
||||||
|
{{ itemTypeLabel(item.item_type) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="isChanged(item)" class="doc-preview__change-chip">
|
||||||
|
<UIcon name="i-lucide-git-branch" class="text-xs" />
|
||||||
|
Vote en cours
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="doc-preview__item-text">
|
||||||
|
<MarkdownRenderer :content="getDisplayText(item)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ══ Footer ══ -->
|
||||||
|
<div class="doc-preview__footer">
|
||||||
|
<div class="doc-preview__footer-main">
|
||||||
|
<span>ğ(Decision) · {{ document.title }} · v{{ document.version }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="mode === 'projected'" class="doc-preview__footer-note">
|
||||||
|
Projection non officielle — texte simulé selon {{ changedCount }} vote{{ changedCount > 1 ? 's' : '' }} en cours au {{ today }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.doc-preview {
|
||||||
|
position: relative;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: clamp(1.5rem, 4vw, 3rem);
|
||||||
|
box-shadow: 0 4px 32px var(--mood-shadow);
|
||||||
|
line-height: 1.75;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Watermark ── */
|
||||||
|
.doc-preview__watermark {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(-35deg);
|
||||||
|
font-size: clamp(2rem, 8vw, 5rem);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
opacity: 0.03;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ── */
|
||||||
|
.doc-preview__header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
padding-bottom: 1.5rem;
|
||||||
|
border-bottom: 2px solid color-mix(in srgb, var(--mood-accent) 20%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__title {
|
||||||
|
font-size: clamp(1.25rem, 3vw, 1.875rem);
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--mood-text);
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
margin-bottom: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__sep { opacity: 0.3; }
|
||||||
|
|
||||||
|
.doc-preview__proj-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 2px 0.625rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 15%, transparent);
|
||||||
|
color: var(--mood-warning, #d97706);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__current-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 2px 0.625rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-success, #16a34a) 12%, transparent);
|
||||||
|
color: var(--mood-success, #16a34a);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Body ── */
|
||||||
|
.doc-preview__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section heading */
|
||||||
|
.doc-preview__section {
|
||||||
|
padding-top: 1.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--mood-accent) 15%, transparent);
|
||||||
|
padding-bottom: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Preamble */
|
||||||
|
.doc-preview__preamble {
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 5%, transparent);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item */
|
||||||
|
.doc-preview__item {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, var(--mood-bg) 35%, transparent);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item--changed {
|
||||||
|
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 8%, var(--mood-surface));
|
||||||
|
border-left: 3px solid var(--mood-warning, #f59e0b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-pos {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 800;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
min-width: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-type {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__change-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 1px 0.5rem;
|
||||||
|
background: color-mix(in srgb, var(--mood-warning, #f59e0b) 18%, transparent);
|
||||||
|
color: var(--mood-warning, #d97706);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__item-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--mood-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Footer ── */
|
||||||
|
.doc-preview__footer {
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--mood-accent) 12%, transparent);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__footer-main {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-preview__footer-note {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Print ── */
|
||||||
|
@media print {
|
||||||
|
.doc-preview {
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 2rem;
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
}
|
||||||
|
.doc-preview__watermark { opacity: 0.05; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
* - Permanent vote signage
|
* - Permanent vote signage
|
||||||
* - Tuto overlay
|
* - Tuto overlay
|
||||||
*/
|
*/
|
||||||
import type { DocumentItem } from '~/stores/documents'
|
import type { DocumentItem, ItemVersion } from '~/stores/documents'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const documents = useDocumentsStore()
|
const documents = useDocumentsStore()
|
||||||
@@ -138,6 +138,40 @@ async function archiveToSanctuary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── View mode (editorial vs preview) ────────────────────────
|
||||||
|
|
||||||
|
type ViewMode = 'editorial' | 'preview'
|
||||||
|
type PreviewMode = 'current' | 'projected'
|
||||||
|
|
||||||
|
const viewMode = ref<ViewMode>('editorial')
|
||||||
|
const previewMode = ref<PreviewMode>('current')
|
||||||
|
const versionsLoaded = ref(false)
|
||||||
|
|
||||||
|
async function activatePreview() {
|
||||||
|
viewMode.value = 'preview'
|
||||||
|
if (!versionsLoaded.value && documents.items.length > 0) {
|
||||||
|
const itemIds = documents.items.map(i => i.id)
|
||||||
|
await documents.fetchAllItemVersions(slug.value, itemIds)
|
||||||
|
versionsLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map item_id → the active version under vote (or null). */
|
||||||
|
const activeVersionByItem = computed((): Record<string, ItemVersion | null> => {
|
||||||
|
const map: Record<string, ItemVersion | null> = {}
|
||||||
|
for (const item of documents.items) {
|
||||||
|
const versions = documents.allItemVersions[item.id] || []
|
||||||
|
map[item.id] = versions.find(v => v.status === 'vote')
|
||||||
|
|| versions.find(v => v.status === 'proposed')
|
||||||
|
|| null
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasProjectedChanges = computed(() =>
|
||||||
|
Object.values(activeVersionByItem.value).some(v => v !== null),
|
||||||
|
)
|
||||||
|
|
||||||
// ─── Active section (scroll spy) ──────────────────────────────
|
// ─── Active section (scroll spy) ──────────────────────────────
|
||||||
|
|
||||||
const activeSection = ref<string | null>(null)
|
const activeSection = ref<string | null>(null)
|
||||||
@@ -285,8 +319,69 @@ function toggleSection(tag: string) {
|
|||||||
:genesis-json="documents.current.genesis_json"
|
:genesis-json="documents.current.genesis_json"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- ═══ VIEW MODE TOGGLE ═══ -->
|
||||||
|
<div class="doc-page__view-toggle">
|
||||||
|
<div class="doc-page__view-tabs">
|
||||||
|
<button
|
||||||
|
class="doc-page__view-tab"
|
||||||
|
:class="{ 'doc-page__view-tab--active': viewMode === 'editorial' }"
|
||||||
|
@click="viewMode = 'editorial'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-layout-list" class="text-sm" />
|
||||||
|
Vue structurée
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="doc-page__view-tab"
|
||||||
|
:class="{ 'doc-page__view-tab--active': viewMode === 'preview' }"
|
||||||
|
@click="activatePreview"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-file-text" class="text-sm" />
|
||||||
|
Aperçu document
|
||||||
|
<span v-if="documents.loadingVersions" class="doc-page__view-loading">
|
||||||
|
<UIcon name="i-lucide-loader-circle" class="text-xs animate-spin" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview sub-mode (shown only in preview mode) -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="viewMode === 'preview'" class="doc-page__preview-modes">
|
||||||
|
<button
|
||||||
|
class="doc-page__preview-mode"
|
||||||
|
:class="{ 'doc-page__preview-mode--active': previewMode === 'current' }"
|
||||||
|
@click="previewMode = 'current'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-circle-check" class="text-xs" />
|
||||||
|
En vigueur
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="doc-page__preview-mode"
|
||||||
|
:class="{ 'doc-page__preview-mode--active': previewMode === 'projected' }"
|
||||||
|
:disabled="!hasProjectedChanges"
|
||||||
|
:title="!hasProjectedChanges ? 'Aucun vote en cours sur ce document' : 'Simuler les votes en cours'"
|
||||||
|
@click="previewMode = 'projected'"
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-flask-conical" class="text-xs" />
|
||||||
|
Selon les votes
|
||||||
|
<span v-if="hasProjectedChanges" class="doc-page__preview-dot" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ DOCUMENT PREVIEW ═══ -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<DocumentPreview
|
||||||
|
v-if="viewMode === 'preview'"
|
||||||
|
:document="documents.current"
|
||||||
|
:items="documents.items"
|
||||||
|
:mode="previewMode"
|
||||||
|
:version-map="activeVersionByItem"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- ═══ SECTION NAVIGATOR ═══ -->
|
<!-- ═══ SECTION NAVIGATOR ═══ -->
|
||||||
<div v-if="sections.length > 1" class="doc-page__section-nav">
|
<div v-if="sections.length > 1 && viewMode === 'editorial'" class="doc-page__section-nav">
|
||||||
<button
|
<button
|
||||||
v-for="section in sections"
|
v-for="section in sections"
|
||||||
:key="section.tag"
|
:key="section.tag"
|
||||||
@@ -301,7 +396,7 @@ function toggleSection(tag: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ SECTIONS WITH ITEMS ═══ -->
|
<!-- ═══ SECTIONS WITH ITEMS ═══ -->
|
||||||
<div class="doc-page__sections">
|
<div v-if="viewMode === 'editorial'" class="doc-page__sections">
|
||||||
<div
|
<div
|
||||||
v-for="section in sections"
|
v-for="section in sections"
|
||||||
:key="section.tag"
|
:key="section.tag"
|
||||||
@@ -590,6 +685,115 @@ function toggleSection(tag: string) {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* View mode toggle */
|
||||||
|
.doc-page__view-toggle {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--mood-surface);
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 14px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.5rem 0.875rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
background: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-tab:hover {
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-tab--active {
|
||||||
|
color: var(--mood-accent);
|
||||||
|
background: var(--mood-accent-soft);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__view-loading {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-modes {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mood-text-muted);
|
||||||
|
background: var(--mood-surface);
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode:hover:not(:disabled) {
|
||||||
|
color: var(--mood-text);
|
||||||
|
background: color-mix(in srgb, var(--mood-accent) 10%, var(--mood-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode--active {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-mode--active:hover:not(:disabled) {
|
||||||
|
background: var(--mood-accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-page__preview-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mood-warning, #f59e0b);
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade transition */
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Section collapse transition */
|
/* Section collapse transition */
|
||||||
.section-collapse-enter-active,
|
.section-collapse-enter-active,
|
||||||
.section-collapse-leave-active {
|
.section-collapse-leave-active {
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ interface DocumentsState {
|
|||||||
current: Document | null
|
current: Document | null
|
||||||
items: DocumentItem[]
|
items: DocumentItem[]
|
||||||
versions: ItemVersion[]
|
versions: ItemVersion[]
|
||||||
|
allItemVersions: Record<string, ItemVersion[]>
|
||||||
|
loadingVersions: boolean
|
||||||
loading: boolean
|
loading: boolean
|
||||||
error: string | null
|
error: string | null
|
||||||
}
|
}
|
||||||
@@ -78,6 +80,8 @@ export const useDocumentsStore = defineStore('documents', {
|
|||||||
current: null,
|
current: null,
|
||||||
items: [],
|
items: [],
|
||||||
versions: [],
|
versions: [],
|
||||||
|
allItemVersions: {},
|
||||||
|
loadingVersions: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
@@ -247,6 +251,28 @@ export const useDocumentsStore = defineStore('documents', {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all versions for every item in a document (parallel).
|
||||||
|
* Used to compute the "projected" view (document as-if all active votes passed).
|
||||||
|
*/
|
||||||
|
async fetchAllItemVersions(slug: string, itemIds: string[]) {
|
||||||
|
this.loadingVersions = true
|
||||||
|
try {
|
||||||
|
const { $api } = useApi()
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
itemIds.map(id => $api<ItemVersion[]>(`/documents/${slug}/items/${id}/versions`)),
|
||||||
|
)
|
||||||
|
const map: Record<string, ItemVersion[]> = {}
|
||||||
|
itemIds.forEach((id, i) => {
|
||||||
|
const r = results[i]
|
||||||
|
map[id] = r.status === 'fulfilled' ? r.value : []
|
||||||
|
})
|
||||||
|
this.allItemVersions = map
|
||||||
|
} finally {
|
||||||
|
this.loadingVersions = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Archive a document into the Sanctuary.
|
* Archive a document into the Sanctuary.
|
||||||
*/
|
*/
|
||||||
@@ -280,6 +306,8 @@ export const useDocumentsStore = defineStore('documents', {
|
|||||||
this.current = null
|
this.current = null
|
||||||
this.items = []
|
this.items = []
|
||||||
this.versions = []
|
this.versions = []
|
||||||
|
this.allItemVersions = {}
|
||||||
|
this.loadingVersions = false
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user