Admin : déroulant sommaire PDF par chapitre, transitions pages, URL GrateWizard
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
@@ -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`)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
43
server/api/admin/pdf-outline.get.ts
Normal file
43
server/api/admin/pdf-outline.get.ts
Normal 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
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user