Compare commits

..

11 Commits

Author SHA1 Message Date
Yvv
f0338cca5e Fix déroulant PDF en production : polyfills DOMMatrix + worker pdfjs
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
pdfjs-dist en Node.js pur (hors Vite) nécessite :
- DOMMatrix, Path2D, ImageData polyfills (pas de DOM en Node)
- pdf.worker.mjs copié dans le build (traceInclude dans nitro config)
Testé : 61 entrées retournées en mode production.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:24:59 +01:00
Yvv
c6b9abf2f3 Fix chemin PDF en production pour l'API pdf-outline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
En prod le PDF est dans .output/public/, pas dans public/.
Cherche dans les deux emplacements (dev et prod).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:55:14 +01:00
Yvv
df0409fec3 Fix déroulant sommaire PDF : onMounted au lieu de import.meta.client
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Le $fetch dans import.meta.client ne se ré-exécutait pas après hydratation SSR.
onMounted garantit l'exécution côté client après le montage du composant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:42:58 +01:00
Yvv
1af00cc64c Admin : déroulant sommaire PDF par chapitre, transitions pages, URL GrateWizard
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Ajout API /api/admin/pdf-outline (extraction sommaire PDF côté serveur via pdfjs-dist)
- Déroulant <select> dans chaque ligne de chapitre admin avec les 61 titres/sous-titres du PDF
- Sauvegarde des associations chapitre→page PDF via config YAML
- Transition douce (fondu 1s/1.2s) pour le changement de pages dans le viewer PDF
- Correction des numéros de pages réels dans chapterPages (extraits du sommaire PDF)
- URL GrateWizard prod → gratewizard.axiom-team.fr

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:29:54 +01:00
Yvv
8a38c86794 Fix prod 404 : retrait volumes git sync qui cassent le conteneur
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Les volumes ../site, ../content, ../public montés dans docker-compose
écrasaient les fichiers du conteneur avec des chemins host inexistants.
Retour à la config d'origine. Le git sync sera configuré ultérieurement
quand le serveur aura un clone avec accès push.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:44:44 +01:00
Yvv
17f39e735d Fix build prod : postinstall tolérant + copy-pdfjs dans Dockerfile
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- postinstall : test -f avant d'appeler copy-pdfjs.sh (absent pendant pnpm install Docker)
- Dockerfile : RUN copy-pdfjs.sh après COPY . . pour copier les fichiers PDF.js
- Dockerfile : COPY content/ en production pour Nuxt Content

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:26:05 +01:00
Yvv
7ea19e2247 Viewer PDF.js mode livre avec signets, fix hydratation SSR
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Viewer PDF.js mode livre : double page côte à côte, navigation prev/next visuelle et clavier
- Panneau signets (outline) avec tout déplier/replier, highlight du spread courant
- Page 1 = couverture seule, puis paires 2-3, 4-5, etc.
- Navigation clavier : flèches, espace, Home/End
- Redimensionnement auto des canvas au resize
- Fix hydratation SSR : bookData.init() sans await dans ChapterHeader et SongBadges
- BookPdfReader : iframe vers /pdfjs/viewer.html au lieu du viewer natif
- Script postinstall pour copier pdf.min.mjs depuis node_modules

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 18:16:47 +01:00
Yvv
9525ed3953 Bouton PDF par chapitre, badges morceaux améliorés, PDF configurable admin, git sync admin→prod
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Bouton PDF blanc par chapitre avec numéro de page (ChapterHeader)
- Badges morceaux plus visibles (bordure, poids, hover) dans ChapterHeader et SongBadges
- PDF viewer : page cible + panneau signets ouverts par défaut (BookPdfReader)
- Config YAML : pdfFile dans book, chapterPages pour le mapping chapitre→page
- Admin book : section PDF du livre avec chemin éditable et sauvegarde
- Git sync automatique : chaque sauvegarde admin commit+push en prod (ADMIN_GIT_SYNC=true)
- Docker : git installé en prod, volumes pour .git/site/content/public

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 15:32:38 +01:00
Yvv
b02368a15b Fix build prod : Pinia CJS default import crash Node 22+
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Pinia résout vers pinia.prod.cjs en production, Rollup convertit
require('vue') en default import ESM invalide. Script post-build
remplace par un namespace import (* as).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:36:37 +01:00
1f47533c77 Merge branch 'develop'
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-27 19:40:43 +01:00
07bf07a942 update app src
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-27 18:38:41 +01:00
32 changed files with 1390 additions and 17 deletions

4
.gitignore vendored
View File

@@ -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/

View File

@@ -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`)

View File

@@ -21,7 +21,7 @@ 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,

View File

@@ -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

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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,
}
}

View File

@@ -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 &amp; 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;

View File

@@ -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[]
}

View File

@@ -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

View File

@@ -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:

View File

@@ -44,4 +44,13 @@ export default defineNuxtConfig({
siteUrl: 'https://librodrome.org',
},
},
nitro: {
externals: {
traceInclude: [
'node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs',
],
},
},
})

View File

@@ -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
View File

@@ -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
View 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()">&#10005;</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()">&#9776;</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>&#9664; Précédent</button>
<span class="nav-info" id="pageInfo"></span>
<button class="nav-btn" id="nextBtn" onclick="nextSpread()" disabled>Suivant &#9654;</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
View 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"

View 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')
}

View File

@@ -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 }
})

View File

@@ -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 }
})

View File

@@ -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 }
})

View File

@@ -25,5 +25,7 @@ export default defineEventHandler(async (event) => {
}),
)
gitSyncContent('Réorganisation chapitres', ['content/book/'])
return { ok: true }
})

View File

@@ -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 }
})

View File

@@ -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 }
})

View File

@@ -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 }
})

View File

@@ -23,6 +23,7 @@ export default defineEventHandler(async (event) => {
try {
await unlink(filePath)
gitSyncContent(`Suppression média ${path}`, [`public/${path}`])
return { ok: true }
}
catch {

View File

@@ -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 }
})

View File

@@ -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 }
})

View File

@@ -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 }
})

View 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
})

44
server/utils/gitSync.ts Normal file
View 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()
}

View File

@@ -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