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:
@@ -65,7 +65,7 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [value: string]
|
'update:modelValue': [value: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const tab = ref<'edit' | 'preview' | 'split'>('edit')
|
const tab = ref<'edit' | 'preview' | 'split'>('split')
|
||||||
const fullscreen = ref(false)
|
const fullscreen = ref(false)
|
||||||
const textareaRef = ref<HTMLTextAreaElement>()
|
const textareaRef = ref<HTMLTextAreaElement>()
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,8 @@
|
|||||||
<div class="i-lucide-list h-5 w-5" />
|
<div class="i-lucide-list h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
<div class="reader-bar-title">
|
<div class="reader-bar-title">
|
||||||
<span class="reader-bar-num">{{ chapterIdx + 1 }}.</span>
|
<span class="reader-bar-num">{{ trackIdx + 1 }}.</span>
|
||||||
{{ chapters[chapterIdx].title }}
|
{{ currentTrack?.title ?? '' }}
|
||||||
</div>
|
</div>
|
||||||
<span class="reader-bar-pages">{{ scrollPercent }}%</span>
|
<span class="reader-bar-pages">{{ scrollPercent }}%</span>
|
||||||
<button class="reader-bar-btn reader-bar-close" @click="close" aria-label="Fermer">
|
<button class="reader-bar-btn reader-bar-close" @click="close" aria-label="Fermer">
|
||||||
@@ -50,14 +50,14 @@
|
|||||||
<div class="sommaire-panel">
|
<div class="sommaire-panel">
|
||||||
<h4 class="sommaire-title">{{ bpContent?.reader.sommaireTitle ?? 'Sommaire' }}</h4>
|
<h4 class="sommaire-title">{{ bpContent?.reader.sommaireTitle ?? 'Sommaire' }}</h4>
|
||||||
<button
|
<button
|
||||||
v-for="(ch, i) in chapters"
|
v-for="(track, i) in tracks"
|
||||||
:key="i"
|
:key="track.id"
|
||||||
class="sommaire-item"
|
class="sommaire-item"
|
||||||
:class="{ 'sommaire-item--active': chapterIdx === i }"
|
:class="{ 'sommaire-item--active': trackIdx === i }"
|
||||||
@click="goToChapter(i)"
|
@click="goToTrack(i)"
|
||||||
>
|
>
|
||||||
<span class="sommaire-num">{{ i + 1 }}</span>
|
<span class="sommaire-num">{{ i + 1 }}</span>
|
||||||
{{ ch.title }}
|
{{ track.title }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -76,12 +76,12 @@
|
|||||||
ref="contentEl"
|
ref="contentEl"
|
||||||
:style="contentStyle"
|
:style="contentStyle"
|
||||||
>
|
>
|
||||||
<div v-if="chapterLyrics" class="lyrics-content" v-html="chapterLyricsHtml" />
|
<div v-if="currentLyrics" class="lyrics-content" v-html="currentLyricsHtml" />
|
||||||
<div v-else-if="chapterSong" class="lyrics-empty">
|
<div v-else-if="currentSong" class="lyrics-empty">
|
||||||
<p class="op-40 italic">Paroles à venir pour « {{ chapterSong.title }} »</p>
|
<p class="op-40 italic">Paroles à venir pour « {{ currentSong.title }} »</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="lyrics-empty">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Page turn shadow overlay (paginated only) -->
|
<!-- Page turn shadow overlay (paginated only) -->
|
||||||
@@ -92,28 +92,28 @@
|
|||||||
<div class="reader-nav">
|
<div class="reader-nav">
|
||||||
<button
|
<button
|
||||||
class="reader-nav-btn"
|
class="reader-nav-btn"
|
||||||
:class="{ 'reader-nav-btn--hidden': isScrollMode ? chapterIdx <= 0 : (currentPage <= 0 && chapterIdx <= 0) }"
|
:class="{ 'reader-nav-btn--hidden': isScrollMode ? trackIdx <= 0 : (currentPage <= 0 && trackIdx <= 0) }"
|
||||||
@click="isScrollMode ? prevChapter() : prevPage()"
|
@click="isScrollMode ? prevTrack() : prevPage()"
|
||||||
:aria-label="isScrollMode ? 'Chapitre précédent' : 'Page précédente'"
|
:aria-label="isScrollMode ? 'Morceau précédent' : 'Page précédente'"
|
||||||
>
|
>
|
||||||
<div class="i-lucide-chevron-left h-5 w-5" />
|
<div class="i-lucide-chevron-left h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Song disc (if chapter has a song) -->
|
<!-- Song disc -->
|
||||||
<div v-if="chapterSong" class="reader-song">
|
<div v-if="currentSong" class="reader-song">
|
||||||
<div class="reader-disc" :class="{ spinning: playerStore.isPlaying }">
|
<div class="reader-disc" :class="{ spinning: playerStore.isPlaying }">
|
||||||
<img src="/images/book-cover-spread.jpg" alt="" class="reader-disc-img" />
|
<img src="/images/book-cover-spread.jpg" alt="" class="reader-disc-img" />
|
||||||
<div class="reader-disc-hole" />
|
<div class="reader-disc-hole" />
|
||||||
</div>
|
</div>
|
||||||
<span class="reader-song-name">{{ chapterSong.title }}</span>
|
<span class="reader-song-name">{{ currentSong.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="reader-song" />
|
<div v-else class="reader-song" />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="reader-nav-btn"
|
class="reader-nav-btn"
|
||||||
:class="{ 'reader-nav-btn--hidden': isScrollMode ? chapterIdx >= chapters.length - 1 : (currentPage >= totalPages - 1 && chapterIdx >= chapters.length - 1) }"
|
:class="{ 'reader-nav-btn--hidden': isScrollMode ? trackIdx >= tracks.length - 1 : (currentPage >= totalPages - 1 && trackIdx >= tracks.length - 1) }"
|
||||||
@click="isScrollMode ? nextChapter() : nextPage()"
|
@click="isScrollMode ? nextTrack() : nextPage()"
|
||||||
:aria-label="isScrollMode ? 'Chapitre suivant' : 'Page suivante'"
|
:aria-label="isScrollMode ? 'Morceau suivant' : 'Page suivante'"
|
||||||
>
|
>
|
||||||
<div class="i-lucide-chevron-right h-5 w-5" />
|
<div class="i-lucide-chevron-right h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
<!-- Hint -->
|
<!-- Hint -->
|
||||||
<p class="bp-hint">
|
<p class="bp-hint">
|
||||||
<template v-if="isScrollMode">
|
<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>
|
<span class="md:hidden">{{ bpContent?.reader.hints.mobileScroll ?? 'Défilez pour lire' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -153,7 +153,7 @@ const overlayRef = ref<HTMLElement>()
|
|||||||
const viewportEl = ref<HTMLElement>()
|
const viewportEl = ref<HTMLElement>()
|
||||||
const contentEl = ref<HTMLElement>()
|
const contentEl = ref<HTMLElement>()
|
||||||
|
|
||||||
const chapterIdx = ref(0)
|
const trackIdx = ref(0)
|
||||||
const currentPage = ref(0)
|
const currentPage = ref(0)
|
||||||
const totalPages = ref(1)
|
const totalPages = ref(1)
|
||||||
const colWidth = ref(500)
|
const colWidth = ref(500)
|
||||||
@@ -165,10 +165,6 @@ const readingMode = ref<'paginated' | 'scroll'>('scroll')
|
|||||||
const isScrollMode = computed(() => readingMode.value === 'scroll')
|
const isScrollMode = computed(() => readingMode.value === 'scroll')
|
||||||
const scrollPercent = ref(0)
|
const scrollPercent = ref(0)
|
||||||
|
|
||||||
function toggleReadingMode() {
|
|
||||||
readingMode.value = readingMode.value === 'paginated' ? 'scroll' : 'paginated'
|
|
||||||
}
|
|
||||||
|
|
||||||
// When switching back to paginated, recalc pages
|
// When switching back to paginated, recalc pages
|
||||||
watch(readingMode, async (mode) => {
|
watch(readingMode, async (mode) => {
|
||||||
if (mode === 'paginated') {
|
if (mode === 'paginated') {
|
||||||
@@ -185,66 +181,29 @@ function onViewportScroll() {
|
|||||||
scrollPercent.value = max > 0 ? Math.round((el.scrollTop / max) * 100) : 0
|
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 audioPlayer = useAudioPlayer()
|
||||||
const playerStore = usePlayerStore()
|
const playerStore = usePlayerStore()
|
||||||
|
|
||||||
// ── Content loaded flag (lyrics come from bookplayer config) ──
|
// ── Tracks: built from playlist order (songs), not chapters ──
|
||||||
const contentLoaded = ref(false)
|
const tracks = computed(() => {
|
||||||
|
return playerStore.playlist.map(song => ({
|
||||||
async function loadContent() {
|
id: song.id,
|
||||||
if (contentLoaded.value) return
|
title: song.title,
|
||||||
contentLoaded.value = true
|
song,
|
||||||
}
|
}))
|
||||||
|
|
||||||
// ── 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>
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const chapterSong = computed(() => {
|
const currentTrack = computed(() => tracks.value[trackIdx.value] ?? null)
|
||||||
return getPrimarySong(chapters[chapterIdx.value].slug)
|
const currentSong = computed(() => currentTrack.value?.song ?? null)
|
||||||
|
|
||||||
|
const currentLyrics = computed(() => {
|
||||||
|
return currentSong.value?.lyrics?.trim() || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const chapterLyrics = computed(() => {
|
const currentLyricsHtml = computed(() => {
|
||||||
return chapterSong.value?.lyrics?.trim() || ''
|
if (!currentLyrics.value) return ''
|
||||||
})
|
return currentLyrics.value
|
||||||
|
|
||||||
const chapterLyricsHtml = computed(() => {
|
|
||||||
if (!chapterLyrics.value) return ''
|
|
||||||
return chapterLyrics.value
|
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
@@ -255,6 +214,24 @@ const chapterLyricsHtml = computed(() => {
|
|||||||
.replace(/$/, '</p>')
|
.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 ──
|
// ── CSS columns pagination ──
|
||||||
const contentStyle = computed(() => {
|
const contentStyle = computed(() => {
|
||||||
if (isScrollMode.value) return {}
|
if (isScrollMode.value) return {}
|
||||||
@@ -275,8 +252,8 @@ function recalcPages() {
|
|||||||
|
|
||||||
let resizeObs: ResizeObserver | null = null
|
let resizeObs: ResizeObserver | null = null
|
||||||
|
|
||||||
// Recalc when chapter changes
|
// Recalc when track changes
|
||||||
watch(chapterIdx, async () => {
|
watch(trackIdx, async () => {
|
||||||
currentPage.value = 0
|
currentPage.value = 0
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -284,8 +261,7 @@ watch(chapterIdx, async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
async function initReading() {
|
async function initReading() {
|
||||||
await loadContent()
|
trackIdx.value = 0
|
||||||
chapterIdx.value = 0
|
|
||||||
currentPage.value = 0
|
currentPage.value = 0
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -297,34 +273,35 @@ async function initReading() {
|
|||||||
setTimeout(recalcPages, 200)
|
setTimeout(recalcPages, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Navigation ──
|
// ── Navigation by tracks (songs) ──
|
||||||
let _skipSongWatch = false
|
let _skipSongWatch = false
|
||||||
|
|
||||||
function goToChapter(idx: number) {
|
function goToTrack(idx: number) {
|
||||||
chapterIdx.value = idx
|
if (idx < 0 || idx >= tracks.value.length) return
|
||||||
|
trackIdx.value = idx
|
||||||
currentPage.value = 0
|
currentPage.value = 0
|
||||||
showSommaire.value = false
|
showSommaire.value = false
|
||||||
// Scroll to top in scroll mode
|
// Scroll to top in scroll mode
|
||||||
if (isScrollMode.value && viewportEl.value) {
|
if (isScrollMode.value && viewportEl.value) {
|
||||||
viewportEl.value.scrollTop = 0
|
viewportEl.value.scrollTop = 0
|
||||||
}
|
}
|
||||||
// Play chapter song (skip watcher to avoid loop)
|
// Play the song
|
||||||
const song = getPrimarySong(chapters[idx].slug)
|
const song = tracks.value[idx]?.song
|
||||||
if (song) {
|
if (song) {
|
||||||
_skipSongWatch = true
|
_skipSongWatch = true
|
||||||
audioPlayer.loadAndPlay(song)
|
audioPlayer.loadAndPlay(song)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextChapter() {
|
function nextTrack() {
|
||||||
if (chapterIdx.value < chapters.length - 1) {
|
if (trackIdx.value < tracks.value.length - 1) {
|
||||||
goToChapter(chapterIdx.value + 1)
|
goToTrack(trackIdx.value + 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function prevChapter() {
|
function prevTrack() {
|
||||||
if (chapterIdx.value > 0) {
|
if (trackIdx.value > 0) {
|
||||||
goToChapter(chapterIdx.value - 1)
|
goToTrack(trackIdx.value - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,9 +310,8 @@ function nextPage() {
|
|||||||
triggerTurn()
|
triggerTurn()
|
||||||
currentPage.value++
|
currentPage.value++
|
||||||
}
|
}
|
||||||
else if (chapterIdx.value < chapters.length - 1) {
|
else if (trackIdx.value < tracks.value.length - 1) {
|
||||||
// Next chapter
|
goToTrack(trackIdx.value + 1)
|
||||||
goToChapter(chapterIdx.value + 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,25 +320,8 @@ function prevPage() {
|
|||||||
triggerTurn()
|
triggerTurn()
|
||||||
currentPage.value--
|
currentPage.value--
|
||||||
}
|
}
|
||||||
else if (chapterIdx.value > 0) {
|
else if (trackIdx.value > 0) {
|
||||||
// Previous chapter, go to last page
|
goToTrack(trackIdx.value - 1)
|
||||||
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 })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,19 +335,26 @@ function close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
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) {
|
if (isScrollMode.value) {
|
||||||
// Scroll mode: left/right = chapters, up/down = natural scroll (no preventDefault)
|
if (e.key === 'ArrowRight') { e.preventDefault(); nextTrack() }
|
||||||
if (e.key === 'ArrowRight') { e.preventDefault(); nextChapter() }
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevTrack() }
|
||||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevChapter() }
|
|
||||||
else if (e.key === 'Escape') close()
|
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// Paginated mode: left/right = pages, up/down = chapters
|
|
||||||
if (e.key === 'ArrowRight') { e.preventDefault(); nextPage() }
|
if (e.key === 'ArrowRight') { e.preventDefault(); nextPage() }
|
||||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPage() }
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPage() }
|
||||||
else if (e.key === 'ArrowDown') { e.preventDefault(); nextChapter() }
|
else if (e.key === 'ArrowDown') { e.preventDefault(); nextTrack() }
|
||||||
else if (e.key === 'ArrowUp') { e.preventDefault(); prevChapter() }
|
else if (e.key === 'ArrowUp') { e.preventDefault(); prevTrack() }
|
||||||
else if (e.key === 'Escape') close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +366,6 @@ function onTouchStart(e: TouchEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onTouchEnd(e: TouchEvent) {
|
function onTouchEnd(e: TouchEvent) {
|
||||||
// Disable page-swipe in scroll mode (vertical scroll is native)
|
|
||||||
if (isScrollMode.value) return
|
if (isScrollMode.value) return
|
||||||
const diff = touchStartX - (e.changedTouches[0]?.screenX ?? 0)
|
const diff = touchStartX - (e.changedTouches[0]?.screenX ?? 0)
|
||||||
if (Math.abs(diff) > 50) {
|
if (Math.abs(diff) > 50) {
|
||||||
@@ -413,7 +378,6 @@ function onTouchEnd(e: TouchEvent) {
|
|||||||
watch(isOpen, async (open) => {
|
watch(isOpen, async (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
showSommaire.value = false
|
showSommaire.value = false
|
||||||
contentLoaded.value = false
|
|
||||||
await initBookData()
|
await initBookData()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
overlayRef.value?.focus()
|
overlayRef.value?.focus()
|
||||||
@@ -422,12 +386,10 @@ watch(isOpen, async (open) => {
|
|||||||
// Load playlist & play first song
|
// Load playlist & play first song
|
||||||
const playlist = getPlaylistOrder()
|
const playlist = getPlaylistOrder()
|
||||||
if (playlist.length) playerStore.setPlaylist(playlist)
|
if (playlist.length) playerStore.setPlaylist(playlist)
|
||||||
const first = getSongs().find(s => s.id === 'ce-livre-est-une-facon')
|
if (playlist.length) {
|
||||||
if (first) {
|
|
||||||
_skipSongWatch = true
|
_skipSongWatch = true
|
||||||
audioPlayer.loadAndPlay(first)
|
audioPlayer.loadAndPlay(playlist[0])
|
||||||
}
|
}
|
||||||
// Start reading directly
|
|
||||||
await initReading()
|
await initReading()
|
||||||
}
|
}
|
||||||
else {
|
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) => {
|
watch(() => playerStore.currentSong, (song) => {
|
||||||
if (!song || !isOpen.value) return
|
if (!song || !isOpen.value) return
|
||||||
if (_skipSongWatch) {
|
if (_skipSongWatch) {
|
||||||
_skipSongWatch = false
|
_skipSongWatch = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const slug = getChapterForSong(song.id)
|
const idx = tracks.value.findIndex(t => t.id === song.id)
|
||||||
if (!slug) return
|
if (idx !== -1 && idx !== trackIdx.value) {
|
||||||
const idx = chapters.findIndex(ch => ch.slug === slug)
|
trackIdx.value = idx
|
||||||
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
|
|
||||||
currentPage.value = 0
|
currentPage.value = 0
|
||||||
showSommaire.value = false
|
showSommaire.value = false
|
||||||
if (isScrollMode.value && viewportEl.value) {
|
if (isScrollMode.value && viewportEl.value) {
|
||||||
|
|||||||
@@ -124,15 +124,9 @@ export function useAudioPlayer() {
|
|||||||
function playPrev() {
|
function playPrev() {
|
||||||
const song = store.prevSong()
|
const song = store.prevSong()
|
||||||
if (song) {
|
if (song) {
|
||||||
if (song === store.currentSong && store.currentTime <= 3) {
|
|
||||||
// prevSong already reset time
|
|
||||||
seek(0)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
loadAndPlay(song)
|
loadAndPlay(song)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Watch volume changes from store
|
// Watch volume changes from store
|
||||||
watch(() => store.volume, (vol) => {
|
watch(() => store.volume, (vol) => {
|
||||||
|
|||||||
@@ -34,6 +34,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
|
|
||||||
|
<AdminFormSection title="Morceaux associés">
|
||||||
|
<p class="text-xs text-white/40 mb-3">
|
||||||
|
Cliquez pour associer/dissocier. Cliquez sur l'étoile pour définir le morceau principal.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<div
|
||||||
|
v-for="song in allSongs"
|
||||||
|
:key="song.id"
|
||||||
|
class="song-tag"
|
||||||
|
:class="{
|
||||||
|
'song-tag--active': isLinked(song.id),
|
||||||
|
'song-tag--primary': isPrimary(song.id),
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="isLinked(song.id)"
|
||||||
|
class="song-star"
|
||||||
|
:class="{ 'song-star--active': isPrimary(song.id) }"
|
||||||
|
@click="setPrimary(song.id)"
|
||||||
|
aria-label="Définir comme principal"
|
||||||
|
>
|
||||||
|
<div class="i-lucide-star h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button class="song-tag-label" @click="toggleSong(song.id)">
|
||||||
|
{{ song.title }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminFormSection>
|
||||||
|
|
||||||
<AdminFormSection title="Contenu" open>
|
<AdminFormSection title="Contenu" open>
|
||||||
<AdminMarkdownEditor v-model="body" :rows="35" />
|
<AdminMarkdownEditor v-model="body" :rows="35" />
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
@@ -51,6 +81,7 @@ const route = useRoute()
|
|||||||
const slug = computed(() => route.params.slug as string)
|
const slug = computed(() => route.params.slug as string)
|
||||||
|
|
||||||
const { data: chapter } = await useFetch(() => `/api/admin/chapters/${slug.value}`)
|
const { data: chapter } = await useFetch(() => `/api/admin/chapters/${slug.value}`)
|
||||||
|
const { data: bookConfig } = await useFetch<any>('/api/content/config')
|
||||||
|
|
||||||
const title = ref('')
|
const title = ref('')
|
||||||
const description = ref('')
|
const description = ref('')
|
||||||
@@ -65,7 +96,6 @@ const wordCount = computed(() => {
|
|||||||
|
|
||||||
watch(chapter, (val) => {
|
watch(chapter, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
// Parse frontmatter fields
|
|
||||||
const fm = val.frontmatter ?? ''
|
const fm = val.frontmatter ?? ''
|
||||||
title.value = extractFmField(fm, 'title')
|
title.value = extractFmField(fm, 'title')
|
||||||
description.value = extractFmField(fm, 'description')
|
description.value = extractFmField(fm, 'description')
|
||||||
@@ -79,6 +109,52 @@ function extractFmField(fm: string, field: string): string {
|
|||||||
return match ? match[1].trim() : ''
|
return match ? match[1].trim() : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Morceaux associés ──
|
||||||
|
const allSongs = computed(() => bookConfig.value?.songs ?? [])
|
||||||
|
const linkedSongIds = ref<Set<string>>(new Set())
|
||||||
|
const primarySongId = ref<string | null>(null)
|
||||||
|
|
||||||
|
watch(bookConfig, (val) => {
|
||||||
|
if (!val) return
|
||||||
|
const links = (val.chapterSongs ?? []).filter(
|
||||||
|
(cs: any) => cs.chapterSlug === slug.value,
|
||||||
|
)
|
||||||
|
linkedSongIds.value = new Set(links.map((l: any) => l.songId))
|
||||||
|
const primary = links.find((l: any) => l.primary)
|
||||||
|
primarySongId.value = primary?.songId ?? null
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function isLinked(songId: string) {
|
||||||
|
return linkedSongIds.value.has(songId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPrimary(songId: string) {
|
||||||
|
return primarySongId.value === songId
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSong(songId: string) {
|
||||||
|
const next = new Set(linkedSongIds.value)
|
||||||
|
if (next.has(songId)) {
|
||||||
|
next.delete(songId)
|
||||||
|
if (primarySongId.value === songId) primarySongId.value = null
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
next.add(songId)
|
||||||
|
if (!primarySongId.value) primarySongId.value = songId
|
||||||
|
}
|
||||||
|
linkedSongIds.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPrimary(songId: string) {
|
||||||
|
if (!linkedSongIds.value.has(songId)) {
|
||||||
|
const next = new Set(linkedSongIds.value)
|
||||||
|
next.add(songId)
|
||||||
|
linkedSongIds.value = next
|
||||||
|
}
|
||||||
|
primarySongId.value = songId
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save ──
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const saved = ref(false)
|
const saved = ref(false)
|
||||||
|
|
||||||
@@ -86,6 +162,7 @@ async function save() {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
saved.value = false
|
saved.value = false
|
||||||
try {
|
try {
|
||||||
|
// 1. Sauvegarder le contenu du chapitre
|
||||||
const order = chapter.value?.frontmatter?.match(/order:\s*(\d+)/)?.[1] ?? '1'
|
const order = chapter.value?.frontmatter?.match(/order:\s*(\d+)/)?.[1] ?? '1'
|
||||||
const frontmatter = [
|
const frontmatter = [
|
||||||
`title: "${title.value}"`,
|
`title: "${title.value}"`,
|
||||||
@@ -98,6 +175,27 @@ async function save() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: { frontmatter, body: body.value },
|
body: { frontmatter, body: body.value },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 2. Sauvegarder les liaisons morceaux dans la config
|
||||||
|
if (bookConfig.value) {
|
||||||
|
const otherLinks = (bookConfig.value.chapterSongs ?? []).filter(
|
||||||
|
(cs: any) => cs.chapterSlug !== slug.value,
|
||||||
|
)
|
||||||
|
const newLinks = [...linkedSongIds.value].map(songId => ({
|
||||||
|
chapterSlug: slug.value,
|
||||||
|
songId,
|
||||||
|
primary: songId === primarySongId.value,
|
||||||
|
}))
|
||||||
|
const updatedConfig = {
|
||||||
|
...bookConfig.value,
|
||||||
|
chapterSongs: [...otherLinks, ...newLinks],
|
||||||
|
}
|
||||||
|
await $fetch('/api/admin/content/config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: updatedConfig,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
saved.value = true
|
saved.value = true
|
||||||
setTimeout(() => { saved.value = false }, 2000)
|
setTimeout(() => { saved.value = false }, 2000)
|
||||||
}
|
}
|
||||||
@@ -129,4 +227,70 @@ async function save() {
|
|||||||
outline: none;
|
outline: none;
|
||||||
border-color: hsl(12 76% 48% / 0.5);
|
border-color: hsl(12 76% 48% / 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Song tags ── */
|
||||||
|
.song-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid hsl(20 8% 22%);
|
||||||
|
transition: all 0.15s;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tag:hover {
|
||||||
|
border-color: hsl(12 76% 48% / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tag--active {
|
||||||
|
border-color: hsl(12 76% 48% / 0.6);
|
||||||
|
background: hsl(12 76% 48% / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tag--primary {
|
||||||
|
border-color: hsl(45 90% 55%);
|
||||||
|
background: hsl(45 90% 55% / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tag-label {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: hsl(20 8% 50%);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tag--active .song-tag-label {
|
||||||
|
color: hsl(12 76% 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tag--primary .song-tag-label {
|
||||||
|
color: hsl(45 90% 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-tag-label:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-star {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.375rem 0 0.375rem 0.625rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: hsl(20 8% 30%);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-star:hover {
|
||||||
|
color: hsl(45 90% 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-star--active {
|
||||||
|
color: hsl(45 90% 55%);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,12 +20,21 @@
|
|||||||
<div class="i-lucide-grip-vertical h-4 w-4" />
|
<div class="i-lucide-grip-vertical h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span class="chapter-order">{{ String(i + 1).padStart(2, '0') }}</span>
|
<span class="chapter-order">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||||
|
<div class="chapter-info">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="`/admin/book/${chapter.slug}`"
|
:to="`/admin/book/${chapter.slug}`"
|
||||||
class="chapter-title"
|
class="chapter-title"
|
||||||
>
|
>
|
||||||
{{ chapter.title }}
|
{{ chapter.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<div v-if="getChapterSongNames(chapter.slug).length" class="chapter-songs">
|
||||||
|
<span
|
||||||
|
v-for="name in getChapterSongNames(chapter.slug)"
|
||||||
|
:key="name"
|
||||||
|
class="song-badge"
|
||||||
|
>{{ name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
class="delete-btn"
|
class="delete-btn"
|
||||||
@click="removeChapter(chapter.slug)"
|
@click="removeChapter(chapter.slug)"
|
||||||
@@ -64,6 +73,19 @@ definePageMeta({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { data: chapters, refresh } = await useFetch<any[]>('/api/admin/chapters')
|
const { data: chapters, refresh } = await useFetch<any[]>('/api/admin/chapters')
|
||||||
|
const { data: bookConfig } = await useFetch<any>('/api/content/config')
|
||||||
|
|
||||||
|
function getChapterSongNames(chapterSlug: string): string[] {
|
||||||
|
if (!bookConfig.value) return []
|
||||||
|
const links = (bookConfig.value.chapterSongs ?? []).filter(
|
||||||
|
(cs: any) => cs.chapterSlug === chapterSlug,
|
||||||
|
)
|
||||||
|
return links.map((link: any) => {
|
||||||
|
const song = bookConfig.value.songs.find((s: any) => s.id === link.songId)
|
||||||
|
return song?.title ?? link.songId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const saved = ref(false)
|
const saved = ref(false)
|
||||||
const newTitle = ref('')
|
const newTitle = ref('')
|
||||||
@@ -176,8 +198,13 @@ async function removeChapter(slug: string) {
|
|||||||
width: 1.75rem;
|
width: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-title {
|
.chapter-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-title {
|
||||||
|
display: block;
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -187,6 +214,22 @@ async function removeChapter(slug: string) {
|
|||||||
color: hsl(12 76% 68%);
|
color: hsl(12 76% 68%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chapter-songs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: hsl(12 76% 48% / 0.1);
|
||||||
|
color: hsl(12 76% 60%);
|
||||||
|
border: 1px solid hsl(12 76% 48% / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
.delete-btn {
|
.delete-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0.375rem;
|
padding: 0.375rem;
|
||||||
|
|||||||
@@ -119,20 +119,13 @@ export const usePlayerStore = defineStore('player', () => {
|
|||||||
function prevSong(): Song | null {
|
function prevSong(): Song | null {
|
||||||
if (playlist.value.length === 0) return null
|
if (playlist.value.length === 0) return null
|
||||||
|
|
||||||
// If more than 3 seconds in, restart current song
|
|
||||||
if (currentTime.value > 3) {
|
|
||||||
currentTime.value = 0
|
|
||||||
return currentSong.value
|
|
||||||
}
|
|
||||||
|
|
||||||
let prevIdx = currentIndex.value - 1
|
let prevIdx = currentIndex.value - 1
|
||||||
if (prevIdx < 0) {
|
if (prevIdx < 0) {
|
||||||
if (repeatMode.value === 'all') {
|
if (repeatMode.value === 'all') {
|
||||||
prevIdx = playlist.value.length - 1
|
prevIdx = playlist.value.length - 1
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
currentTime.value = 0
|
return null
|
||||||
return currentSong.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user