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
|
## Intégration GrateWizard
|
||||||
|
|
||||||
- URL dev configurée dans `app/app.config.ts` → `localhost:3001`
|
- 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`
|
- Ouverture en popup via `composables/useGrateWizard.ts`
|
||||||
- GrateWizard est un projet Next.js séparé (`/home/yvv/Documents/PROD/DEV/GrateWizard`)
|
- GrateWizard est un projet Next.js séparé (`/home/yvv/Documents/PROD/DEV/GrateWizard`)
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default defineAppConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
gratewizard: {
|
gratewizard: {
|
||||||
url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.ml',
|
url: import.meta.dev ? 'http://localhost:3001' : 'https://gratewizard.axiom-team.fr',
|
||||||
popup: {
|
popup: {
|
||||||
width: 420,
|
width: 420,
|
||||||
height: 720,
|
height: 720,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
placeholder="/pdf/une-economie-du-don.pdf"
|
placeholder="/pdf/une-economie-du-don.pdf"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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-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-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" />
|
<div v-else class="i-lucide-save h-3.5 w-3.5" />
|
||||||
@@ -59,6 +59,20 @@
|
|||||||
>{{ name }}</span>
|
>{{ name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
class="delete-btn"
|
class="delete-btn"
|
||||||
@click="removeChapter(chapter.slug)"
|
@click="removeChapter(chapter.slug)"
|
||||||
@@ -115,25 +129,54 @@ const saved = ref(false)
|
|||||||
const newTitle = ref('')
|
const newTitle = ref('')
|
||||||
const newSlug = ref('')
|
const newSlug = ref('')
|
||||||
|
|
||||||
// PDF path
|
// PDF path + outline
|
||||||
const pdfPath = ref(bookConfig.value?.book?.pdfFile ?? '/pdf/une-economie-du-don.pdf')
|
const pdfPath = ref(bookConfig.value?.book?.pdfFile ?? '/pdf/une-economie-du-don.pdf')
|
||||||
const savingPdf = ref(false)
|
const savingPdf = ref(false)
|
||||||
const savedPdf = 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
|
if (!bookConfig.value) return
|
||||||
savingPdf.value = true
|
savingPdf.value = true
|
||||||
savedPdf.value = false
|
savedPdf.value = false
|
||||||
try {
|
try {
|
||||||
bookConfig.value.book.pdfFile = pdfPath.value
|
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', {
|
await $fetch('/api/admin/content/config', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: bookConfig.value,
|
body: bookConfig.value,
|
||||||
})
|
})
|
||||||
savedPdf.value = true
|
savedPdf.value = true
|
||||||
setTimeout(() => { savedPdf.value = false }, 2000)
|
setTimeout(() => { savedPdf.value = false }, 2000)
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
savingPdf.value = false
|
savingPdf.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,6 +353,23 @@ async function removeChapter(slug: string) {
|
|||||||
border: 1px solid hsl(12 76% 48% / 0.2);
|
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 {
|
.delete-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0.375rem;
|
padding: 0.375rem;
|
||||||
|
|||||||
@@ -151,12 +151,31 @@ html, body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-slot.empty {
|
.page-slot.empty {
|
||||||
visibility: hidden;
|
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 */
|
/* Navigation bar */
|
||||||
.nav-bar {
|
.nav-bar {
|
||||||
display: flex;
|
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 spreads = []; // [[1], [2,3], [4,5], ...] — page 1 alone (cover), then pairs
|
||||||
let pageCanvasCache = new Map();
|
let pageCanvasCache = new Map();
|
||||||
let outlinePageMap = []; // [{item, pageNum}] for highlighting
|
let outlinePageMap = []; // [{item, pageNum}] for highlighting
|
||||||
|
let isAnimating = false;
|
||||||
|
|
||||||
function buildSpreads(numPages) {
|
function buildSpreads(numPages) {
|
||||||
spreads = [];
|
spreads = [];
|
||||||
@@ -327,32 +347,73 @@ async function renderPageCanvas(pageNum) {
|
|||||||
return canvas;
|
return canvas;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showSpread(index) {
|
async function showSpread(index, animate = true) {
|
||||||
if (index < 0 || index >= spreads.length) return;
|
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;
|
currentSpread = index;
|
||||||
|
|
||||||
const pages = spreads[index];
|
const pages = spreads[index];
|
||||||
const slotLeft = document.getElementById('slotLeft');
|
const slotLeft = document.getElementById('slotLeft');
|
||||||
const slotRight = document.getElementById('slotRight');
|
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 = '';
|
slotLeft.innerHTML = '';
|
||||||
slotRight.innerHTML = '';
|
slotRight.innerHTML = '';
|
||||||
slotLeft.className = 'page-slot';
|
slotLeft.className = 'page-slot';
|
||||||
slotRight.className = 'page-slot';
|
slotRight.className = 'page-slot';
|
||||||
|
|
||||||
if (pages.length === 1) {
|
if (pages.length === 1) {
|
||||||
// Single page (cover or last odd page) — centered
|
|
||||||
const canvas = await renderPageCanvas(pages[0]);
|
const canvas = await renderPageCanvas(pages[0]);
|
||||||
|
if (shouldAnimate) {
|
||||||
|
canvas.style.setProperty('--enter-dir', `${direction * 20}px`);
|
||||||
|
canvas.classList.add('entering');
|
||||||
|
}
|
||||||
slotLeft.appendChild(canvas);
|
slotLeft.appendChild(canvas);
|
||||||
slotRight.className = 'page-slot empty';
|
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 {
|
} else {
|
||||||
const [left, right] = await Promise.all([
|
const [left, right] = await Promise.all([
|
||||||
renderPageCanvas(pages[0]),
|
renderPageCanvas(pages[0]),
|
||||||
renderPageCanvas(pages[1]),
|
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);
|
slotLeft.appendChild(left);
|
||||||
slotRight.appendChild(right);
|
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();
|
updateNav();
|
||||||
highlightOutline();
|
highlightOutline();
|
||||||
@@ -405,7 +466,7 @@ window.addEventListener('resize', () => {
|
|||||||
clearTimeout(resizeTimer);
|
clearTimeout(resizeTimer);
|
||||||
resizeTimer = setTimeout(() => {
|
resizeTimer = setTimeout(() => {
|
||||||
pageCanvasCache.clear();
|
pageCanvasCache.clear();
|
||||||
showSpread(currentSpread);
|
showSpread(currentSpread, false);
|
||||||
}, 200);
|
}, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,7 +566,7 @@ async function init() {
|
|||||||
document.getElementById('loadingMsg').remove();
|
document.getElementById('loadingMsg').remove();
|
||||||
|
|
||||||
const startSpread = getSpreadForPage(targetPage);
|
const startSpread = getSpreadForPage(targetPage);
|
||||||
await showSpread(startSpread >= 0 ? startSpread : 0);
|
await showSpread(startSpread >= 0 ? startSpread : 0, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
init().catch(err => {
|
init().catch(err => {
|
||||||
@@ -538,7 +599,7 @@ window.toggleSidebar = function() {
|
|||||||
// Recalc on sidebar toggle
|
// Recalc on sidebar toggle
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
pageCanvasCache.clear();
|
pageCanvasCache.clear();
|
||||||
showSpread(currentSpread);
|
showSpread(currentSpread, false);
|
||||||
}, 300);
|
}, 300);
|
||||||
};
|
};
|
||||||
</script>
|
</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
|
primary: true
|
||||||
chapterPages:
|
chapterPages:
|
||||||
- chapterSlug: 01-introduction
|
- chapterSlug: 01-introduction
|
||||||
page: 5
|
page: 9
|
||||||
- chapterSlug: 02-don
|
- chapterSlug: 02-don
|
||||||
page: 15
|
page: 23
|
||||||
- chapterSlug: 03-mesure
|
- chapterSlug: 03-mesure
|
||||||
page: 30
|
page: 43
|
||||||
- chapterSlug: 04-monnaie
|
- chapterSlug: 04-monnaie
|
||||||
page: 45
|
page: 49
|
||||||
- chapterSlug: 05-trm
|
- chapterSlug: 05-trm
|
||||||
page: 60
|
page: 83
|
||||||
- chapterSlug: 06-economie
|
- chapterSlug: 06-economie
|
||||||
page: 75
|
page: 121
|
||||||
- chapterSlug: 07-echange
|
- chapterSlug: 07-echange
|
||||||
page: 90
|
page: 147
|
||||||
- chapterSlug: 08-institution
|
- chapterSlug: 08-institution
|
||||||
page: 105
|
page: 163
|
||||||
- chapterSlug: 09-greffes
|
- chapterSlug: 09-greffes
|
||||||
page: 120
|
page: 181
|
||||||
- chapterSlug: 10-maintenant
|
- chapterSlug: 10-maintenant
|
||||||
page: 135
|
page: 193
|
||||||
- chapterSlug: 11-annexes
|
- chapterSlug: 11-annexes
|
||||||
page: 150
|
page: 199
|
||||||
defaultPlaylistOrder:
|
defaultPlaylistOrder:
|
||||||
- ce-livre-est-une-facon
|
- ce-livre-est-une-facon
|
||||||
- de-quel-don-nous-parlons
|
- de-quel-don-nous-parlons
|
||||||
|
|||||||
Reference in New Issue
Block a user