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>
This commit is contained in:
Yvv
2026-02-28 22:29:54 +01:00
parent 8a38c86794
commit 1af00cc64c
6 changed files with 187 additions and 23 deletions

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

@@ -17,7 +17,7 @@
placeholder="/pdf/une-economie-du-don.pdf"
/>
</div>
<button class="save-pdf-btn" @click="savePdfPath" :disabled="savingPdf">
<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" />
@@ -59,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)"
@@ -115,25 +129,54 @@ const saved = ref(false)
const newTitle = ref('')
const newSlug = ref('')
// PDF path
// PDF path + outline
const pdfPath = ref(bookConfig.value?.book?.pdfFile ?? '/pdf/une-economie-du-don.pdf')
const savingPdf = ref(false)
const savedPdf = ref(false)
async function savePdfPath() {
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
if (import.meta.client) {
$fetch<Array<{ title: string; page: number; level: number }>>('/api/admin/pdf-outline')
.then((data) => { pdfOutline.value = data })
.catch(() => {})
}
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 {
} finally {
savingPdf.value = false
}
}
@@ -310,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

@@ -151,12 +151,31 @@ html, body {
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;
@@ -281,6 +300,7 @@ 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 = [];
@@ -327,33 +347,74 @@ async function renderPageCanvas(pageNum) {
return canvas;
}
async function showSpread(index) {
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) {
// Single page (cover or last odd page) — centered
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();
}
@@ -405,7 +466,7 @@ window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
pageCanvasCache.clear();
showSpread(currentSpread);
showSpread(currentSpread, false);
}, 200);
});
@@ -505,7 +566,7 @@ async function init() {
document.getElementById('loadingMsg').remove();
const startSpread = getSpreadForPage(targetPage);
await showSpread(startSpread >= 0 ? startSpread : 0);
await showSpread(startSpread >= 0 ? startSpread : 0, false);
}
init().catch(err => {
@@ -538,7 +599,7 @@ window.toggleSidebar = function() {
// Recalc on sidebar toggle
setTimeout(() => {
pageCanvasCache.clear();
showSpread(currentSpread);
showSpread(currentSpread, false);
}, 300);
};
</script>

View File

@@ -0,0 +1,43 @@
import { join } from 'node:path'
import { readFileSync } from 'node:fs'
export default defineEventHandler(async () => {
const config = await readYaml('bookplayer.config.yml')
const pdfFile = config?.book?.pdfFile || '/pdf/une-economie-du-don.pdf'
const pdfPath = join(process.cwd(), 'public', pdfFile)
let data: Uint8Array
try {
data = new Uint8Array(readFileSync(pdfPath))
} catch {
return []
}
const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs')
const doc = await pdfjsLib.getDocument({ data, useSystemFonts: true }).promise
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
})

View File

@@ -563,27 +563,27 @@ chapterSongs:
primary: true
chapterPages:
- chapterSlug: 01-introduction
page: 5
page: 9
- chapterSlug: 02-don
page: 15
page: 23
- chapterSlug: 03-mesure
page: 30
page: 43
- chapterSlug: 04-monnaie
page: 45
page: 49
- chapterSlug: 05-trm
page: 60
page: 83
- chapterSlug: 06-economie
page: 75
page: 121
- chapterSlug: 07-echange
page: 90
page: 147
- chapterSlug: 08-institution
page: 105
page: 163
- chapterSlug: 09-greffes
page: 120
page: 181
- chapterSlug: 10-maintenant
page: 135
page: 193
- chapterSlug: 11-annexes
page: 150
page: 199
defaultPlaylistOrder:
- ce-livre-est-une-facon
- de-quel-don-nous-parlons