Fix double-fire player, navigation par morceaux, admin labels morceaux

- BookPlayer : navigation par playlist (9 morceaux) au lieu de 11 chapitres
- stopPropagation clavier → plus de saut 1→3→5
- Sommaire aligné avec titres des morceaux
- Bouton back aligné avec clavier (toujours morceau précédent)
- Admin chapitres : tags morceaux cliquables avec étoile primary
- Admin liste chapitres : badges morceaux associés
- Éditeur markdown en vue split par défaut

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-27 14:08:58 +01:00
parent 8803087e77
commit 25bfc07b59
6 changed files with 314 additions and 162 deletions

View File

@@ -65,7 +65,7 @@ const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const tab = ref<'edit' | 'preview' | 'split'>('edit')
const tab = ref<'edit' | 'preview' | 'split'>('split')
const fullscreen = ref(false)
const textareaRef = ref<HTMLTextAreaElement>()

View File

@@ -35,8 +35,8 @@
<div class="i-lucide-list h-5 w-5" />
</button>
<div class="reader-bar-title">
<span class="reader-bar-num">{{ chapterIdx + 1 }}.</span>
{{ chapters[chapterIdx].title }}
<span class="reader-bar-num">{{ trackIdx + 1 }}.</span>
{{ currentTrack?.title ?? '' }}
</div>
<span class="reader-bar-pages">{{ scrollPercent }}%</span>
<button class="reader-bar-btn reader-bar-close" @click="close" aria-label="Fermer">
@@ -50,14 +50,14 @@
<div class="sommaire-panel">
<h4 class="sommaire-title">{{ bpContent?.reader.sommaireTitle ?? 'Sommaire' }}</h4>
<button
v-for="(ch, i) in chapters"
:key="i"
v-for="(track, i) in tracks"
:key="track.id"
class="sommaire-item"
:class="{ 'sommaire-item--active': chapterIdx === i }"
@click="goToChapter(i)"
:class="{ 'sommaire-item--active': trackIdx === i }"
@click="goToTrack(i)"
>
<span class="sommaire-num">{{ i + 1 }}</span>
{{ ch.title }}
{{ track.title }}
</button>
</div>
</aside>
@@ -76,12 +76,12 @@
ref="contentEl"
:style="contentStyle"
>
<div v-if="chapterLyrics" class="lyrics-content" v-html="chapterLyricsHtml" />
<div v-else-if="chapterSong" class="lyrics-empty">
<p class="op-40 italic">Paroles à venir pour « {{ chapterSong.title }} »</p>
<div v-if="currentLyrics" class="lyrics-content" v-html="currentLyricsHtml" />
<div v-else-if="currentSong" class="lyrics-empty">
<p class="op-40 italic">Paroles à venir pour « {{ currentSong.title }} »</p>
</div>
<div v-else class="lyrics-empty">
<p class="op-40 italic">Aucun morceau associé à ce chapitre</p>
<p class="op-40 italic">Aucun morceau sélectionné</p>
</div>
</div>
<!-- Page turn shadow overlay (paginated only) -->
@@ -92,28 +92,28 @@
<div class="reader-nav">
<button
class="reader-nav-btn"
:class="{ 'reader-nav-btn--hidden': isScrollMode ? chapterIdx <= 0 : (currentPage <= 0 && chapterIdx <= 0) }"
@click="isScrollMode ? prevChapter() : prevPage()"
:aria-label="isScrollMode ? 'Chapitre précédent' : 'Page précédente'"
:class="{ 'reader-nav-btn--hidden': isScrollMode ? trackIdx <= 0 : (currentPage <= 0 && trackIdx <= 0) }"
@click="isScrollMode ? prevTrack() : prevPage()"
:aria-label="isScrollMode ? 'Morceau précédent' : 'Page précédente'"
>
<div class="i-lucide-chevron-left h-5 w-5" />
</button>
<!-- Song disc (if chapter has a song) -->
<div v-if="chapterSong" class="reader-song">
<!-- Song disc -->
<div v-if="currentSong" class="reader-song">
<div class="reader-disc" :class="{ spinning: playerStore.isPlaying }">
<img src="/images/book-cover-spread.jpg" alt="" class="reader-disc-img" />
<div class="reader-disc-hole" />
</div>
<span class="reader-song-name">{{ chapterSong.title }}</span>
<span class="reader-song-name">{{ currentSong.title }}</span>
</div>
<div v-else class="reader-song" />
<button
class="reader-nav-btn"
:class="{ 'reader-nav-btn--hidden': isScrollMode ? chapterIdx >= chapters.length - 1 : (currentPage >= totalPages - 1 && chapterIdx >= chapters.length - 1) }"
@click="isScrollMode ? nextChapter() : nextPage()"
:aria-label="isScrollMode ? 'Chapitre suivant' : 'Page suivante'"
:class="{ 'reader-nav-btn--hidden': isScrollMode ? trackIdx >= tracks.length - 1 : (currentPage >= totalPages - 1 && trackIdx >= tracks.length - 1) }"
@click="isScrollMode ? nextTrack() : nextPage()"
:aria-label="isScrollMode ? 'Morceau suivant' : 'Page suivante'"
>
<div class="i-lucide-chevron-right h-5 w-5" />
</button>
@@ -123,7 +123,7 @@
<!-- Hint -->
<p class="bp-hint">
<template v-if="isScrollMode">
<span class="hidden md:inline">{{ bpContent?.reader.hints.desktopScroll ?? '← → chapitres · Défilement libre · Esc fermer' }}</span>
<span class="hidden md:inline">{{ bpContent?.reader.hints.desktopScroll ?? '← → morceaux · Défilement libre · Esc fermer' }}</span>
<span class="md:hidden">{{ bpContent?.reader.hints.mobileScroll ?? 'Défilez pour lire' }}</span>
</template>
<template v-else>
@@ -153,7 +153,7 @@ const overlayRef = ref<HTMLElement>()
const viewportEl = ref<HTMLElement>()
const contentEl = ref<HTMLElement>()
const chapterIdx = ref(0)
const trackIdx = ref(0)
const currentPage = ref(0)
const totalPages = ref(1)
const colWidth = ref(500)
@@ -165,10 +165,6 @@ const readingMode = ref<'paginated' | 'scroll'>('scroll')
const isScrollMode = computed(() => readingMode.value === 'scroll')
const scrollPercent = ref(0)
function toggleReadingMode() {
readingMode.value = readingMode.value === 'paginated' ? 'scroll' : 'paginated'
}
// When switching back to paginated, recalc pages
watch(readingMode, async (mode) => {
if (mode === 'paginated') {
@@ -185,66 +181,29 @@ function onViewportScroll() {
scrollPercent.value = max > 0 ? Math.round((el.scrollTop / max) * 100) : 0
}
const { init: initBookData, getSongs, getPrimarySong, getChapterForSong, getPlaylistOrder } = useBookData()
const { init: initBookData, getPlaylistOrder } = useBookData()
const audioPlayer = useAudioPlayer()
const playerStore = usePlayerStore()
// ── Content loaded flag (lyrics come from bookplayer config) ──
const contentLoaded = ref(false)
async function loadContent() {
if (contentLoaded.value) return
contentLoaded.value = true
}
// ── Chapter metadata ──
const chapters = [
{ slug: '01-introduction', title: 'Introduction' },
{ slug: '02-don', title: 'De quel don parlons-nous ?' },
{ slug: '03-mesure', title: 'La mesure du don' },
{ slug: '04-monnaie', title: 'Raison d\'être d\'une monnaie' },
{ slug: '05-trm', title: 'La TRM' },
{ slug: '06-economie', title: 'Créer une économie ?' },
{ slug: '07-echange', title: 'Échanger' },
{ slug: '08-institution', title: 'Relation institutionnelle' },
{ slug: '09-greffes', title: 'Autres greffes' },
{ slug: '10-maintenant', title: 'Et maintenant ?… action ?' },
{ slug: '11-annexes', title: 'Chapitres annexes' },
]
// ── Per-chapter color hues ──
const chapterHues: [number, number][] = [
[12, 36], // cover (intro + cover phases)
[15, 35], // 1
[350, 15], // 2
[36, 50], // 3
[170, 200], // 4
[220, 250], // 5
[270, 300], // 6
[320, 345], // 7
[150, 170], // 8
[190, 220], // 9
[40, 20], // 10
[210, 240], // 11
]
const sceneVars = computed(() => {
const idx = chapterIdx.value + 1
const [h1, h2] = chapterHues[idx] ?? chapterHues[0]
return { '--scene-h1': h1, '--scene-h2': h2 } as Record<string, number>
// ── Tracks: built from playlist order (songs), not chapters ──
const tracks = computed(() => {
return playerStore.playlist.map(song => ({
id: song.id,
title: song.title,
song,
}))
})
const chapterSong = computed(() => {
return getPrimarySong(chapters[chapterIdx.value].slug)
const currentTrack = computed(() => tracks.value[trackIdx.value] ?? null)
const currentSong = computed(() => currentTrack.value?.song ?? null)
const currentLyrics = computed(() => {
return currentSong.value?.lyrics?.trim() || ''
})
const chapterLyrics = computed(() => {
return chapterSong.value?.lyrics?.trim() || ''
})
const chapterLyricsHtml = computed(() => {
if (!chapterLyrics.value) return ''
return chapterLyrics.value
const currentLyricsHtml = computed(() => {
if (!currentLyrics.value) return ''
return currentLyrics.value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
@@ -255,6 +214,24 @@ const chapterLyricsHtml = computed(() => {
.replace(/$/, '</p>')
})
// ── Per-track color hues (9 tracks) ──
const trackHues: [number, number][] = [
[15, 35], // 1 Ce livre est une façon
[350, 15], // 2 De quel don nous parlons
[36, 50], // 3 Les asymétries
[170, 200], // 4 Inverser les flux
[220, 250], // 5 Ainsi soit-il
[270, 300], // 6 La croissance
[320, 345], // 7 Monnaie libre
[150, 170], // 8 Créer une économie
[190, 220], // 9 Coder la liberté
]
const sceneVars = computed(() => {
const [h1, h2] = trackHues[trackIdx.value] ?? trackHues[0]
return { '--scene-h1': h1, '--scene-h2': h2 } as Record<string, number>
})
// ── CSS columns pagination ──
const contentStyle = computed(() => {
if (isScrollMode.value) return {}
@@ -275,8 +252,8 @@ function recalcPages() {
let resizeObs: ResizeObserver | null = null
// Recalc when chapter changes
watch(chapterIdx, async () => {
// Recalc when track changes
watch(trackIdx, async () => {
currentPage.value = 0
await nextTick()
await nextTick()
@@ -284,8 +261,7 @@ watch(chapterIdx, async () => {
})
async function initReading() {
await loadContent()
chapterIdx.value = 0
trackIdx.value = 0
currentPage.value = 0
await nextTick()
await nextTick()
@@ -297,34 +273,35 @@ async function initReading() {
setTimeout(recalcPages, 200)
}
// ── Navigation ──
// ── Navigation by tracks (songs) ──
let _skipSongWatch = false
function goToChapter(idx: number) {
chapterIdx.value = idx
function goToTrack(idx: number) {
if (idx < 0 || idx >= tracks.value.length) return
trackIdx.value = idx
currentPage.value = 0
showSommaire.value = false
// Scroll to top in scroll mode
if (isScrollMode.value && viewportEl.value) {
viewportEl.value.scrollTop = 0
}
// Play chapter song (skip watcher to avoid loop)
const song = getPrimarySong(chapters[idx].slug)
// Play the song
const song = tracks.value[idx]?.song
if (song) {
_skipSongWatch = true
audioPlayer.loadAndPlay(song)
}
}
function nextChapter() {
if (chapterIdx.value < chapters.length - 1) {
goToChapter(chapterIdx.value + 1)
function nextTrack() {
if (trackIdx.value < tracks.value.length - 1) {
goToTrack(trackIdx.value + 1)
}
}
function prevChapter() {
if (chapterIdx.value > 0) {
goToChapter(chapterIdx.value - 1)
function prevTrack() {
if (trackIdx.value > 0) {
goToTrack(trackIdx.value - 1)
}
}
@@ -333,9 +310,8 @@ function nextPage() {
triggerTurn()
currentPage.value++
}
else if (chapterIdx.value < chapters.length - 1) {
// Next chapter
goToChapter(chapterIdx.value + 1)
else if (trackIdx.value < tracks.value.length - 1) {
goToTrack(trackIdx.value + 1)
}
}
@@ -344,25 +320,8 @@ function prevPage() {
triggerTurn()
currentPage.value--
}
else if (chapterIdx.value > 0) {
// Previous chapter, go to last page
chapterIdx.value--
currentPage.value = 0
showSommaire.value = false
const song = getPrimarySong(chapters[chapterIdx.value].slug)
if (song) {
_skipSongWatch = true
audioPlayer.loadAndPlay(song)
}
// After content loads, go to last page
watch(chapterIdx, async () => {
await nextTick()
await nextTick()
setTimeout(() => {
recalcPages()
currentPage.value = Math.max(0, totalPages.value - 1)
}, 150)
}, { once: true })
else if (trackIdx.value > 0) {
goToTrack(trackIdx.value - 1)
}
}
@@ -376,19 +335,26 @@ function close() {
}
function handleKeydown(e: KeyboardEvent) {
// CRITICAL: stop propagation so useKeyboardShortcuts doesn't also fire
e.stopPropagation()
if (e.key === 'Escape') { close(); return }
if (e.key === ' ') {
e.preventDefault()
audioPlayer.togglePlayPause()
return
}
if (isScrollMode.value) {
// Scroll mode: left/right = chapters, up/down = natural scroll (no preventDefault)
if (e.key === 'ArrowRight') { e.preventDefault(); nextChapter() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevChapter() }
else if (e.key === 'Escape') close()
if (e.key === 'ArrowRight') { e.preventDefault(); nextTrack() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevTrack() }
}
else {
// Paginated mode: left/right = pages, up/down = chapters
if (e.key === 'ArrowRight') { e.preventDefault(); nextPage() }
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPage() }
else if (e.key === 'ArrowDown') { e.preventDefault(); nextChapter() }
else if (e.key === 'ArrowUp') { e.preventDefault(); prevChapter() }
else if (e.key === 'Escape') close()
else if (e.key === 'ArrowDown') { e.preventDefault(); nextTrack() }
else if (e.key === 'ArrowUp') { e.preventDefault(); prevTrack() }
}
}
@@ -400,7 +366,6 @@ function onTouchStart(e: TouchEvent) {
}
function onTouchEnd(e: TouchEvent) {
// Disable page-swipe in scroll mode (vertical scroll is native)
if (isScrollMode.value) return
const diff = touchStartX - (e.changedTouches[0]?.screenX ?? 0)
if (Math.abs(diff) > 50) {
@@ -413,7 +378,6 @@ function onTouchEnd(e: TouchEvent) {
watch(isOpen, async (open) => {
if (open) {
showSommaire.value = false
contentLoaded.value = false
await initBookData()
await nextTick()
overlayRef.value?.focus()
@@ -422,12 +386,10 @@ watch(isOpen, async (open) => {
// Load playlist & play first song
const playlist = getPlaylistOrder()
if (playlist.length) playerStore.setPlaylist(playlist)
const first = getSongs().find(s => s.id === 'ce-livre-est-une-facon')
if (first) {
if (playlist.length) {
_skipSongWatch = true
audioPlayer.loadAndPlay(first)
audioPlayer.loadAndPlay(playlist[0])
}
// Start reading directly
await initReading()
}
else {
@@ -437,20 +399,16 @@ watch(isOpen, async (open) => {
}
})
// ── Sync: when song changes in player, navigate to matching chapter (display only, no loadAndPlay) ──
// ── Sync: when song changes externally (persistent player controls), update trackIdx ──
watch(() => playerStore.currentSong, (song) => {
if (!song || !isOpen.value) return
if (_skipSongWatch) {
_skipSongWatch = false
return
}
const slug = getChapterForSong(song.id)
if (!slug) return
const idx = chapters.findIndex(ch => ch.slug === slug)
if (idx !== -1 && idx !== chapterIdx.value) {
// Only update the displayed chapter — do NOT call goToChapter/loadAndPlay
// to avoid retriggering this watcher and causing song skips
chapterIdx.value = idx
const idx = tracks.value.findIndex(t => t.id === song.id)
if (idx !== -1 && idx !== trackIdx.value) {
trackIdx.value = idx
currentPage.value = 0
showSommaire.value = false
if (isScrollMode.value && viewportEl.value) {