Compare commits
15 Commits
develop
...
3a5c40a886
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a5c40a886 | ||
|
|
fbc2867163 | ||
|
|
082a17d09b | ||
|
|
97ba6dd04c | ||
|
|
f0338cca5e | ||
|
|
c6b9abf2f3 | ||
|
|
df0409fec3 | ||
|
|
1af00cc64c | ||
|
|
8a38c86794 | ||
|
|
17f39e735d | ||
|
|
7ea19e2247 | ||
|
|
9525ed3953 | ||
|
|
b02368a15b | ||
| 1f47533c77 | |||
| 07bf07a942 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -18,6 +18,10 @@ logs
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# PDF.js (copié depuis node_modules par postinstall)
|
||||
public/pdfjs/pdf.min.mjs
|
||||
public/pdfjs/pdf.worker.min.mjs
|
||||
|
||||
# Sources originales (PDF, JPG — pas servies par l'appli)
|
||||
sources/
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ Script de gestion : `/home/yvv/Documents/PROD/DEV/dev-ports.sh` (status/kill/cle
|
||||
## Intégration GrateWizard
|
||||
|
||||
- URL dev configurée dans `app/app.config.ts` → `localhost:3001`
|
||||
- URL prod : `https://gratewizard.ml`
|
||||
- URL prod : `https://gratewizard.axiom-team.fr`
|
||||
- Ouverture en popup via `composables/useGrateWizard.ts`
|
||||
- GrateWizard est un projet Next.js séparé (`/home/yvv/Documents/PROD/DEV/GrateWizard`)
|
||||
|
||||
|
||||
@@ -6,13 +6,6 @@ export default defineAppConfig({
|
||||
},
|
||||
header: {
|
||||
height: '4rem',
|
||||
nav: [
|
||||
{ label: 'Autonomie', to: '/autonomie' },
|
||||
{ label: 'Modèle éco', to: '/modele-eco' },
|
||||
{ label: 'En musique', to: '/en-musique' },
|
||||
{ label: 'Évènement', to: '/evenement' },
|
||||
{ label: 'À propos', to: '/a-propos' },
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
credits: '© 2026 Le Librodrome — Productions collectives',
|
||||
@@ -21,10 +14,16 @@ export default defineAppConfig({
|
||||
],
|
||||
},
|
||||
gratewizard: {
|
||||
url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.ml',
|
||||
url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.axiom-team.fr',
|
||||
popup: {
|
||||
width: 420,
|
||||
height: 720,
|
||||
width: 480,
|
||||
height: 860,
|
||||
},
|
||||
},
|
||||
libredecision: {
|
||||
url: import.meta.dev ? 'http://localhost:3002' : 'https://decision.laplank.org',
|
||||
},
|
||||
sejeteral0: {
|
||||
url: import.meta.dev ? 'http://localhost:3009' : 'https://collectivites.librodrome.org',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
/* This file provides fallback and utility classes */
|
||||
|
||||
.font-display {
|
||||
font-family: 'Outfit', system-ui, sans-serif;
|
||||
font-family: 'Syne', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-sans {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-family: 'Space Grotesk', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
:root {
|
||||
--color-primary: 18 80% 45%;
|
||||
--color-accent: 32 85% 50%;
|
||||
--color-bg: 20 10% 7%;
|
||||
--color-surface: 20 10% 12%;
|
||||
--color-surface-light: 20 8% 17%;
|
||||
--color-bg: 215 8% 22%;
|
||||
--color-surface: 213 7% 27%;
|
||||
--color-surface-light: 210 6% 32%;
|
||||
--color-text: 0 0% 100%;
|
||||
--color-text-muted: 0 0% 65%;
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
--player-height: 0rem;
|
||||
--sidebar-width: 280px;
|
||||
|
||||
--font-display: 'Outfit', sans-serif;
|
||||
--font-sans: 'Inter', sans-serif;
|
||||
--font-display: 'Syne', sans-serif;
|
||||
--font-sans: 'Space Grotesk', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<!-- PDF embed -->
|
||||
<div class="pdf-viewport">
|
||||
<iframe
|
||||
:key="pdfUrl"
|
||||
:src="pdfUrl"
|
||||
class="pdf-frame"
|
||||
:title="bpContent?.pdf.iframeTitle"
|
||||
@@ -33,10 +34,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const props = defineProps<{ modelValue: boolean; page?: number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const { data: bpContent } = await usePageContent('book-player')
|
||||
const bookData = useBookData()
|
||||
await bookData.init()
|
||||
|
||||
const overlayRef = ref<HTMLElement>()
|
||||
|
||||
@@ -45,7 +48,12 @@ const isOpen = computed({
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const pdfUrl = '/pdf/une-economie-du-don.pdf'
|
||||
const pdfUrl = computed(() => {
|
||||
const base = bookData.getPdfUrl()
|
||||
const viewerBase = `/pdfjs/viewer.html?file=${encodeURIComponent(base)}`
|
||||
if (props.page) return `${viewerBase}#page=${props.page}`
|
||||
return viewerBase
|
||||
})
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
|
||||
@@ -15,18 +15,28 @@
|
||||
{{ description }}
|
||||
</p>
|
||||
|
||||
<!-- Songs + PDF actions row -->
|
||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
||||
<!-- Associated songs badges -->
|
||||
<div v-if="songs.length > 0" class="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
|
||||
class="song-badge-btn"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<div class="i-lucide-music h-3 w-3" />
|
||||
{{ song.title }}
|
||||
</button>
|
||||
|
||||
<!-- PDF button -->
|
||||
<button v-if="pdfPage" class="pdf-btn" @click="showPdf = true">
|
||||
<div class="i-lucide-book-open h-3.5 w-3.5" />
|
||||
<span>Lire dans le PDF</span>
|
||||
<span class="pdf-page-badge">p.{{ pdfPage }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LazyBookPdfReader v-if="showPdf" v-model="showPdf" :page="pdfPage" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -44,9 +54,12 @@ const props = defineProps<{
|
||||
const bookData = useBookData()
|
||||
const { loadAndPlay } = useAudioPlayer()
|
||||
|
||||
await bookData.init()
|
||||
bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
const pdfPage = computed(() => bookData.getChapterPage(props.chapterSlug))
|
||||
|
||||
const showPdf = ref(false)
|
||||
|
||||
function playSong(song: Song) {
|
||||
loadAndPlay(song)
|
||||
@@ -59,4 +72,57 @@ function playSong(song: Song) {
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.song-badge-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: 9999px;
|
||||
padding: 0.375rem 0.875rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary) / 0.85);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.25);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.song-badge-btn:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pdf-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(0 0% 96%);
|
||||
color: hsl(0 0% 15%);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid hsl(0 0% 88%);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.pdf-btn:hover {
|
||||
background: white;
|
||||
box-shadow: 0 2px 12px hsl(0 0% 100% / 0.25);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.pdf-page-badge {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(0 0% 88%);
|
||||
color: hsl(0 0% 35%);
|
||||
}
|
||||
</style>
|
||||
|
||||
159
app/components/book/ChapterToc.vue
Normal file
159
app/components/book/ChapterToc.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="chapter-toc" ref="tocRoot">
|
||||
<button class="toc-toggle" @click="open = !open" :aria-expanded="open">
|
||||
<div class="i-lucide-list h-4 w-4" />
|
||||
<span>Sommaire</span>
|
||||
<div class="i-lucide-chevron-down h-3.5 w-3.5 toc-chevron" :class="{ 'toc-chevron--open': open }" />
|
||||
</button>
|
||||
|
||||
<Transition name="toc-dropdown">
|
||||
<div v-if="open" class="toc-panel">
|
||||
<nav>
|
||||
<a
|
||||
v-for="item in headings"
|
||||
:key="item.id"
|
||||
:href="`#${item.id}`"
|
||||
class="toc-item"
|
||||
:class="{ 'toc-item--h2': item.level === 2 }"
|
||||
@click="open = false"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface TocHeading {
|
||||
id: string
|
||||
text: string
|
||||
level: number
|
||||
}
|
||||
|
||||
const open = ref(false)
|
||||
const headings = ref<TocHeading[]>([])
|
||||
const tocRoot = ref<HTMLElement>()
|
||||
|
||||
function extractHeadings() {
|
||||
const article = document.querySelector('.prose-lyrics')
|
||||
if (!article) return
|
||||
|
||||
const nodes = article.querySelectorAll('h1, h2')
|
||||
headings.value = Array.from(nodes)
|
||||
.filter(el => el.id)
|
||||
.map(el => ({
|
||||
id: el.id,
|
||||
text: el.textContent?.trim() ?? '',
|
||||
level: parseInt(el.tagName[1]),
|
||||
}))
|
||||
}
|
||||
|
||||
// Close on click outside
|
||||
function onClickOutside(e: MouseEvent) {
|
||||
if (tocRoot.value && !tocRoot.value.contains(e.target as Node)) {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Wait for content to render
|
||||
nextTick(() => {
|
||||
extractHeadings()
|
||||
// Retry after a short delay in case content loads async
|
||||
setTimeout(extractHeadings, 500)
|
||||
})
|
||||
document.addEventListener('click', onClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chapter-toc {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.toc-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(20 8% 10%);
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
color: hsl(20 8% 60%);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.toc-toggle:hover {
|
||||
border-color: hsl(20 8% 30%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toc-chevron {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toc-chevron--open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toc-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 8%);
|
||||
border: 1px solid hsl(20 8% 16%);
|
||||
box-shadow: 0 8px 32px hsl(0 0% 0% / 0.4);
|
||||
}
|
||||
|
||||
.toc-item {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.8rem;
|
||||
color: hsl(20 8% 65%);
|
||||
text-decoration: none;
|
||||
transition: all 0.15s;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.toc-item:hover {
|
||||
background: hsl(20 8% 12%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toc-item--h2 {
|
||||
padding-left: 1.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(20 8% 50%);
|
||||
}
|
||||
|
||||
/* Dropdown transition */
|
||||
.toc-dropdown-enter-active {
|
||||
transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.toc-dropdown-leave-active {
|
||||
transition: all 0.15s ease-in;
|
||||
}
|
||||
.toc-dropdown-enter-from,
|
||||
.toc-dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
309
app/components/home/AxisBlock.vue
Normal file
309
app/components/home/AxisBlock.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<div class="axis-block">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="axis-icon" :class="`axis-icon--${color}`">
|
||||
<div :class="iconClass(icon)" class="h-6 w-6" />
|
||||
</div>
|
||||
<h2 class="font-display text-2xl font-bold text-white">{{ title }}</h2>
|
||||
</div>
|
||||
|
||||
<!-- Items grid -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="(item, i) in items"
|
||||
:key="i"
|
||||
class="axis-item card-surface"
|
||||
:class="{ 'axis-item--gestation': item.gestation }"
|
||||
>
|
||||
<!-- Clickable card body -->
|
||||
<component
|
||||
:is="itemTag(item)"
|
||||
v-bind="itemAttrs(item)"
|
||||
class="axis-item-body"
|
||||
>
|
||||
<!-- Item icon -->
|
||||
<div v-if="item.icon" class="axis-item-icon" :class="`axis-item-icon--${color}`">
|
||||
<span v-if="item.icon === 'g1'" class="axis-item-icon-g1">Ğ1</span>
|
||||
<div v-else :class="iconClass(item.icon)" class="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<h3 class="font-display text-lg font-semibold text-white mb-1">
|
||||
{{ item.label }}
|
||||
<span v-if="item.gestation" class="gestation-badge">
|
||||
<div class="i-lucide-flask-conical h-3 w-3" />
|
||||
En gestation
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-white/60 leading-relaxed">{{ item.description }}</p>
|
||||
</component>
|
||||
|
||||
<!-- Actions zone (separate from card link) -->
|
||||
<div v-if="item.actions?.length" class="axis-actions">
|
||||
<!-- Primary row -->
|
||||
<div class="axis-actions-row">
|
||||
<button
|
||||
v-for="action in primaryActions(item.actions)"
|
||||
:key="action.id"
|
||||
class="axis-action-btn"
|
||||
:class="{ 'axis-action-btn--highlight': action.highlight }"
|
||||
@click.stop="handleAction(action.id)"
|
||||
>
|
||||
<div :class="iconClass(action.icon)" class="h-3.5 w-3.5" />
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Secondary row -->
|
||||
<div v-if="secondaryActions(item.actions).length" class="axis-actions-secondary">
|
||||
<button
|
||||
v-for="action in secondaryActions(item.actions)"
|
||||
:key="action.id"
|
||||
class="axis-action-btn axis-action-btn--secondary"
|
||||
@click.stop="handleAction(action.id)"
|
||||
>
|
||||
<div :class="iconClass(action.icon)" class="h-3.5 w-3.5" />
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface AxisAction {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
highlight?: boolean
|
||||
secondary?: boolean
|
||||
}
|
||||
|
||||
interface AxisItem {
|
||||
label: string
|
||||
description: string
|
||||
to?: string
|
||||
href?: string
|
||||
gestation?: boolean
|
||||
icon?: string
|
||||
actions?: AxisAction[]
|
||||
presentation?: { title: string; text: string }
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
icon: string
|
||||
color?: 'primary' | 'accent'
|
||||
items: AxisItem[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
'launch-gratewizard': []
|
||||
}>()
|
||||
|
||||
function primaryActions(actions: AxisAction[]) {
|
||||
return actions.filter(a => !a.secondary)
|
||||
}
|
||||
|
||||
function secondaryActions(actions: AxisAction[]) {
|
||||
return actions.filter(a => a.secondary)
|
||||
}
|
||||
|
||||
function handleAction(id: string) {
|
||||
if (id === 'open-player') emit('open-player')
|
||||
else if (id === 'open-pdf') emit('open-pdf')
|
||||
else if (id === 'launch-gratewizard') emit('launch-gratewizard')
|
||||
}
|
||||
|
||||
function iconClass(name: string) {
|
||||
return `i-lucide-${name}`
|
||||
}
|
||||
|
||||
function itemTag(item: AxisItem) {
|
||||
if (item.href) return 'a'
|
||||
if (item.to) return resolveComponent('NuxtLink')
|
||||
return 'div'
|
||||
}
|
||||
|
||||
function itemAttrs(item: AxisItem) {
|
||||
if (item.href) return { href: item.href, target: '_blank', rel: 'noopener noreferrer' }
|
||||
if (item.to) return { to: item.to }
|
||||
return {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.axis-block {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.axis-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
border-radius: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.axis-icon--primary {
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.axis-icon--accent {
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
border: 1px solid hsl(var(--color-accent) / 0.2);
|
||||
color: hsl(var(--color-accent));
|
||||
}
|
||||
|
||||
.axis-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-text) / 0.08);
|
||||
background: hsl(var(--color-surface));
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.axis-item:hover {
|
||||
border-color: hsl(var(--color-primary) / 0.25);
|
||||
box-shadow: 0 4px 24px hsl(var(--color-primary) / 0.06);
|
||||
}
|
||||
|
||||
.axis-item--gestation {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.axis-item--gestation:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.axis-item-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1.25rem;
|
||||
flex: 1;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.axis-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.axis-item-icon--primary {
|
||||
background: hsl(var(--color-primary) / 0.18);
|
||||
color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 14px hsl(var(--color-primary) / 0.15);
|
||||
}
|
||||
|
||||
.axis-item-icon--accent {
|
||||
background: hsl(var(--color-accent) / 0.18);
|
||||
color: hsl(var(--color-accent));
|
||||
box-shadow: 0 0 14px hsl(var(--color-accent) / 0.15);
|
||||
}
|
||||
|
||||
.axis-item-icon-g1 {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.gestation-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
color: hsl(var(--color-accent));
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.axis-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border-top: 1px solid hsl(var(--color-text) / 0.06);
|
||||
background: hsl(var(--color-bg) / 0.4);
|
||||
}
|
||||
|
||||
.axis-actions-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.axis-actions-secondary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1.25rem 0.75rem;
|
||||
border-top: 1px solid hsl(var(--color-text) / 0.04);
|
||||
}
|
||||
|
||||
.axis-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-text) / 0.7);
|
||||
background: hsl(var(--color-text) / 0.05);
|
||||
border: 1px solid hsl(var(--color-text) / 0.1);
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.axis-action-btn:hover {
|
||||
color: hsl(var(--color-text));
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
}
|
||||
|
||||
.axis-action-btn--highlight {
|
||||
color: hsl(var(--color-primary));
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
border-color: hsl(var(--color-primary) / 0.25);
|
||||
}
|
||||
|
||||
.axis-action-btn--highlight:hover {
|
||||
background: hsl(var(--color-primary) / 0.2);
|
||||
border-color: hsl(var(--color-primary) / 0.4);
|
||||
}
|
||||
|
||||
.axis-action-btn--secondary {
|
||||
color: hsl(var(--color-text) / 0.45);
|
||||
background: transparent;
|
||||
border-color: hsl(var(--color-text) / 0.06);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.axis-action-btn--secondary:hover {
|
||||
color: hsl(var(--color-accent));
|
||||
background: hsl(var(--color-accent) / 0.08);
|
||||
border-color: hsl(var(--color-accent) / 0.2);
|
||||
}
|
||||
</style>
|
||||
145
app/components/home/AxisGrid.vue
Normal file
145
app/components/home/AxisGrid.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<section class="section-padding">
|
||||
<div class="container-content flex flex-col gap-16">
|
||||
<UiScrollReveal>
|
||||
<div id="numerique">
|
||||
<HomeAxisBlock
|
||||
v-if="axes?.numerique"
|
||||
:title="axes.numerique.title"
|
||||
:icon="axes.numerique.icon"
|
||||
color="primary"
|
||||
:items="axes.numerique.items"
|
||||
/>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="100">
|
||||
<div id="economique">
|
||||
<HomeAxisBlock
|
||||
v-if="axes?.economie"
|
||||
:title="axes.economie.title"
|
||||
:icon="axes.economie.icon"
|
||||
color="accent"
|
||||
:items="axes.economie.items"
|
||||
@open-player="$emit('open-player')"
|
||||
@open-pdf="$emit('open-pdf')"
|
||||
@launch-gratewizard="launchGW"
|
||||
/>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="200">
|
||||
<div id="citoyenne">
|
||||
<HomeAxisBlock
|
||||
v-if="axes?.politique"
|
||||
:title="axes.politique.title"
|
||||
:icon="axes.politique.icon"
|
||||
color="primary"
|
||||
:items="axes.politique.items"
|
||||
/>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- Bloc Événement -->
|
||||
<UiScrollReveal v-if="evenement" :delay="300">
|
||||
<NuxtLink :to="evenement.to" class="event-block">
|
||||
<div class="event-content">
|
||||
<div class="event-icon">
|
||||
<div class="i-lucide-calendar-heart h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="font-display text-2xl font-bold text-white sm:text-3xl">
|
||||
{{ evenement.title }}
|
||||
</h2>
|
||||
<p class="font-display text-xl text-white/70 sm:text-2xl">
|
||||
{{ evenement.subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="evenement.gestation" class="event-badge">
|
||||
<div class="i-lucide-flask-conical h-3.5 w-3.5" />
|
||||
En gestation
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits<{
|
||||
'open-player': []
|
||||
'open-pdf': []
|
||||
}>()
|
||||
|
||||
const { data: content } = await usePageContent('home')
|
||||
const { launch } = useGrateWizard()
|
||||
|
||||
const axes = computed(() => (content.value as any)?.axes)
|
||||
const evenement = computed(() => (content.value as any)?.evenement)
|
||||
|
||||
function launchGW() {
|
||||
launch()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
padding: 2rem 2.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 2px solid hsl(var(--color-accent) / 0.25);
|
||||
background: linear-gradient(135deg, hsl(var(--color-accent) / 0.08), hsl(var(--color-primary) / 0.04));
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.event-block:hover {
|
||||
border-color: hsl(var(--color-accent) / 0.45);
|
||||
box-shadow: 0 0 40px hsl(var(--color-accent) / 0.08);
|
||||
}
|
||||
|
||||
.event-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.event-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-accent) / 0.15);
|
||||
color: hsl(var(--color-accent));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-accent) / 0.12);
|
||||
color: hsl(var(--color-accent));
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.event-block {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,110 +0,0 @@
|
||||
<template>
|
||||
<section class="section-padding">
|
||||
<div class="container-content">
|
||||
<UiScrollReveal>
|
||||
<div class="gw-card relative overflow-hidden">
|
||||
<!-- Shadok blob -->
|
||||
<svg class="shadok-blob" viewBox="0 0 200 180" fill="none" aria-hidden="true">
|
||||
<path d="M60 90 Q30 50 70 30 Q110 10 140 40 Q180 60 170 100 Q165 140 130 155 Q90 170 55 145 Q25 125 60 90Z" fill="currentColor" opacity="0.12"/>
|
||||
<path d="M60 90 Q30 50 70 30 Q110 10 140 40 Q180 60 170 100 Q165 140 130 155 Q90 170 55 145 Q25 125 60 90Z" stroke="currentColor" stroke-width="1.5" opacity="0.2"/>
|
||||
<circle cx="100" cy="80" r="8" fill="currentColor" opacity="0.08"/>
|
||||
<circle cx="120" cy="110" r="6" fill="currentColor" opacity="0.06"/>
|
||||
<circle cx="80" cy="105" r="5" fill="currentColor" opacity="0.07"/>
|
||||
<circle cx="95" cy="72" r="3" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="108" cy="70" r="3" fill="currentColor" opacity="0.3"/>
|
||||
<circle cx="96" cy="71" r="1.2" fill="currentColor" opacity="0.5"/>
|
||||
<circle cx="109" cy="69" r="1.2" fill="currentColor" opacity="0.5"/>
|
||||
</svg>
|
||||
<div class="flex flex-col items-center text-center gap-4 md:flex-row md:text-left md:gap-8 relative z-1">
|
||||
<!-- Icon -->
|
||||
<div class="gw-icon-wrapper">
|
||||
<div class="i-lucide-sparkles h-8 w-8 text-amber-400" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1">
|
||||
<span class="inline-block mb-2 rounded-full bg-amber-400/15 px-3 py-0.5 font-mono text-xs tracking-widest text-amber-400 uppercase">
|
||||
{{ content?.grateWizardTeaser.kicker }}
|
||||
</span>
|
||||
<h3 class="heading-h3 font-display font-bold text-white">
|
||||
{{ content?.grateWizardTeaser.title }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-white/60 md:text-base leading-relaxed">
|
||||
{{ content?.grateWizardTeaser.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CTAs -->
|
||||
<div class="shrink-0 flex flex-col gap-2">
|
||||
<UiBaseButton :href="url" target="_blank" @click="launch">
|
||||
<div class="i-lucide-external-link mr-2 h-4 w-4" />
|
||||
{{ content?.grateWizardTeaser.cta.launch }}
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="ghost" :to="content?.grateWizardTeaser.cta.more.to">
|
||||
{{ content?.grateWizardTeaser.cta.more.label }}
|
||||
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { url, launch } = useGrateWizard()
|
||||
const { data: content } = await usePageContent('home')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gw-card {
|
||||
border: 1px solid hsl(40 80% 50% / 0.2);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem 2rem;
|
||||
background: linear-gradient(135deg, hsl(40 80% 50% / 0.05), hsl(40 80% 50% / 0.02));
|
||||
box-shadow: 0 0 40px hsl(40 80% 50% / 0.05);
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.gw-card:hover {
|
||||
border-color: hsl(40 80% 50% / 0.35);
|
||||
box-shadow: 0 0 60px hsl(40 80% 50% / 0.1);
|
||||
}
|
||||
|
||||
.heading-h3 {
|
||||
font-size: clamp(1.25rem, 3vw, 1.625rem);
|
||||
}
|
||||
|
||||
.gw-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(40 80% 50% / 0.1);
|
||||
border: 1px solid hsl(40 80% 50% / 0.15);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shadok-blob {
|
||||
position: absolute;
|
||||
right: -2%;
|
||||
top: -20%;
|
||||
width: clamp(120px, 16vw, 220px);
|
||||
opacity: 0.35;
|
||||
pointer-events: none;
|
||||
color: hsl(var(--color-accent));
|
||||
animation: shadok-drift 12s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shadok-drift {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-8px) rotate(3deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shadok-blob { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<section class="relative overflow-hidden section-padding">
|
||||
<section class="relative overflow-hidden section-padding hero-section">
|
||||
<!-- Background gradient -->
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-primary/10 via-transparent to-surface-bg" />
|
||||
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(12_76%_48%/0.15),transparent_70%)]" />
|
||||
<div class="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(12_76%_48%/0.12),transparent_70%)]" />
|
||||
|
||||
<!-- Shadok bird decoration -->
|
||||
<svg class="shadok-bird" viewBox="0 0 180 260" fill="none" aria-hidden="true">
|
||||
@@ -23,7 +23,7 @@
|
||||
<path d="M48 105 Q25 102 12 100" stroke="currentColor" stroke-width="3" stroke-linecap="round" opacity="0.3" fill="none"/>
|
||||
</svg>
|
||||
|
||||
<!-- Shadok boulanger: character with oven and bread -->
|
||||
<!-- Shadok boulanger -->
|
||||
<svg class="shadok-boulanger" viewBox="0 0 240 300" fill="none" aria-hidden="true">
|
||||
<ellipse cx="120" cy="155" rx="40" ry="48" fill="currentColor" opacity="0.85"/>
|
||||
<circle cx="120" cy="92" r="25" fill="currentColor" opacity="0.8"/>
|
||||
@@ -44,41 +44,11 @@
|
||||
|
||||
<!-- Content -->
|
||||
<div class="container-content relative z-10 px-4">
|
||||
<div class="mx-auto max-w-3xl text-center">
|
||||
<UiScrollReveal>
|
||||
<p class="mb-3 font-mono text-sm tracking-widest text-primary uppercase">
|
||||
{{ content?.hero.kicker }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="100">
|
||||
<h1 class="font-display font-extrabold leading-tight tracking-tight">
|
||||
<span class="hero-title text-gradient">{{ content?.hero.title }}</span>
|
||||
</h1>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="200">
|
||||
<p class="mt-6 text-lg leading-relaxed text-white/60 md:text-xl">
|
||||
{{ content?.hero.subtitle }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="300">
|
||||
<p class="mt-4 text-base leading-relaxed text-white/45">
|
||||
{{ content?.hero.footnote }}
|
||||
</p>
|
||||
</UiScrollReveal>
|
||||
|
||||
<UiScrollReveal :delay="400">
|
||||
<div class="mt-8 flex justify-center">
|
||||
<UiBaseButton variant="ghost" :to="content?.hero.cta.to">
|
||||
{{ content?.hero.cta.label }}
|
||||
<div class="i-lucide-arrow-right ml-2 h-4 w-4" />
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
|
||||
<HomeMessages />
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<HomeTypewriterText
|
||||
v-if="hero"
|
||||
:hero="hero"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -86,11 +56,25 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: content } = await usePageContent('home')
|
||||
|
||||
const hero = computed(() => {
|
||||
const raw = (content.value as any)?.hero
|
||||
if (!raw) return null
|
||||
return {
|
||||
heading: Array.isArray(raw.heading) ? raw.heading : [],
|
||||
citations: Array.isArray(raw.citations) ? raw.citations : [],
|
||||
approach: raw.approach || '',
|
||||
axes: Array.isArray(raw.axes) ? raw.axes : [],
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-title {
|
||||
font-size: clamp(2.25rem, 7vw, 4rem);
|
||||
.hero-section {
|
||||
min-height: 70vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shadok-bird {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="mt-16">
|
||||
<section class="section-padding">
|
||||
<div class="container-content mx-auto max-w-3xl">
|
||||
<!-- Formulaire -->
|
||||
<UiScrollReveal :delay="500">
|
||||
<UiScrollReveal>
|
||||
<div class="message-form-card">
|
||||
<h3 class="font-display text-lg font-bold text-white mb-4">Laisser un message</h3>
|
||||
|
||||
@@ -45,7 +46,7 @@
|
||||
</UiScrollReveal>
|
||||
|
||||
<!-- 2 derniers messages publiés -->
|
||||
<UiScrollReveal v-if="messages?.length" :delay="600">
|
||||
<UiScrollReveal v-if="messages?.length" :delay="100">
|
||||
<div class="mt-8 space-y-4">
|
||||
<h3 class="font-display text-lg font-bold text-white/80 text-center">Derniers messages</h3>
|
||||
<div v-for="msg in messages.slice(0, 2)" :key="msg.id" class="message-card">
|
||||
@@ -65,6 +66,7 @@
|
||||
</div>
|
||||
</UiScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
260
app/components/home/TypewriterText.vue
Normal file
260
app/components/home/TypewriterText.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="hero-content">
|
||||
<!-- 5-line heading with alternating typography -->
|
||||
<div class="hero-heading">
|
||||
<h1 v-if="hero.heading?.length" class="hero-lines">
|
||||
<span
|
||||
v-for="(line, i) in hero.heading"
|
||||
:key="i"
|
||||
class="hero-line"
|
||||
:class="`hero-line--${i + 1}`"
|
||||
>{{ line }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Discrete aside block -->
|
||||
<details class="hero-aside">
|
||||
<summary class="hero-aside-toggle">En savoir plus</summary>
|
||||
|
||||
<div class="hero-aside-body">
|
||||
<!-- Citations -->
|
||||
<blockquote v-if="hero.citations?.length" class="hero-citations">
|
||||
<p v-for="(cite, i) in hero.citations" :key="i" class="hero-cite">
|
||||
{{ cite }}
|
||||
</p>
|
||||
</blockquote>
|
||||
|
||||
<!-- Approach + Axes -->
|
||||
<div v-if="hero.approach" class="hero-approach">
|
||||
<p class="hero-approach-text">{{ hero.approach }}</p>
|
||||
<dl v-if="hero.axes?.length" class="hero-axes">
|
||||
<div v-for="(axis, i) in hero.axes" :key="i" class="hero-axis">
|
||||
<dt>{{ axis.label }}</dt>
|
||||
<dd>{{ axis.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface HeroAxis {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface HeroData {
|
||||
heading: string[]
|
||||
citations: string[]
|
||||
approach: string
|
||||
axes: HeroAxis[]
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
hero: HeroData
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-content {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ── Heading — 5 lines ── */
|
||||
|
||||
.hero-heading {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-lines {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.1em;
|
||||
}
|
||||
|
||||
.hero-line {
|
||||
display: block;
|
||||
font-family: var(--font-display);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* l1: Construire une autonomie collective. — bold, large */
|
||||
.hero-line--1 {
|
||||
font-weight: 700;
|
||||
font-size: clamp(1.45rem, 3.8vw, 2.5rem);
|
||||
color: hsl(var(--color-text) / 0.95);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
/* l2: à l'échelle des bassins de vie — light, same size, softer */
|
||||
.hero-line--2 {
|
||||
font-weight: 300;
|
||||
font-size: clamp(1.3rem, 3.2vw, 2.1rem);
|
||||
color: hsl(var(--color-text) / 0.55);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
/* l3: Pousser les curseurs — accent color, medium */
|
||||
.hero-line--3 {
|
||||
font-weight: 600;
|
||||
font-size: clamp(1.1rem, 2.6vw, 1.6rem);
|
||||
color: hsl(var(--color-accent));
|
||||
margin-top: 0.5em;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* l4: Autonomie numérique, économique, citoyenne. — small-caps feel */
|
||||
.hero-line--4 {
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: clamp(0.9rem, 2vw, 1.15rem);
|
||||
color: hsl(var(--color-text) / 0.65);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
|
||||
/* l5: — s'en donner les moyens — — italic, same tone as l4 */
|
||||
.hero-line--5 {
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: clamp(0.95rem, 2.2vw, 1.2rem);
|
||||
color: hsl(var(--color-text) / 0.65);
|
||||
margin-top: 0.3em;
|
||||
}
|
||||
|
||||
/* ── Discrete aside block ── */
|
||||
|
||||
.hero-aside {
|
||||
width: 100%;
|
||||
max-width: 36em;
|
||||
}
|
||||
|
||||
.hero-aside-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0 auto;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 9999px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-text) / 0.35);
|
||||
border: 1px solid hsl(var(--color-text) / 0.08);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.hero-aside-toggle::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-aside-toggle::before {
|
||||
content: '▸';
|
||||
font-size: 0.65em;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.hero-aside[open] .hero-aside-toggle::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.hero-aside-toggle:hover {
|
||||
color: hsl(var(--color-text) / 0.55);
|
||||
border-color: hsl(var(--color-text) / 0.15);
|
||||
}
|
||||
|
||||
.hero-aside-body {
|
||||
margin-top: 1.25rem;
|
||||
animation: aside-reveal 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes aside-reveal {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Citations ── */
|
||||
|
||||
.hero-citations {
|
||||
margin: 0 0 1.25rem;
|
||||
padding: 0.6rem 0 0.6rem 1rem;
|
||||
border-left: 2px solid hsl(var(--color-accent) / 0.3);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hero-cite {
|
||||
font-family: var(--font-display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--color-text) / 0.45);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-cite + .hero-cite {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Approach + Axes ── */
|
||||
|
||||
.hero-approach {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-approach-text {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--color-text) / 0.35);
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.hero-axes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hero-axis {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-axis dt {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.hero-axis dt::after {
|
||||
content: ' →';
|
||||
color: hsl(var(--color-text) / 0.2);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hero-axis dd {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.88rem;
|
||||
color: hsl(var(--color-text) / 0.5);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -16,8 +16,10 @@
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<nav class="hidden md:flex items-center gap-1" aria-label="Navigation principale">
|
||||
<!-- Autonomie : prefix + axis buttons -->
|
||||
<span class="nav-prefix">Autonomie :</span>
|
||||
<NuxtLink
|
||||
v-for="item in site?.navigation"
|
||||
v-for="item in axes"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="btn-ghost text-sm"
|
||||
@@ -25,6 +27,19 @@
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Separator + extra nav -->
|
||||
<span class="nav-sep" />
|
||||
<NuxtLink
|
||||
v-for="item in extra"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="btn-ghost btn-ghost--muted text-sm"
|
||||
active-class="!text-[hsl(var(--color-text))] bg-[hsl(var(--color-text)/0.06)]"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
|
||||
<UiPaletteSelector />
|
||||
</nav>
|
||||
|
||||
@@ -39,13 +54,17 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="site?.navigation ?? []" />
|
||||
<LayoutNavMobile v-model:open="isMobileMenuOpen" :nav="allNav" />
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { data: site } = await useSiteContent()
|
||||
const isMobileMenuOpen = ref(false)
|
||||
|
||||
const axes = computed(() => (site.value as any)?.navigation?.axes ?? [])
|
||||
const extra = computed(() => (site.value as any)?.navigation?.extra ?? [])
|
||||
const allNav = computed(() => [...axes.value, ...extra.value])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -65,9 +84,34 @@ const isMobileMenuOpen = ref(false)
|
||||
|
||||
.logo-text {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 1.15rem;
|
||||
letter-spacing: 0.02em;
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 400;
|
||||
font-size: 1.25rem;
|
||||
letter-spacing: 0.04em;
|
||||
background-image: linear-gradient(to right, hsl(var(--color-primary)), hsl(var(--color-accent)));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nav-prefix {
|
||||
font-family: var(--font-display);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
letter-spacing: 0.03em;
|
||||
color: hsl(var(--color-text) / 0.4);
|
||||
margin-right: 0.125rem;
|
||||
}
|
||||
|
||||
.nav-sep {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
background: hsl(var(--color-text) / 0.12);
|
||||
margin: 0 0.375rem;
|
||||
}
|
||||
|
||||
.btn-ghost--muted {
|
||||
color: hsl(var(--color-text) / 0.45);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span
|
||||
v-for="song in songs"
|
||||
:key="song.id"
|
||||
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary/70"
|
||||
class="inline-flex items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary/80"
|
||||
>
|
||||
<div class="i-lucide-music h-2.5 w-2.5" />
|
||||
{{ song.title }}
|
||||
@@ -17,7 +17,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const bookData = useBookData()
|
||||
await bookData.init()
|
||||
bookData.init()
|
||||
|
||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Song } from '~/types/song'
|
||||
import type { ChapterSongLink, BookConfig } from '~/types/book'
|
||||
import type { ChapterSongLink, ChapterPageLink, BookConfig } from '~/types/book'
|
||||
|
||||
let _configCache: BookConfig | null = null
|
||||
|
||||
@@ -13,9 +13,11 @@ async function loadConfig(): Promise<BookConfig> {
|
||||
author: parsed.book.author,
|
||||
description: parsed.book.description,
|
||||
coverImage: parsed.book.coverImage,
|
||||
pdfFile: parsed.book.pdfFile,
|
||||
chapters: [],
|
||||
songs: parsed.songs as Song[],
|
||||
chapterSongs: parsed.chapterSongs as ChapterSongLink[],
|
||||
chapterPages: (parsed.chapterPages ?? []) as ChapterPageLink[],
|
||||
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
|
||||
}
|
||||
|
||||
@@ -76,6 +78,14 @@ export function useBookData() {
|
||||
return link?.chapterSlug
|
||||
}
|
||||
|
||||
function getChapterPage(chapterSlug: string): number | undefined {
|
||||
return config.value?.chapterPages.find(cp => cp.chapterSlug === chapterSlug)?.page
|
||||
}
|
||||
|
||||
function getPdfUrl(): string {
|
||||
return config.value?.pdfFile || '/pdf/une-economie-du-don.pdf'
|
||||
}
|
||||
|
||||
function getBookMeta() {
|
||||
if (!config.value) return null
|
||||
return {
|
||||
@@ -98,5 +108,7 @@ export function useBookData() {
|
||||
getChapterForSong,
|
||||
getPlaylistOrder,
|
||||
getBookMeta,
|
||||
getChapterPage,
|
||||
getPdfUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ export function useGrateWizard() {
|
||||
const { url, popup } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
|
||||
|
||||
function launch(e?: Event) {
|
||||
const left = Math.round((window.screen.width - popup.width) / 2)
|
||||
const top = Math.round((window.screen.height - popup.height) / 2)
|
||||
const w = popup.width
|
||||
const h = popup.height
|
||||
const left = Math.round((window.screen.width - w) / 2)
|
||||
const top = Math.round((window.screen.height - h) / 2)
|
||||
const embedUrl = `${url}?embed=true&hideTabBar=true&tab=mn`
|
||||
const win = window.open(
|
||||
url,
|
||||
embedUrl,
|
||||
'grateWizard',
|
||||
`width=${popup.width},height=${popup.height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,scrollbars=yes,resizable=yes`,
|
||||
`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes`,
|
||||
)
|
||||
if (win) e?.preventDefault()
|
||||
}
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
<template>
|
||||
<div class="app-layout grid grid-cols-1 min-h-dvh">
|
||||
<LayoutTheHeader />
|
||||
<main>
|
||||
<main class="app-main">
|
||||
<slot />
|
||||
|
||||
<!-- 益 Yì (Increase, #42) — sceau hexagramme -->
|
||||
<svg class="app-seal" viewBox="0 0 130 100" fill="currentColor" aria-hidden="true">
|
||||
<!-- Line 6 (top) — yang -->
|
||||
<rect x="5" y="5" width="120" height="5" rx="1"/>
|
||||
<!-- Line 5 — yang -->
|
||||
<rect x="5" y="22" width="120" height="5" rx="1"/>
|
||||
<!-- Line 4 — yin -->
|
||||
<rect x="5" y="39" width="49" height="5" rx="1"/>
|
||||
<rect x="76" y="39" width="49" height="5" rx="1"/>
|
||||
<!-- Line 3 — yin -->
|
||||
<rect x="5" y="56" width="49" height="5" rx="1"/>
|
||||
<rect x="76" y="56" width="49" height="5" rx="1"/>
|
||||
<!-- Line 2 — yin -->
|
||||
<rect x="5" y="73" width="49" height="5" rx="1"/>
|
||||
<rect x="76" y="73" width="49" height="5" rx="1"/>
|
||||
<!-- Line 1 (bottom) — yang -->
|
||||
<rect x="5" y="90" width="120" height="5" rx="1"/>
|
||||
</svg>
|
||||
</main>
|
||||
<LayoutTheFooter />
|
||||
</div>
|
||||
@@ -12,4 +31,15 @@
|
||||
.app-layout {
|
||||
grid-template-rows: auto 1fr auto;
|
||||
}
|
||||
|
||||
/* === Seal — 益 Yì (Increase) === */
|
||||
.app-seal {
|
||||
display: block;
|
||||
width: 44px;
|
||||
margin: 2rem 1.5rem 1rem auto;
|
||||
color: hsl(var(--color-accent));
|
||||
opacity: 0.28;
|
||||
filter: drop-shadow(1px 1px 0.5px rgba(0,0,0,0.25))
|
||||
drop-shadow(-0.5px -0.5px 0.5px rgba(255,255,255,0.15));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="font-display text-2xl font-bold text-white">Chapitres</h1>
|
||||
<h1 class="font-display text-2xl font-bold text-white">Livre & chapitres</h1>
|
||||
<AdminSaveButton :saving="saving" :saved="saved" @save="saveOrder" />
|
||||
</div>
|
||||
|
||||
<!-- PDF config -->
|
||||
<div class="pdf-section">
|
||||
<h2 class="font-display text-sm font-semibold text-white/60 mb-3">PDF du livre</h2>
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs text-white/40 mb-1">Chemin du fichier PDF</label>
|
||||
<input
|
||||
v-model="pdfPath"
|
||||
class="admin-input w-full font-mono text-xs"
|
||||
placeholder="/pdf/une-economie-du-don.pdf"
|
||||
/>
|
||||
</div>
|
||||
<button class="save-pdf-btn" @click="savePdfConfig" :disabled="savingPdf">
|
||||
<div v-if="savingPdf" class="i-lucide-loader-2 h-3.5 w-3.5 animate-spin" />
|
||||
<div v-else-if="savedPdf" class="i-lucide-check h-3.5 w-3.5" />
|
||||
<div v-else class="i-lucide-save h-3.5 w-3.5" />
|
||||
{{ savedPdf ? 'Enregistré' : 'Enregistrer' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-white/30 mt-2">
|
||||
Uploadez le PDF via Médias, puis renseignez le chemin ici.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(chapter, i) in chapters"
|
||||
@@ -35,6 +59,20 @@
|
||||
>{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<select
|
||||
v-if="pdfOutline.length"
|
||||
class="page-select"
|
||||
:value="getChapterPage(chapter.slug) ?? ''"
|
||||
@change="setChapterPage(chapter.slug, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">— Page PDF —</option>
|
||||
<option
|
||||
v-for="entry in pdfOutline"
|
||||
:key="`${entry.page}-${entry.title}`"
|
||||
:value="entry.page"
|
||||
>{{ '\u00A0\u00A0'.repeat(entry.level) }}{{ entry.title }} (p.{{ entry.page }})</option>
|
||||
</select>
|
||||
<span v-else class="text-xs text-white/30">…</span>
|
||||
<button
|
||||
class="delete-btn"
|
||||
@click="removeChapter(chapter.slug)"
|
||||
@@ -91,6 +129,58 @@ const saved = ref(false)
|
||||
const newTitle = ref('')
|
||||
const newSlug = ref('')
|
||||
|
||||
// PDF path + outline
|
||||
const pdfPath = ref(bookConfig.value?.book?.pdfFile ?? '/pdf/une-economie-du-don.pdf')
|
||||
const savingPdf = ref(false)
|
||||
const savedPdf = ref(false)
|
||||
|
||||
const pdfOutline = ref<Array<{ title: string; page: number; level: number }>>([])
|
||||
|
||||
|
||||
const chapterPageMap = ref<Record<string, number | undefined>>({})
|
||||
if (bookConfig.value?.chapterPages) {
|
||||
for (const cp of bookConfig.value.chapterPages) {
|
||||
chapterPageMap.value[cp.chapterSlug] = cp.page
|
||||
}
|
||||
}
|
||||
|
||||
function getChapterPage(slug: string): number | undefined {
|
||||
return chapterPageMap.value[slug]
|
||||
}
|
||||
|
||||
function setChapterPage(slug: string, val: string) {
|
||||
const num = parseInt(val, 10)
|
||||
if (num > 0) chapterPageMap.value[slug] = num
|
||||
else delete chapterPageMap.value[slug]
|
||||
}
|
||||
|
||||
// Charger le sommaire PDF côté client via l'API serveur
|
||||
onMounted(() => {
|
||||
$fetch<Array<{ title: string; page: number; level: number }>>('/api/admin/pdf-outline')
|
||||
.then((data) => { pdfOutline.value = data })
|
||||
.catch((err) => { console.warn('PDF outline load failed:', err) })
|
||||
})
|
||||
|
||||
async function savePdfConfig() {
|
||||
if (!bookConfig.value) return
|
||||
savingPdf.value = true
|
||||
savedPdf.value = false
|
||||
try {
|
||||
bookConfig.value.book.pdfFile = pdfPath.value
|
||||
bookConfig.value.chapterPages = Object.entries(chapterPageMap.value)
|
||||
.filter(([, page]) => page != null)
|
||||
.map(([chapterSlug, page]) => ({ chapterSlug, page }))
|
||||
await $fetch('/api/admin/content/config', {
|
||||
method: 'PUT',
|
||||
body: bookConfig.value,
|
||||
})
|
||||
savedPdf.value = true
|
||||
setTimeout(() => { savedPdf.value = false }, 2000)
|
||||
} finally {
|
||||
savingPdf.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Drag & drop state
|
||||
const dragIdx = ref<number | null>(null)
|
||||
const dropIdx = ref<number | null>(null)
|
||||
@@ -156,6 +246,39 @@ async function removeChapter(slug: string) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pdf-section {
|
||||
margin-bottom: 1.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(20 8% 14%);
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(20 8% 5%);
|
||||
}
|
||||
|
||||
.save-pdf-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid hsl(20 8% 25%);
|
||||
background: none;
|
||||
color: hsl(20 8% 55%);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.save-pdf-btn:hover:not(:disabled) {
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
color: hsl(12 76% 68%);
|
||||
}
|
||||
|
||||
.save-pdf-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chapter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -230,6 +353,23 @@ async function removeChapter(slug: string) {
|
||||
border: 1px solid hsl(12 76% 48% / 0.2);
|
||||
}
|
||||
|
||||
.page-select {
|
||||
flex-shrink: 0;
|
||||
max-width: 14rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(20 8% 18%);
|
||||
background: hsl(20 8% 6%);
|
||||
color: hsl(20 8% 65%);
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(12 76% 48% / 0.5);
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0.375rem;
|
||||
|
||||
104
app/pages/decision.vue
Normal file
104
app/pages/decision.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="section-padding">
|
||||
<div class="container-content">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-12">
|
||||
<div class="decision-icon mx-auto mb-6">
|
||||
<div class="i-lucide-gavel h-10 w-10" />
|
||||
</div>
|
||||
<h1 class="font-display text-4xl font-bold text-white mb-4">Plateforme Décision</h1>
|
||||
<p class="text-lg text-white/60 leading-relaxed">
|
||||
Se donner les moyens de la décision collective.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="grid gap-4 sm:grid-cols-2 mb-12">
|
||||
<div v-for="feature in features" :key="feature.title" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<div :class="`i-lucide-${feature.icon} h-5 w-5`" />
|
||||
</div>
|
||||
<h3 class="font-display font-semibold text-white mb-1">{{ feature.title }}</h3>
|
||||
<p class="text-sm text-white/50 leading-relaxed">{{ feature.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA -->
|
||||
<div class="text-center flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
||||
<UiBaseButton :href="decisionUrl" target="_blank">
|
||||
<div class="i-lucide-external-link mr-2 h-4 w-4" />
|
||||
Ouvrir Glibredecision
|
||||
</UiBaseButton>
|
||||
<UiBaseButton variant="ghost" to="/">
|
||||
<div class="i-lucide-arrow-left mr-2 h-4 w-4" />
|
||||
Retour à l'accueil
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({ title: 'Décision collective' })
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
const decisionUrl = (appConfig.libredecision as { url: string }).url
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: 'vote',
|
||||
title: 'Décisions on-chain',
|
||||
text: 'Des décisions transparentes et vérifiables, inscrites sur la blockchain.',
|
||||
},
|
||||
{
|
||||
icon: 'scroll-text',
|
||||
title: 'Les Mandats',
|
||||
text: 'Formaliser et suivre les mandats confiés aux personnes désignées.',
|
||||
},
|
||||
{
|
||||
icon: 'scroll-text',
|
||||
title: 'Documents de référence',
|
||||
text: 'Les textes fondateurs et documents qui encadrent la prise de décision.',
|
||||
},
|
||||
{
|
||||
icon: 'git-branch',
|
||||
title: 'Les Protocoles',
|
||||
text: 'Les règles et processus qui structurent la décision collective.',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.decision-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border: 1px solid hsl(var(--color-primary) / 0.2);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
padding: 1.25rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-text) / 0.08);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
105
app/pages/gestation/[slug].vue
Normal file
105
app/pages/gestation/[slug].vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="section-padding">
|
||||
<div class="container-content">
|
||||
<div class="mx-auto max-w-2xl">
|
||||
<div class="gestation-icon mx-auto mb-6">
|
||||
<div class="i-lucide-flask-conical h-12 w-12 text-accent" />
|
||||
</div>
|
||||
|
||||
<h1 class="font-display text-3xl font-bold text-white mb-4 text-center">
|
||||
{{ item?.label ?? 'En gestation' }}
|
||||
</h1>
|
||||
|
||||
<p class="text-lg text-white/60 leading-relaxed mb-8 text-center">
|
||||
{{ item?.description ?? 'Cette initiative est en cours de préparation.' }}
|
||||
</p>
|
||||
|
||||
<!-- Présentation spécifique -->
|
||||
<div v-if="item?.presentation" class="presentation-card mb-10">
|
||||
<div class="presentation-icon">
|
||||
<div class="i-lucide-rocket h-5 w-5" />
|
||||
</div>
|
||||
<h2 class="font-display text-xl font-semibold text-white mb-2">
|
||||
{{ item.presentation.title }}
|
||||
</h2>
|
||||
<p class="text-white/60 leading-relaxed">
|
||||
{{ item.presentation.text }}
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-white/30 italic">En cours de développement.</p>
|
||||
</div>
|
||||
|
||||
<!-- Bouton SejeteralO pour tarifs-eau -->
|
||||
<div v-if="slug === 'tarifs-eau'" class="text-center mb-10">
|
||||
<UiBaseButton :href="sejeteral0Url" target="_blank">
|
||||
<div class="i-lucide-external-link mr-2 h-4 w-4" />
|
||||
Lancer SejeteralO
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<UiBaseButton variant="ghost" to="/">
|
||||
<div class="i-lucide-arrow-left mr-2 h-4 w-4" />
|
||||
Retour à l'accueil
|
||||
</UiBaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: content } = await usePageContent('home')
|
||||
|
||||
const appConfig = useAppConfig()
|
||||
const sejeteral0Url = (appConfig.sejeteral0 as { url: string }).url
|
||||
|
||||
const item = computed(() => {
|
||||
const axes = (content.value as any)?.axes
|
||||
if (!axes) return null
|
||||
for (const axis of Object.values(axes) as any[]) {
|
||||
for (const it of axis.items ?? []) {
|
||||
if (it.to === `/gestation/${slug}`) return it
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
useHead({
|
||||
title: item.value?.label ?? `En gestation — ${slug}`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gestation-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-accent) / 0.1);
|
||||
border: 1px solid hsl(var(--color-accent) / 0.2);
|
||||
}
|
||||
|
||||
.presentation-card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-primary) / 0.15);
|
||||
background: hsl(var(--color-surface));
|
||||
}
|
||||
|
||||
.presentation-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
color: hsl(var(--color-primary));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<HomeHeroSection />
|
||||
<HomeBookSection @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
|
||||
<HomeGrateWizardTeaser />
|
||||
<HomeAxisGrid @open-player="showBookPlayer = true" @open-pdf="showPdfReader = true" />
|
||||
<HomeMessages />
|
||||
<BookPlayer v-model="showBookPlayer" />
|
||||
<BookPdfReader v-model="showPdfReader" />
|
||||
</div>
|
||||
|
||||
@@ -74,6 +74,13 @@
|
||||
</svg>
|
||||
|
||||
<div class="container-content">
|
||||
<!-- Page de couverture du livre -->
|
||||
<HomeBookSection
|
||||
class="mb-16"
|
||||
@open-player="showBookPlayer = true"
|
||||
@open-pdf="showPdfReader = true"
|
||||
/>
|
||||
|
||||
<header class="mb-12 text-center">
|
||||
<p class="mb-2 font-mono text-sm tracking-widest text-primary uppercase">{{ content?.kicker }}</p>
|
||||
<h1 class="page-title font-display font-bold tracking-tight text-white">
|
||||
@@ -118,6 +125,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BookPlayer v-model="showBookPlayer" />
|
||||
<BookPdfReader v-model="showPdfReader" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -135,6 +145,9 @@ useHead({
|
||||
const { data: chapters } = await useAsyncData('book-toc', () =>
|
||||
queryCollection('book').order('order', 'ASC').all(),
|
||||
)
|
||||
|
||||
const showBookPlayer = ref(false)
|
||||
const showPdfReader = ref(false)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -117,10 +117,10 @@ definePageMeta({
|
||||
layout: 'default',
|
||||
})
|
||||
|
||||
const { data: content } = await usePageContent('autonomie')
|
||||
const { data: content } = await usePageContent('numerique')
|
||||
|
||||
useHead({
|
||||
title: content.value?.meta?.title ?? 'Autonomie',
|
||||
title: content.value?.meta?.title ?? 'Autonomie numérique',
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -12,13 +12,20 @@ export interface ChapterSongLink {
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
export interface ChapterPageLink {
|
||||
chapterSlug: string
|
||||
page: number
|
||||
}
|
||||
|
||||
export interface BookConfig {
|
||||
title: string
|
||||
author: string
|
||||
description: string
|
||||
coverImage?: string
|
||||
pdfFile?: string
|
||||
chapters: ChapterMeta[]
|
||||
songs: import('./song').Song[]
|
||||
chapterSongs: ChapterSongLink[]
|
||||
chapterPages: ChapterPageLink[]
|
||||
defaultPlaylistOrder: string[]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ FROM node:${NODE_VERSION} AS base
|
||||
|
||||
ARG PORT=3000
|
||||
|
||||
WORKDIR /src
|
||||
WORKDIR /app
|
||||
|
||||
# Build
|
||||
FROM base AS build
|
||||
@@ -19,6 +19,7 @@ RUN pnpm rebuild sharp
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN sh scripts/copy-pdfjs.sh
|
||||
RUN pnpm run build
|
||||
|
||||
# Production
|
||||
@@ -27,10 +28,11 @@ FROM base AS production
|
||||
ENV PORT=${PORT}
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=build /src/.output /src/.output
|
||||
COPY --from=build /src/site /src/site
|
||||
RUN apt-get update && apt-get -fy install curl git && rm -rf /var/cache/apt/*
|
||||
|
||||
RUN apt-get update && apt-get -fy install curl && rm -rf /var/cache/apt/*
|
||||
COPY --from=build /app/.output /app/.output
|
||||
COPY --from=build /app/site /app/site
|
||||
COPY --from=build /app/content /app/content
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT}/api/health || exit 1
|
||||
|
||||
@@ -10,6 +10,7 @@ services:
|
||||
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://librodrome.org}
|
||||
NUXT_ADMIN_PASSWORD: ${NUXT_ADMIN_PASSWORD}
|
||||
NUXT_ADMIN_SECRET: ${NUXT_ADMIN_SECRET}
|
||||
ADMIN_GIT_SYNC: ${ADMIN_GIT_SYNC:-false}
|
||||
ports:
|
||||
- 3000
|
||||
volumes:
|
||||
|
||||
@@ -16,6 +16,20 @@ export default defineNuxtConfig({
|
||||
'@nuxt/image',
|
||||
],
|
||||
|
||||
unocss: {
|
||||
safelist: [
|
||||
// Axis block icons (dynamic from YAML)
|
||||
'i-lucide-monitor', 'i-lucide-coins', 'i-lucide-landmark',
|
||||
'i-lucide-code-2', 'i-lucide-share-2', 'i-lucide-cloud',
|
||||
'i-lucide-scale', 'i-lucide-gavel', 'i-lucide-users',
|
||||
'i-lucide-droplets', 'i-lucide-calendar-heart',
|
||||
// Action icons
|
||||
'i-lucide-play', 'i-lucide-book-open', 'i-lucide-sparkles',
|
||||
// Decision page
|
||||
'i-lucide-vote', 'i-lucide-scroll-text', 'i-lucide-git-branch',
|
||||
],
|
||||
},
|
||||
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'fr' },
|
||||
@@ -44,4 +58,13 @@ export default defineNuxtConfig({
|
||||
siteUrl: 'https://librodrome.org',
|
||||
},
|
||||
},
|
||||
|
||||
nitro: {
|
||||
externals: {
|
||||
traceInclude: [
|
||||
'node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
})
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"build": "nuxt build && node scripts/fix-esm-imports.mjs",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
"postinstall": "nuxt prepare && (test -f scripts/copy-pdfjs.sh && sh scripts/copy-pdfjs.sh || true)"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/content": "^3.11.2",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@vueuse/nuxt": "^14.2.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"nuxt": "^4.3.1",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"vue": "^3.5.28",
|
||||
"vue-router": "^4.6.4",
|
||||
"yaml": "^2.8.2"
|
||||
|
||||
141
pnpm-lock.yaml
generated
141
pnpm-lock.yaml
generated
@@ -29,6 +29,9 @@ importers:
|
||||
nuxt:
|
||||
specifier: ^4.3.1
|
||||
version: 4.3.1(@parcel/watcher@2.5.6)(@types/node@25.2.3)(@vue/compiler-sfc@3.5.28)(better-sqlite3@12.6.2)(cac@6.7.14)(db0@0.3.4(better-sqlite3@12.6.2))(ioredis@5.9.3)(magicast@0.5.2)(rollup@4.57.1)(terser@5.46.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))(yaml@2.8.2)
|
||||
pdfjs-dist:
|
||||
specifier: ^5.4.624
|
||||
version: 5.4.624
|
||||
vue:
|
||||
specifier: ^3.5.28
|
||||
version: 3.5.28(typescript@5.9.3)
|
||||
@@ -598,6 +601,81 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.95':
|
||||
resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.95':
|
||||
resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.95':
|
||||
resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
|
||||
resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.95':
|
||||
resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.95':
|
||||
resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
|
||||
resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.95':
|
||||
resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.95':
|
||||
resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.95':
|
||||
resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.95':
|
||||
resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.95':
|
||||
resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.1':
|
||||
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
|
||||
|
||||
@@ -3336,6 +3414,9 @@ packages:
|
||||
node-mock-http@1.0.4:
|
||||
resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==}
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
|
||||
|
||||
node-releases@2.0.27:
|
||||
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
|
||||
|
||||
@@ -3496,6 +3577,10 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdfjs-dist@5.4.624:
|
||||
resolution: {integrity: sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==}
|
||||
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
@@ -5214,6 +5299,54 @@ snapshots:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.95':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.95
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.95
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.95
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.95
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.95
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.95
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.95
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.95
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.95
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.95
|
||||
optional: true
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.1':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.8.1
|
||||
@@ -8512,6 +8645,9 @@ snapshots:
|
||||
|
||||
node-mock-http@1.0.4: {}
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
optional: true
|
||||
|
||||
node-releases@2.0.27: {}
|
||||
|
||||
nopt@8.1.0:
|
||||
@@ -8855,6 +8991,11 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdfjs-dist@5.4.624:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.95
|
||||
node-readable-to-web-readable-stream: 0.4.2
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
|
||||
607
public/pdfjs/viewer.html
Normal file
607
public/pdfjs/viewer.html
Normal file
@@ -0,0 +1,607 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PDF Viewer</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.viewer-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Outline panel */
|
||||
.outline-panel {
|
||||
width: 260px;
|
||||
min-width: 260px;
|
||||
background: #111;
|
||||
border-right: 1px solid #2a2a2a;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: width 0.25s, min-width 0.25s;
|
||||
}
|
||||
|
||||
.outline-panel.collapsed {
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.outline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.outline-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.outline-actions { display: flex; gap: 0.25rem; }
|
||||
|
||||
.outline-btn {
|
||||
background: none;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 4px;
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.65rem;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.outline-btn:hover { border-color: #555; color: #ccc; }
|
||||
|
||||
.outline-tree {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.outline-tree::-webkit-scrollbar { width: 4px; }
|
||||
.outline-tree::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||||
|
||||
.outline-node { user-select: none; }
|
||||
|
||||
.outline-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.2rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: #aaa;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.3;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.outline-item:hover { background: #1e1e1e; color: #fff; }
|
||||
.outline-item.active { background: #1a1207; color: #e8a040; }
|
||||
|
||||
.outline-item .toggle {
|
||||
flex-shrink: 0;
|
||||
width: 14px; height: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.6rem; color: #555; cursor: pointer;
|
||||
transition: transform 0.15s; margin-top: 1px;
|
||||
}
|
||||
|
||||
.outline-item .toggle.open { transform: rotate(90deg); }
|
||||
.outline-item .toggle.empty { visibility: hidden; }
|
||||
.outline-item .label { flex: 1; min-width: 0; }
|
||||
|
||||
.outline-children { padding-left: 0.85rem; overflow: hidden; }
|
||||
.outline-children.hidden { display: none; }
|
||||
|
||||
.outline-empty {
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
color: #444;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Main area */
|
||||
.main-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Book spread */
|
||||
.book-spread {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
padding: 1rem;
|
||||
background: #2a2a2a;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.book-spread canvas {
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.5);
|
||||
background: white;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
max-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-slot.empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* Page turn transition */
|
||||
.page-slot canvas {
|
||||
transition: opacity 1s ease-out, transform 1s ease-out;
|
||||
}
|
||||
|
||||
.page-slot canvas.entering {
|
||||
opacity: 0;
|
||||
transform: translateX(var(--enter-dir, 20px));
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.page-slot canvas.leaving {
|
||||
opacity: 0;
|
||||
transform: translateX(var(--leave-dir, -20px));
|
||||
position: absolute;
|
||||
transition: opacity 1.2s ease-in, transform 1.2s ease-in;
|
||||
}
|
||||
|
||||
/* Navigation bar */
|
||||
.nav-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: #141414;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #aaa;
|
||||
cursor: pointer;
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.nav-btn:hover:not(:disabled) { background: #2a2a2a; color: #fff; border-color: #555; }
|
||||
.nav-btn:disabled { opacity: 0.25; cursor: default; }
|
||||
|
||||
.nav-info {
|
||||
font-size: 0.75rem;
|
||||
color: #777;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 6rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-btn {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: all 0.15s;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.sidebar-btn:hover { background: rgba(255,255,255,0.03); color: #aaa; }
|
||||
.sidebar-btn.hidden { display: none; }
|
||||
|
||||
/* Loading */
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.outline-panel { width: 200px; min-width: 200px; }
|
||||
.book-spread { padding: 0.5rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.outline-panel { position: absolute; z-index: 20; height: 100%; }
|
||||
.outline-panel.collapsed { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="viewer-layout">
|
||||
<div class="outline-panel" id="outlinePanel">
|
||||
<div class="outline-header">
|
||||
<span class="outline-title">Sommaire</span>
|
||||
<div class="outline-actions">
|
||||
<button class="outline-btn" onclick="expandAll()">Déplier</button>
|
||||
<button class="outline-btn" onclick="collapseAll()">Replier</button>
|
||||
<button class="outline-btn" onclick="toggleSidebar()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="outline-tree" id="outlineTree">
|
||||
<div class="loading">Chargement…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main-area">
|
||||
<div class="book-spread" id="bookSpread">
|
||||
<button class="sidebar-btn hidden" id="sidebarBtn" onclick="toggleSidebar()">☰</button>
|
||||
<div class="loading" id="loadingMsg">Chargement du PDF…</div>
|
||||
<div class="page-slot" id="slotLeft"></div>
|
||||
<div class="page-slot" id="slotRight"></div>
|
||||
</div>
|
||||
|
||||
<div class="nav-bar">
|
||||
<button class="nav-btn" id="prevBtn" onclick="prevSpread()" disabled>◀ Précédent</button>
|
||||
<span class="nav-info" id="pageInfo"></span>
|
||||
<button class="nav-btn" id="nextBtn" onclick="nextSpread()" disabled>Suivant ▶</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import * as pdfjsLib from '/pdfjs/pdf.min.mjs';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = '/pdfjs/pdf.worker.min.mjs';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const hash = location.hash.slice(1);
|
||||
const hashParams = new URLSearchParams(hash);
|
||||
|
||||
const fileUrl = params.get('file') || '/pdf/une-economie-du-don.pdf';
|
||||
const targetPage = parseInt(hashParams.get('page') || params.get('page') || '1', 10);
|
||||
|
||||
let pdfDoc = null;
|
||||
let currentSpread = 0; // index into spreads[]
|
||||
let spreads = []; // [[1], [2,3], [4,5], ...] — page 1 alone (cover), then pairs
|
||||
let pageCanvasCache = new Map();
|
||||
let outlinePageMap = []; // [{item, pageNum}] for highlighting
|
||||
let isAnimating = false;
|
||||
|
||||
function buildSpreads(numPages) {
|
||||
spreads = [];
|
||||
// Page 1 = couverture seule
|
||||
spreads.push([1]);
|
||||
// Ensuite par paires : 2-3, 4-5, etc.
|
||||
for (let i = 2; i <= numPages; i += 2) {
|
||||
if (i + 1 <= numPages) {
|
||||
spreads.push([i, i + 1]);
|
||||
} else {
|
||||
spreads.push([i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getSpreadForPage(pageNum) {
|
||||
return spreads.findIndex(s => s.includes(pageNum));
|
||||
}
|
||||
|
||||
async function renderPageCanvas(pageNum) {
|
||||
if (pageCanvasCache.has(pageNum)) return pageCanvasCache.get(pageNum);
|
||||
|
||||
const page = await pdfDoc.getPage(pageNum);
|
||||
const spread = document.getElementById('bookSpread');
|
||||
const availH = spread.clientHeight - 32;
|
||||
const availW = (spread.clientWidth - 40) / 2;
|
||||
|
||||
const rawViewport = page.getViewport({ scale: 1 });
|
||||
const scaleH = availH / rawViewport.height;
|
||||
const scaleW = availW / rawViewport.width;
|
||||
const scale = Math.min(scaleH, scaleW, 2.5);
|
||||
|
||||
const viewport = page.getViewport({ scale });
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
canvas.dataset.page = pageNum;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
|
||||
pageCanvasCache.set(pageNum, canvas);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
async function showSpread(index, animate = true) {
|
||||
if (index < 0 || index >= spreads.length) return;
|
||||
if (isAnimating) return;
|
||||
|
||||
const prevIndex = currentSpread;
|
||||
const direction = index > prevIndex ? 1 : -1; // 1 = forward, -1 = back
|
||||
currentSpread = index;
|
||||
|
||||
const pages = spreads[index];
|
||||
const slotLeft = document.getElementById('slotLeft');
|
||||
const slotRight = document.getElementById('slotRight');
|
||||
|
||||
// Collect old canvases for fade-out
|
||||
const oldCanvases = [...slotLeft.querySelectorAll('canvas'), ...slotRight.querySelectorAll('canvas')];
|
||||
const shouldAnimate = animate && oldCanvases.length > 0;
|
||||
|
||||
if (shouldAnimate) {
|
||||
isAnimating = true;
|
||||
// Fade out old canvases with directional slide
|
||||
oldCanvases.forEach(c => {
|
||||
c.style.setProperty('--leave-dir', `${-direction * 20}px`);
|
||||
c.classList.add('leaving');
|
||||
});
|
||||
// Wait for fade-out to mostly complete
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
slotLeft.innerHTML = '';
|
||||
slotRight.innerHTML = '';
|
||||
slotLeft.className = 'page-slot';
|
||||
slotRight.className = 'page-slot';
|
||||
|
||||
if (pages.length === 1) {
|
||||
const canvas = await renderPageCanvas(pages[0]);
|
||||
if (shouldAnimate) {
|
||||
canvas.style.setProperty('--enter-dir', `${direction * 20}px`);
|
||||
canvas.classList.add('entering');
|
||||
}
|
||||
slotLeft.appendChild(canvas);
|
||||
slotRight.className = 'page-slot empty';
|
||||
if (shouldAnimate) {
|
||||
// Double rAF ensures the browser has painted the initial state before transitioning
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => canvas.classList.remove('entering')));
|
||||
}
|
||||
} else {
|
||||
const [left, right] = await Promise.all([
|
||||
renderPageCanvas(pages[0]),
|
||||
renderPageCanvas(pages[1]),
|
||||
]);
|
||||
if (shouldAnimate) {
|
||||
left.style.setProperty('--enter-dir', `${direction * 20}px`);
|
||||
right.style.setProperty('--enter-dir', `${direction * 20}px`);
|
||||
left.classList.add('entering');
|
||||
right.classList.add('entering');
|
||||
}
|
||||
slotLeft.appendChild(left);
|
||||
slotRight.appendChild(right);
|
||||
if (shouldAnimate) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
left.classList.remove('entering');
|
||||
right.classList.remove('entering');
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldAnimate) setTimeout(() => { isAnimating = false; }, 1100);
|
||||
else isAnimating = false;
|
||||
|
||||
updateNav();
|
||||
highlightOutline();
|
||||
}
|
||||
|
||||
function updateNav() {
|
||||
const pages = spreads[currentSpread];
|
||||
const info = document.getElementById('pageInfo');
|
||||
const prevBtn = document.getElementById('prevBtn');
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
|
||||
if (pages.length === 1) {
|
||||
info.textContent = `Page ${pages[0]} / ${pdfDoc.numPages}`;
|
||||
} else {
|
||||
info.textContent = `Pages ${pages[0]}–${pages[1]} / ${pdfDoc.numPages}`;
|
||||
}
|
||||
|
||||
prevBtn.disabled = currentSpread <= 0;
|
||||
nextBtn.disabled = currentSpread >= spreads.length - 1;
|
||||
}
|
||||
|
||||
function prevSpread() { showSpread(currentSpread - 1); }
|
||||
function nextSpread() { showSpread(currentSpread + 1); }
|
||||
|
||||
function goToPage(pageNum) {
|
||||
const idx = getSpreadForPage(pageNum);
|
||||
if (idx >= 0) showSpread(idx);
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
prevSpread();
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
nextSpread();
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
showSpread(0);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
showSpread(spreads.length - 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
pageCanvasCache.clear();
|
||||
showSpread(currentSpread, false);
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Outline
|
||||
async function renderOutline() {
|
||||
const outlineTree = document.getElementById('outlineTree');
|
||||
const outline = await pdfDoc.getOutline();
|
||||
|
||||
if (!outline || outline.length === 0) {
|
||||
outlineTree.innerHTML = '<div class="outline-empty">Aucun signet dans ce PDF</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
outlineTree.innerHTML = '';
|
||||
outlinePageMap = [];
|
||||
const tree = await buildOutlineTree(outline);
|
||||
outlineTree.appendChild(tree);
|
||||
}
|
||||
|
||||
async function buildOutlineTree(items) {
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
for (const item of items) {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'outline-node';
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'outline-item';
|
||||
|
||||
// Resolve page number for this item
|
||||
let pageNum = null;
|
||||
try {
|
||||
let dest = item.dest;
|
||||
if (typeof dest === 'string') dest = await pdfDoc.getDestination(dest);
|
||||
if (dest) {
|
||||
const ref = dest[0];
|
||||
pageNum = (await pdfDoc.getPageIndex(ref)) + 1;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const toggle = document.createElement('span');
|
||||
toggle.className = 'toggle' + (item.items && item.items.length > 0 ? ' open' : ' empty');
|
||||
toggle.textContent = '▶';
|
||||
if (item.items && item.items.length > 0) {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const children = node.querySelector('.outline-children');
|
||||
if (children) {
|
||||
const isHidden = children.classList.toggle('hidden');
|
||||
toggle.classList.toggle('open', !isHidden);
|
||||
}
|
||||
});
|
||||
}
|
||||
row.appendChild(toggle);
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'label';
|
||||
label.textContent = item.title;
|
||||
row.appendChild(label);
|
||||
|
||||
if (pageNum !== null) {
|
||||
const pg = pageNum;
|
||||
row.addEventListener('click', () => goToPage(pg));
|
||||
outlinePageMap.push({ row, pageNum: pg });
|
||||
}
|
||||
|
||||
node.appendChild(row);
|
||||
|
||||
if (item.items && item.items.length > 0) {
|
||||
const children = document.createElement('div');
|
||||
children.className = 'outline-children';
|
||||
children.appendChild(await buildOutlineTree(item.items));
|
||||
node.appendChild(children);
|
||||
}
|
||||
|
||||
frag.appendChild(node);
|
||||
}
|
||||
|
||||
return frag;
|
||||
}
|
||||
|
||||
function highlightOutline() {
|
||||
const pages = spreads[currentSpread];
|
||||
for (const { row, pageNum } of outlinePageMap) {
|
||||
row.classList.toggle('active', pages.includes(pageNum));
|
||||
}
|
||||
}
|
||||
|
||||
// Load
|
||||
async function init() {
|
||||
const loadingTask = pdfjsLib.getDocument(fileUrl);
|
||||
pdfDoc = await loadingTask.promise;
|
||||
|
||||
buildSpreads(pdfDoc.numPages);
|
||||
await renderOutline();
|
||||
|
||||
document.getElementById('loadingMsg').remove();
|
||||
|
||||
const startSpread = getSpreadForPage(targetPage);
|
||||
await showSpread(startSpread >= 0 ? startSpread : 0, false);
|
||||
}
|
||||
|
||||
init().catch(err => {
|
||||
document.getElementById('loadingMsg').textContent = `Erreur : ${err.message}`;
|
||||
document.getElementById('loadingMsg').style.color = '#c44';
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
// Expose globals
|
||||
window.prevSpread = prevSpread;
|
||||
window.nextSpread = nextSpread;
|
||||
|
||||
window.expandAll = function() {
|
||||
const tree = document.getElementById('outlineTree');
|
||||
tree.querySelectorAll('.outline-children.hidden').forEach(el => el.classList.remove('hidden'));
|
||||
tree.querySelectorAll('.toggle:not(.empty)').forEach(el => el.classList.add('open'));
|
||||
};
|
||||
|
||||
window.collapseAll = function() {
|
||||
const tree = document.getElementById('outlineTree');
|
||||
tree.querySelectorAll('.outline-children').forEach(el => el.classList.add('hidden'));
|
||||
tree.querySelectorAll('.toggle:not(.empty)').forEach(el => el.classList.remove('open'));
|
||||
};
|
||||
|
||||
window.toggleSidebar = function() {
|
||||
const panel = document.getElementById('outlinePanel');
|
||||
const btn = document.getElementById('sidebarBtn');
|
||||
panel.classList.toggle('collapsed');
|
||||
btn.classList.toggle('hidden', !panel.classList.contains('collapsed'));
|
||||
// Recalc on sidebar toggle
|
||||
setTimeout(() => {
|
||||
pageCanvasCache.clear();
|
||||
showSpread(currentSpread, false);
|
||||
}, 300);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
12
scripts/copy-pdfjs.sh
Executable file
12
scripts/copy-pdfjs.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
# Copie les fichiers PDF.js nécessaires depuis node_modules vers public/pdfjs/
|
||||
set -e
|
||||
|
||||
DEST="public/pdfjs"
|
||||
SRC="node_modules/pdfjs-dist/build"
|
||||
|
||||
mkdir -p "$DEST"
|
||||
cp "$SRC/pdf.min.mjs" "$DEST/pdf.min.mjs"
|
||||
cp "$SRC/pdf.worker.min.mjs" "$DEST/pdf.worker.min.mjs"
|
||||
|
||||
echo "[copy-pdfjs] OK"
|
||||
35
scripts/fix-esm-imports.mjs
Normal file
35
scripts/fix-esm-imports.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Post-build fix: Pinia CJS (pinia.prod.cjs) fait require('vue').
|
||||
* Rollup convertit ça en `import require$$0 from 'vue'` (default import).
|
||||
* Vue 3 n'a pas de default export en ESM → crash Node 22+.
|
||||
*
|
||||
* Ce script remplace le default import par un namespace import valide.
|
||||
*/
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
|
||||
const file = '.output/server/chunks/build/server.mjs'
|
||||
|
||||
if (!existsSync(file)) {
|
||||
console.log('[fix-esm] server.mjs not found, skipping')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
let code = readFileSync(file, 'utf-8')
|
||||
|
||||
// Pattern: import require$$0, { defineComponent, inject, ... } from 'vue'
|
||||
// → import * as require$$0 from 'vue'; import { defineComponent, ... } from 'vue'
|
||||
const re = /import\s+(require\$\$\d+)\s*,\s*\{([^}]+)\}\s*from\s*'vue'/g
|
||||
let patched = false
|
||||
|
||||
code = code.replace(re, (match, varName, namedStr) => {
|
||||
patched = true
|
||||
const names = namedStr.split(',').map(n => n.trim()).filter(Boolean)
|
||||
return `import * as ${varName} from 'vue'; import { ${names.join(', ')} } from 'vue'`
|
||||
})
|
||||
|
||||
if (patched) {
|
||||
writeFileSync(file, code)
|
||||
console.log('[fix-esm] Patched default import from vue in server.mjs')
|
||||
} else {
|
||||
console.log('[fix-esm] No default import found, nothing to patch')
|
||||
}
|
||||
@@ -11,6 +11,7 @@ export default defineEventHandler(async (event) => {
|
||||
const filePath = join(process.cwd(), 'content', 'book', `${slug}.md`)
|
||||
|
||||
await unlink(filePath)
|
||||
gitSyncContent(`Suppression chapitre ${slug}`, [`content/book/${slug}.md`])
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ export default defineEventHandler(async (event) => {
|
||||
const content = `---\n${body.frontmatter.trim()}\n---\n${body.body}`
|
||||
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
gitSyncContent(`Mise à jour chapitre ${slug}`, [`content/book/${slug}.md`])
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ readingTime: "5 min"
|
||||
`
|
||||
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
gitSyncContent(`Nouveau chapitre ${body.slug}`, [`content/book/${body.slug}.md`])
|
||||
|
||||
return { ok: true, slug: body.slug }
|
||||
})
|
||||
|
||||
@@ -25,5 +25,7 @@ export default defineEventHandler(async (event) => {
|
||||
}),
|
||||
)
|
||||
|
||||
gitSyncContent('Réorganisation chapitres', ['content/book/'])
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
await writeYaml('bookplayer.config.yml', body)
|
||||
gitSyncContent('Mise à jour config livre/player', ['site/bookplayer.config.yml'])
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -7,5 +7,6 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const body = await readBody(event)
|
||||
await writeYaml(`pages/${name}.yml`, body)
|
||||
gitSyncContent(`Mise à jour page ${name}`, [`site/pages/${name}.yml`])
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
await writeYaml('site.yml', body)
|
||||
gitSyncContent('Mise à jour config site', ['site/site.yml'])
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -23,6 +23,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
try {
|
||||
await unlink(filePath)
|
||||
gitSyncContent(`Suppression média ${path}`, [`public/${path}`])
|
||||
return { ok: true }
|
||||
}
|
||||
catch {
|
||||
|
||||
@@ -33,5 +33,9 @@ export default defineEventHandler(async (event) => {
|
||||
uploaded.push(`/${subdir}/${safeName}`)
|
||||
}
|
||||
|
||||
if (uploaded.length > 0) {
|
||||
gitSyncContent(`Upload média : ${uploaded.join(', ')}`, uploaded.map(f => `public${f}`))
|
||||
}
|
||||
|
||||
return { ok: true, files: uploaded }
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
data.messages.splice(index, 1)
|
||||
await writeYaml('messages.yml', data)
|
||||
gitSyncContent(`Suppression message #${id}`, ['site/messages.yml'])
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -18,6 +18,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (body.author !== undefined) message.author = body.author
|
||||
|
||||
await writeYaml('messages.yml', data)
|
||||
gitSyncContent(`Mise à jour message #${id}`, ['site/messages.yml'])
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
86
server/api/admin/pdf-outline.get.ts
Normal file
86
server/api/admin/pdf-outline.get.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { join } from 'node:path'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
|
||||
// Polyfills nécessaires pour pdfjs-dist en Node.js pur (pas de DOM)
|
||||
// On n'a besoin que du parsing, pas du rendu
|
||||
if (typeof globalThis.DOMMatrix === 'undefined') {
|
||||
// @ts-expect-error polyfill minimal pour pdfjs
|
||||
globalThis.DOMMatrix = class DOMMatrix {
|
||||
constructor() { return Object.assign(this, { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }) }
|
||||
isIdentity = true
|
||||
translate() { return new DOMMatrix() }
|
||||
scale() { return new DOMMatrix() }
|
||||
inverse() { return new DOMMatrix() }
|
||||
multiply() { return new DOMMatrix() }
|
||||
}
|
||||
}
|
||||
if (typeof globalThis.Path2D === 'undefined') {
|
||||
// @ts-expect-error polyfill stub
|
||||
globalThis.Path2D = class Path2D { constructor() {} }
|
||||
}
|
||||
if (typeof globalThis.ImageData === 'undefined') {
|
||||
// @ts-expect-error polyfill stub
|
||||
globalThis.ImageData = class ImageData { constructor(w: number, h: number) { this.width = w; this.height = h; this.data = new Uint8ClampedArray(w * h * 4) } }
|
||||
}
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
const config = await readYaml('bookplayer.config.yml')
|
||||
const pdfFile = config?.book?.pdfFile || '/pdf/une-economie-du-don.pdf'
|
||||
|
||||
// Résolution du chemin PDF : dev (public/) et prod (.output/public/)
|
||||
const cwd = process.cwd()
|
||||
const candidates = [
|
||||
join(cwd, 'public', pdfFile),
|
||||
join(cwd, '.output', 'public', pdfFile),
|
||||
]
|
||||
const pdfPath = candidates.find(p => existsSync(p))
|
||||
|
||||
if (!pdfPath) {
|
||||
console.warn('[pdf-outline] PDF non trouvé. cwd:', cwd, 'candidats:', candidates)
|
||||
return []
|
||||
}
|
||||
|
||||
let data: Uint8Array
|
||||
try {
|
||||
data = new Uint8Array(readFileSync(pdfPath))
|
||||
} catch (err) {
|
||||
console.warn('[pdf-outline] Erreur lecture PDF:', err)
|
||||
return []
|
||||
}
|
||||
|
||||
const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs')
|
||||
|
||||
let doc
|
||||
try {
|
||||
doc = await pdfjsLib.getDocument({ data, useSystemFonts: true }).promise
|
||||
} catch (err) {
|
||||
console.warn('[pdf-outline] Erreur getDocument:', err)
|
||||
return []
|
||||
}
|
||||
|
||||
const outline = await doc.getOutline()
|
||||
|
||||
if (!outline || outline.length === 0) {
|
||||
doc.destroy()
|
||||
return []
|
||||
}
|
||||
|
||||
const entries: Array<{ title: string; page: number; level: number }> = []
|
||||
|
||||
async function extract(items: any[], level: number) {
|
||||
for (const item of items) {
|
||||
let page: number | null = null
|
||||
try {
|
||||
let dest = item.dest
|
||||
if (typeof dest === 'string') dest = await doc.getDestination(dest)
|
||||
if (dest) page = (await doc.getPageIndex(dest[0])) + 1
|
||||
} catch {}
|
||||
if (page !== null) entries.push({ title: item.title, page, level })
|
||||
if (item.items?.length) await extract(item.items, level + 1)
|
||||
}
|
||||
}
|
||||
|
||||
await extract(outline, 0)
|
||||
doc.destroy()
|
||||
return entries
|
||||
})
|
||||
@@ -10,4 +10,8 @@ export default defineEventHandler((event) => {
|
||||
const rest = path.slice(8) // remove '/ecouter'
|
||||
return sendRedirect(event, `/en-musique${rest || '/'}`, 301)
|
||||
}
|
||||
|
||||
if (path === '/autonomie' || path === '/autonomie/') {
|
||||
return sendRedirect(event, '/numerique', 301)
|
||||
}
|
||||
})
|
||||
|
||||
44
server/utils/gitSync.ts
Normal file
44
server/utils/gitSync.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const exec = promisify(execFile)
|
||||
|
||||
const enabled = process.env.ADMIN_GIT_SYNC === 'true'
|
||||
const cwd = process.cwd()
|
||||
|
||||
async function git(...args: string[]): Promise<string> {
|
||||
const { stdout } = await exec('git', args, { cwd })
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit et push les modifications admin vers le dépôt git.
|
||||
* Activé uniquement si ADMIN_GIT_SYNC=true (prod).
|
||||
* Ne bloque jamais la réponse HTTP — exécution fire-and-forget.
|
||||
*/
|
||||
export function gitSyncContent(description: string, paths: string[] = ['.']) {
|
||||
if (!enabled) return
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
for (const p of paths) {
|
||||
await git('add', p)
|
||||
}
|
||||
|
||||
// Vérifier s'il y a vraiment des changements stagés
|
||||
const status = await git('diff', '--cached', '--name-only')
|
||||
if (!status) return
|
||||
|
||||
await git('commit', '-m', `[admin] ${description}`)
|
||||
await git('push')
|
||||
|
||||
console.log(`[gitSync] pushed: ${description}`)
|
||||
}
|
||||
catch (err: any) {
|
||||
console.error(`[gitSync] error:`, err.message ?? err)
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget : ne pas bloquer la réponse HTTP
|
||||
run()
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
book:
|
||||
title: Une économie du don — enfin concevable
|
||||
author: Yvv
|
||||
pdfFile: /pdf/une-economie-du-don.pdf
|
||||
description: Un livre et 9 chansons pour explorer ensemble les fondements d'une économie fondée sur le don.
|
||||
coverImage: /images/book-cover.jpg
|
||||
license: CC-BY-NC
|
||||
@@ -560,6 +561,29 @@ chapterSongs:
|
||||
- chapterSlug: 11-annexes
|
||||
songId: coder-la-liberte
|
||||
primary: true
|
||||
chapterPages:
|
||||
- chapterSlug: 01-introduction
|
||||
page: 9
|
||||
- chapterSlug: 02-don
|
||||
page: 23
|
||||
- chapterSlug: 03-mesure
|
||||
page: 43
|
||||
- chapterSlug: 04-monnaie
|
||||
page: 49
|
||||
- chapterSlug: 05-trm
|
||||
page: 83
|
||||
- chapterSlug: 06-economie
|
||||
page: 121
|
||||
- chapterSlug: 07-echange
|
||||
page: 147
|
||||
- chapterSlug: 08-institution
|
||||
page: 163
|
||||
- chapterSlug: 09-greffes
|
||||
page: 181
|
||||
- chapterSlug: 10-maintenant
|
||||
page: 193
|
||||
- chapterSlug: 11-annexes
|
||||
page: 199
|
||||
defaultPlaylistOrder:
|
||||
- ce-livre-est-une-facon
|
||||
- de-quel-don-nous-parlons
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
hero:
|
||||
kicker: Autonomie collective des bassins de vie
|
||||
title: Le librodrome
|
||||
subtitle: Créer une économie ? Couvrir nos besoins pour vivre et nourrir nos plaisirs vivre. Ouverture d'une plateforme
|
||||
de productions collectives, pour facilier la création d'équipes, la préparation et la réalisation de ces
|
||||
productions.
|
||||
footnote: Ce projet est ouvert. Chaque personne qui souhaite se mobiliser pour participer à une production est invitée à
|
||||
laisser un message. Pour poser des questions ou laisser un mail.
|
||||
cta:
|
||||
label: En savoir plus sur le projet
|
||||
to: /a-propos
|
||||
heading:
|
||||
- "Construire une autonomie collective."
|
||||
- "à l'échelle des bassins de vie"
|
||||
- "Pousser les curseurs"
|
||||
- "Autonomie numérique, économique, citoyenne."
|
||||
- "— s'en donner les moyens —"
|
||||
citations:
|
||||
- "Il s'agit d'émancipation."
|
||||
- "Les trois dimensions qui nous émancipent sont le numérique, l'économie et le politique."
|
||||
- "Elles peuvent nous asservir tout autant."
|
||||
- "Ce sont les 3 axes de l'espace dans lequel nous naviguons."
|
||||
approach: "Dans chaque dimension, nous adressons ce qui est le plus en amont"
|
||||
axes:
|
||||
- label: numérique
|
||||
value: le code source
|
||||
- label: économie
|
||||
value: la création monétaire
|
||||
- label: citoyenne
|
||||
value: la décision
|
||||
|
||||
book:
|
||||
kicker: Modèle économique
|
||||
title: Une économie du don — enfin concevable
|
||||
@@ -19,44 +29,81 @@ book:
|
||||
cta:
|
||||
player: Présentation musicale
|
||||
pdf: Lecture du livre
|
||||
bookPresentation:
|
||||
kicker: Le livre
|
||||
title: Une économie du don — enfin concevable
|
||||
description:
|
||||
- Ce livre explore les fondements d'une économie fondée sur le don.
|
||||
- Les chansons prolongent le propos en suivant le cours des chapitres, pour le rendre plus accessible. Elles créent
|
||||
une invitation inédite et immersive à la lecture.
|
||||
cta:
|
||||
label: Sommaire
|
||||
|
||||
axes:
|
||||
numerique:
|
||||
title: Autonomie numérique
|
||||
icon: monitor
|
||||
items:
|
||||
- label: Logiciel libre
|
||||
description: Maîtriser le code source, c'est maîtriser l'outil. Le logiciel libre est la base de l'autonomie numérique.
|
||||
to: /gestation/logiciel-libre
|
||||
gestation: true
|
||||
icon: code-2
|
||||
presentation:
|
||||
title: wishBounty
|
||||
text: Application pour le financement fléché des développements.
|
||||
- label: Authentification — WoT
|
||||
description: Une toile de confiance décentralisée, sans autorité centrale. Chaque identité est certifiée par ses pairs.
|
||||
to: /gestation/authentification-wot
|
||||
gestation: true
|
||||
icon: share-2
|
||||
presentation:
|
||||
title: trustWallet
|
||||
text: Gestionnaire de confiances.
|
||||
- label: Cloud libre
|
||||
description: Héberger ses propres services pour ne dépendre de personne. Serveurs, noms de domaine, infrastructure.
|
||||
to: /gestation/cloud-libre
|
||||
gestation: true
|
||||
icon: cloud
|
||||
presentation:
|
||||
title: Bouquet de services
|
||||
text: "Un bouquet de services complet : Drive, Visio, Forum, Wiki, CMS. IA frugale localisée."
|
||||
economie:
|
||||
title: Autonomie économique
|
||||
icon: coins
|
||||
items:
|
||||
- label: Monnaie libre
|
||||
description: "La Ğ1 (June) : une monnaie co-créée par ses membres, sans dette ni intérêt. Le dividende universel comme base."
|
||||
href: https://monnaie-libre.fr
|
||||
icon: g1
|
||||
- label: Économie du don
|
||||
description: Un livre et des chansons pour une proposition de modèle économique fondé sur le don.
|
||||
to: /modele-eco
|
||||
songs:
|
||||
kicker: Les chansons
|
||||
title: Des chansons qui racontent le livre, du moins une partie
|
||||
description: Chaque chanson est un prolongement musical d'un ou deux chapitres. Naturellement, les chansons ne
|
||||
restituent pas l'intégralité du livre.
|
||||
cta:
|
||||
label: Voir toutes les chansons
|
||||
to: /en-musique
|
||||
cooperative:
|
||||
icon: scale
|
||||
actions:
|
||||
- id: open-player
|
||||
label: Présentation musicale
|
||||
icon: play
|
||||
highlight: true
|
||||
- id: open-pdf
|
||||
label: Lecture du livre
|
||||
icon: book-open
|
||||
- id: launch-gratewizard
|
||||
label: grateWizard
|
||||
icon: sparkles
|
||||
secondary: true
|
||||
- label: Productions collectives
|
||||
description: Une plateforme pour faciliter la création d'équipes et la réalisation de productions à l'échelle des bassins de vie.
|
||||
to: /gestation/productions-collectives
|
||||
gestation: true
|
||||
icon: users
|
||||
kicker: Vision
|
||||
title: Une plateforme coopérative
|
||||
description:
|
||||
- L'ouverture de cette page librodrome est le premier pas vers une plateforme de productions collectives. Un espace
|
||||
où les créateurs, producteurs et toute personne mobilisée, contribuent ensemble à faire émerger des projets de
|
||||
productions. La plateforme sera utile pour leur réalisation effective, le suivi et le retour d'expérience.
|
||||
- Ce projet est ouvert. Chaque contribution enrichit l'ensemble. Rejoignez-nous pour construire une autonomie
|
||||
collective à l'échelle des bassins de vie.
|
||||
cta:
|
||||
label: En savoir plus
|
||||
to: /a-propos
|
||||
grateWizardTeaser:
|
||||
kicker: Estimer les valeurs en DU - Les coefficients relatifs
|
||||
title: grateWizard
|
||||
description: Une webapp pour calculer des coefficients relatifs et estimer les valeurs dans une économie du don.
|
||||
Relatifs à la moyenne, à l'ancienneté, au solde net, au volume disponible.
|
||||
cta:
|
||||
launch: Lancer l'appli
|
||||
more:
|
||||
label: En savoir plus
|
||||
to: /gratewizard
|
||||
politique:
|
||||
title: Autonomie citoyenne
|
||||
icon: landmark
|
||||
items:
|
||||
- label: Décision collective
|
||||
description: Se donner les moyens de la décision collective.
|
||||
to: /decision
|
||||
icon: gavel
|
||||
- label: Tarifs de l'eau
|
||||
description: Application pour obtenir justice sociale et incitation dynamique à la réduction. Permet de confier la décision à la population des communes.
|
||||
to: /gestation/tarifs-eau
|
||||
gestation: true
|
||||
icon: droplets
|
||||
|
||||
evenement:
|
||||
title: Le librodrome,
|
||||
subtitle: c'est également un événement.
|
||||
to: /evenement
|
||||
gestation: true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
kicker: Pourquoi l'autonomie
|
||||
title: Autonomie
|
||||
description: Des passages du livre qui éclairent la démarche d'autonomie collective — le fil rouge du projet.
|
||||
kicker: Autonomie numérique
|
||||
title: Le code source
|
||||
description: Des passages du livre qui éclairent la démarche d'autonomie numérique — maîtriser le code source, c'est maîtriser l'outil.
|
||||
meta:
|
||||
title: Autonomie
|
||||
title: Autonomie numérique
|
||||
extracts:
|
||||
- chapter: Introduction
|
||||
chapterSlug: 01-introduction
|
||||
@@ -4,16 +4,16 @@ identity:
|
||||
racontent, autrement.
|
||||
url: https://librodrome.org
|
||||
navigation:
|
||||
- label: Autonomie
|
||||
to: /autonomie
|
||||
- label: Modèle éco
|
||||
axes:
|
||||
- label: numérique
|
||||
to: /numerique
|
||||
- label: économique
|
||||
to: /modele-eco
|
||||
- label: En musique
|
||||
to: /en-musique
|
||||
- label: Évènement
|
||||
- label: citoyenne
|
||||
to: /decision
|
||||
extra:
|
||||
- label: Événement
|
||||
to: /evenement
|
||||
- label: À propos
|
||||
to: /a-propos
|
||||
footer:
|
||||
credits: © 2026 Le librodrome — Productions collectives
|
||||
links:
|
||||
|
||||
Reference in New Issue
Block a user