Bouton PDF par chapitre, badges morceaux améliorés, PDF configurable admin, git sync admin→prod
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- Bouton PDF blanc par chapitre avec numéro de page (ChapterHeader)
- Badges morceaux plus visibles (bordure, poids, hover) dans ChapterHeader et SongBadges
- PDF viewer : page cible + panneau signets ouverts par défaut (BookPdfReader)
- Config YAML : pdfFile dans book, chapterPages pour le mapping chapitre→page
- Admin book : section PDF du livre avec chemin éditable et sauvegarde
- Git sync automatique : chaque sauvegarde admin commit+push en prod (ADMIN_GIT_SYNC=true)
- Docker : git installé en prod, volumes pour .git/site/content/public

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 15:32:38 +01:00
parent b02368a15b
commit 9525ed3953
21 changed files with 271 additions and 6 deletions

View File

@@ -22,6 +22,7 @@
<!-- PDF embed -->
<div class="pdf-viewport">
<iframe
:key="pdfUrl"
:src="pdfUrl"
class="pdf-frame"
:title="bpContent?.pdf.iframeTitle"
@@ -33,10 +34,12 @@
</template>
<script setup lang="ts">
const props = defineProps<{ modelValue: boolean }>()
const props = defineProps<{ modelValue: boolean; page?: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
const { data: bpContent } = await usePageContent('book-player')
const bookData = useBookData()
await bookData.init()
const overlayRef = ref<HTMLElement>()
@@ -45,7 +48,13 @@ const isOpen = computed({
set: (v) => emit('update:modelValue', v),
})
const pdfUrl = '/pdf/une-economie-du-don.pdf'
const pdfUrl = computed(() => {
const base = bookData.getPdfUrl()
const fragments: string[] = []
if (props.page) fragments.push(`page=${props.page}`)
fragments.push('pagemode=bookmarks')
return `${base}#${fragments.join('&')}`
})
function close() {
isOpen.value = false

View File

@@ -20,13 +20,24 @@
<button
v-for="song in songs"
:key="song.id"
class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary transition-colors hover:bg-primary/20"
class="song-badge-btn"
@click="playSong(song)"
>
<div class="i-lucide-music h-3 w-3" />
{{ song.title }}
</button>
</div>
<!-- PDF button -->
<div v-if="pdfPage" class="mt-3">
<button class="pdf-btn" @click="showPdf = true">
<div class="i-lucide-book-open h-3.5 w-3.5" />
<span>Lire dans le PDF</span>
<span class="pdf-page-badge">p.{{ pdfPage }}</span>
</button>
</div>
<BookPdfReader v-model="showPdf" :page="pdfPage" />
</header>
</template>
@@ -47,6 +58,9 @@ const { loadAndPlay } = useAudioPlayer()
await bookData.init()
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
const pdfPage = computed(() => bookData.getChapterPage(props.chapterSlug))
const showPdf = ref(false)
function playSong(song: Song) {
loadAndPlay(song)
@@ -59,4 +73,57 @@ function playSong(song: Song) {
padding-bottom: 0.75rem;
border-bottom: 2px solid hsl(var(--color-primary) / 0.4);
}
.song-badge-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: 9999px;
padding: 0.375rem 0.875rem;
font-size: 0.8rem;
font-weight: 600;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary) / 0.85);
border: 1px solid hsl(var(--color-primary) / 0.25);
cursor: pointer;
transition: all 0.2s;
}
.song-badge-btn:hover {
background: hsl(var(--color-primary) / 0.2);
border-color: hsl(var(--color-primary) / 0.4);
box-shadow: 0 2px 8px hsl(var(--color-primary) / 0.15);
transform: translateY(-1px);
}
.pdf-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: hsl(0 0% 96%);
color: hsl(0 0% 15%);
font-size: 0.8rem;
font-weight: 600;
border: 1px solid hsl(0 0% 88%);
cursor: pointer;
transition: all 0.2s;
}
.pdf-btn:hover {
background: white;
box-shadow: 0 2px 12px hsl(0 0% 100% / 0.25);
transform: translateY(-1px);
}
.pdf-page-badge {
font-family: var(--font-mono, monospace);
font-size: 0.7rem;
font-weight: 500;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
background: hsl(0 0% 88%);
color: hsl(0 0% 35%);
}
</style>

View File

@@ -3,7 +3,7 @@
<span
v-for="song in songs"
:key="song.id"
class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary/70"
class="inline-flex items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary/80"
>
<div class="i-lucide-music h-2.5 w-2.5" />
{{ song.title }}

View File

@@ -1,5 +1,5 @@
import type { Song } from '~/types/song'
import type { ChapterSongLink, BookConfig } from '~/types/book'
import type { ChapterSongLink, ChapterPageLink, BookConfig } from '~/types/book'
let _configCache: BookConfig | null = null
@@ -13,9 +13,11 @@ async function loadConfig(): Promise<BookConfig> {
author: parsed.book.author,
description: parsed.book.description,
coverImage: parsed.book.coverImage,
pdfFile: parsed.book.pdfFile,
chapters: [],
songs: parsed.songs as Song[],
chapterSongs: parsed.chapterSongs as ChapterSongLink[],
chapterPages: (parsed.chapterPages ?? []) as ChapterPageLink[],
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
}
@@ -76,6 +78,14 @@ export function useBookData() {
return link?.chapterSlug
}
function getChapterPage(chapterSlug: string): number | undefined {
return config.value?.chapterPages.find(cp => cp.chapterSlug === chapterSlug)?.page
}
function getPdfUrl(): string {
return config.value?.pdfFile || '/pdf/une-economie-du-don.pdf'
}
function getBookMeta() {
if (!config.value) return null
return {
@@ -98,5 +108,7 @@ export function useBookData() {
getChapterForSong,
getPlaylistOrder,
getBookMeta,
getChapterPage,
getPdfUrl,
}
}

View File

@@ -5,6 +5,30 @@
<AdminSaveButton :saving="saving" :saved="saved" @save="saveOrder" />
</div>
<!-- PDF config -->
<div class="pdf-section">
<h2 class="font-display text-sm font-semibold text-white/60 mb-3">PDF du livre</h2>
<div class="flex items-end gap-3">
<div class="flex-1">
<label class="block text-xs text-white/40 mb-1">Chemin du fichier PDF</label>
<input
v-model="pdfPath"
class="admin-input w-full font-mono text-xs"
placeholder="/pdf/une-economie-du-don.pdf"
/>
</div>
<button class="save-pdf-btn" @click="savePdfPath" :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" />
{{ savedPdf ? 'Enregistré' : 'Enregistrer' }}
</button>
</div>
<p class="text-xs text-white/30 mt-2">
Uploadez le PDF via Médias, puis renseignez le chemin ici.
</p>
</div>
<div class="flex flex-col gap-2">
<div
v-for="(chapter, i) in chapters"
@@ -91,6 +115,29 @@ const saved = ref(false)
const newTitle = ref('')
const newSlug = ref('')
// PDF path
const pdfPath = ref(bookConfig.value?.book?.pdfFile ?? '/pdf/une-economie-du-don.pdf')
const savingPdf = ref(false)
const savedPdf = ref(false)
async function savePdfPath() {
if (!bookConfig.value) return
savingPdf.value = true
savedPdf.value = false
try {
bookConfig.value.book.pdfFile = pdfPath.value
await $fetch('/api/admin/content/config', {
method: 'PUT',
body: bookConfig.value,
})
savedPdf.value = true
setTimeout(() => { savedPdf.value = false }, 2000)
}
finally {
savingPdf.value = false
}
}
// Drag & drop state
const dragIdx = ref<number | null>(null)
const dropIdx = ref<number | null>(null)
@@ -156,6 +203,39 @@ async function removeChapter(slug: string) {
</script>
<style scoped>
.pdf-section {
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid hsl(20 8% 14%);
border-radius: 0.5rem;
background: hsl(20 8% 5%);
}
.save-pdf-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid hsl(20 8% 25%);
background: none;
color: hsl(20 8% 55%);
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.save-pdf-btn:hover:not(:disabled) {
border-color: hsl(12 76% 48% / 0.5);
color: hsl(12 76% 68%);
}
.save-pdf-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chapter-item {
display: flex;
align-items: center;

View File

@@ -12,13 +12,20 @@ export interface ChapterSongLink {
primary: boolean
}
export interface ChapterPageLink {
chapterSlug: string
page: number
}
export interface BookConfig {
title: string
author: string
description: string
coverImage?: string
pdfFile?: string
chapters: ChapterMeta[]
songs: import('./song').Song[]
chapterSongs: ChapterSongLink[]
chapterPages: ChapterPageLink[]
defaultPlaylistOrder: string[]
}