Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- Viewer PDF.js mode livre : double page côte à côte, navigation prev/next visuelle et clavier - Panneau signets (outline) avec tout déplier/replier, highlight du spread courant - Page 1 = couverture seule, puis paires 2-3, 4-5, etc. - Navigation clavier : flèches, espace, Home/End - Redimensionnement auto des canvas au resize - Fix hydratation SSR : bookData.init() sans await dans ChapterHeader et SongBadges - BookPdfReader : iframe vers /pdfjs/viewer.html au lieu du viewer natif - Script postinstall pour copier pdf.min.mjs depuis node_modules Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
367 lines
8.9 KiB
Vue
367 lines
8.9 KiB
Vue
<template>
|
|
<div>
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="font-display text-2xl font-bold text-white">Livre & chapitres</h1>
|
|
<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"
|
|
:key="chapter.slug"
|
|
class="chapter-item"
|
|
draggable="true"
|
|
@dragstart="onDragStart(i, $event)"
|
|
@dragover.prevent="onDragOver(i)"
|
|
@dragend="onDragEnd"
|
|
:class="{ 'chapter-item--dragging': dragIdx === i, 'chapter-item--over': dropIdx === i && dropIdx !== dragIdx }"
|
|
>
|
|
<div class="drag-handle" aria-label="Réordonner">
|
|
<div class="i-lucide-grip-vertical h-4 w-4" />
|
|
</div>
|
|
<span class="chapter-order">{{ String(i + 1).padStart(2, '0') }}</span>
|
|
<div class="chapter-info">
|
|
<NuxtLink
|
|
:to="`/admin/book/${chapter.slug}`"
|
|
class="chapter-title"
|
|
>
|
|
{{ chapter.title }}
|
|
</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
|
|
class="delete-btn"
|
|
@click="removeChapter(chapter.slug)"
|
|
aria-label="Supprimer"
|
|
>
|
|
<div class="i-lucide-trash-2 h-4 w-4" />
|
|
</button>
|
|
<NuxtLink :to="`/admin/book/${chapter.slug}`">
|
|
<div class="i-lucide-chevron-right h-4 w-4 text-white/20" />
|
|
</NuxtLink>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add chapter -->
|
|
<div class="mt-6 flex items-end gap-3">
|
|
<div class="flex-1">
|
|
<label class="block text-xs text-white/40 mb-1">Titre</label>
|
|
<input v-model="newTitle" class="admin-input w-full" placeholder="Nouveau chapitre" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-white/40 mb-1">Slug</label>
|
|
<input v-model="newSlug" class="admin-input w-full font-mono text-xs" placeholder="12-slug" />
|
|
</div>
|
|
<button class="add-btn" @click="addChapter" :disabled="!newTitle || !newSlug">
|
|
<div class="i-lucide-plus h-4 w-4" />
|
|
Ajouter
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
definePageMeta({
|
|
layout: 'admin',
|
|
middleware: 'admin',
|
|
})
|
|
|
|
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 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)
|
|
|
|
function onDragStart(i: number, e: DragEvent) {
|
|
dragIdx.value = i
|
|
if (e.dataTransfer) {
|
|
e.dataTransfer.effectAllowed = 'move'
|
|
}
|
|
}
|
|
|
|
function onDragOver(i: number) {
|
|
dropIdx.value = i
|
|
}
|
|
|
|
function onDragEnd() {
|
|
if (dragIdx.value !== null && dropIdx.value !== null && dragIdx.value !== dropIdx.value && chapters.value) {
|
|
const [moved] = chapters.value.splice(dragIdx.value, 1)
|
|
chapters.value.splice(dropIdx.value, 0, moved)
|
|
}
|
|
dragIdx.value = null
|
|
dropIdx.value = null
|
|
}
|
|
|
|
async function saveOrder() {
|
|
if (!chapters.value) return
|
|
saving.value = true
|
|
saved.value = false
|
|
try {
|
|
const orderedChapters = chapters.value.map((ch: any, i: number) => ({
|
|
slug: ch.slug,
|
|
order: i + 1,
|
|
}))
|
|
await $fetch('/api/admin/chapters', {
|
|
method: 'PUT',
|
|
body: { chapters: orderedChapters },
|
|
})
|
|
saved.value = true
|
|
setTimeout(() => { saved.value = false }, 2000)
|
|
}
|
|
finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function addChapter() {
|
|
if (!newTitle.value || !newSlug.value) return
|
|
const order = (chapters.value?.length ?? 0) + 1
|
|
await $fetch('/api/admin/chapters', {
|
|
method: 'POST',
|
|
body: { slug: newSlug.value, title: newTitle.value, order },
|
|
})
|
|
newTitle.value = ''
|
|
newSlug.value = ''
|
|
await refresh()
|
|
}
|
|
|
|
async function removeChapter(slug: string) {
|
|
if (!confirm(`Supprimer le chapitre "${slug}" ?`)) return
|
|
await $fetch(`/api/admin/chapters/${slug}`, { method: 'DELETE' })
|
|
await refresh()
|
|
}
|
|
</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;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
border: 1px solid hsl(20 8% 14%);
|
|
border-radius: 0.5rem;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.chapter-item:hover {
|
|
border-color: hsl(12 76% 48% / 0.3);
|
|
background: hsl(20 8% 6%);
|
|
}
|
|
|
|
.chapter-item--dragging {
|
|
opacity: 0.4;
|
|
}
|
|
|
|
.chapter-item--over {
|
|
border-top: 2px solid hsl(12 76% 48%);
|
|
}
|
|
|
|
.drag-handle {
|
|
cursor: grab;
|
|
padding: 0.25rem;
|
|
color: hsl(20 8% 35%);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.drag-handle:active {
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.chapter-order {
|
|
font-family: var(--font-mono, monospace);
|
|
font-size: 0.85rem;
|
|
color: hsl(12 76% 48% / 0.5);
|
|
font-weight: 600;
|
|
width: 1.75rem;
|
|
}
|
|
|
|
.chapter-info {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.chapter-title {
|
|
display: block;
|
|
color: white;
|
|
font-weight: 500;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.chapter-title:hover {
|
|
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 {
|
|
flex-shrink: 0;
|
|
padding: 0.375rem;
|
|
border-radius: 0.375rem;
|
|
color: hsl(0 60% 50%);
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
|
|
.delete-btn:hover {
|
|
background: hsl(0 60% 50% / 0.1);
|
|
}
|
|
|
|
.admin-input {
|
|
padding: 0.375rem 0.5rem;
|
|
border-radius: 0.375rem;
|
|
border: 1px solid hsl(20 8% 18%);
|
|
background: hsl(20 8% 6%);
|
|
color: white;
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.admin-input:focus {
|
|
outline: none;
|
|
border-color: hsl(12 76% 48% / 0.5);
|
|
}
|
|
|
|
.add-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.85rem;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.add-btn:hover:not(:disabled) {
|
|
border-color: hsl(12 76% 48% / 0.5);
|
|
color: hsl(12 76% 68%);
|
|
}
|
|
|
|
.add-btn:disabled {
|
|
opacity: 0.3;
|
|
cursor: not-allowed;
|
|
}
|
|
</style>
|