Files
librodrome/app/pages/admin/book/index.vue
Yvv 9d92c4a5b3
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Fix typos blanches admin lightmode + hero audience
- Remplace color:white → hsl(var(--color-text)) dans tous les composants admin
  (AdminFieldText, AdminFieldTextarea, AdminFormSection, AdminMarkdownEditor,
  AdminMediaBrowser, AdminSidebar, book/index, book/[slug], login, messages, site, songs)
- Conserve color:white uniquement sur fond primary (AdminSaveButton, login-btn)
- Hero home : ajout bloc audience/addressees (clé distincte pour éviter conflit YAML)
- home.yml : réordonne axes (citoyenne en premier — effet triangle)
- TypewriterText : affiche le second bloc avec séparateur fin

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 16:08:03 +01:00

427 lines
11 KiB
Vue

<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="font-display text-2xl font-bold text-white">Livre &amp; 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="savePdfConfig" :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>
<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
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 + outline
const pdfPath = ref(bookConfig.value?.book?.pdfFile ?? '/pdf/une-economie-du-don.pdf')
const savingPdf = ref(false)
const savedPdf = ref(false)
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
onMounted(() => {
$fetch<Array<{ title: string; page: number; level: number }>>('/api/admin/pdf-outline')
.then((data) => { pdfOutline.value = data })
.catch((err) => { console.warn('PDF outline load failed:', err) })
})
async function savePdfConfig() {
if (!bookConfig.value) return
savingPdf.value = true
savedPdf.value = false
try {
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', {
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(var(--color-surface-light));
border-radius: 0.5rem;
background: hsl(var(--color-bg));
}
.save-pdf-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-surface-light));
background: none;
color: hsl(var(--color-text-muted));
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.save-pdf-btn:hover:not(:disabled) {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-primary));
}
.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(var(--color-surface-light));
border-radius: 0.5rem;
transition: all 0.2s;
}
.chapter-item:hover {
border-color: hsl(var(--color-primary) / 0.3);
background: hsl(var(--color-bg));
}
.chapter-item--dragging {
opacity: 0.4;
}
.chapter-item--over {
border-top: 2px solid hsl(var(--color-primary));
}
.drag-handle {
cursor: grab;
padding: 0.25rem;
color: hsl(var(--color-text-muted));
flex-shrink: 0;
}
.drag-handle:active {
cursor: grabbing;
}
.chapter-order {
font-family: var(--font-mono, monospace);
font-size: 0.85rem;
color: hsl(var(--color-primary) / 0.5);
font-weight: 600;
width: 1.75rem;
}
.chapter-info {
flex: 1;
min-width: 0;
}
.chapter-title {
display: block;
color: hsl(var(--color-text));
font-weight: 500;
text-decoration: none;
}
.chapter-title:hover {
color: hsl(var(--color-primary));
}
.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(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
border: 1px solid hsl(var(--color-primary) / 0.2);
}
.page-select {
flex-shrink: 0;
max-width: 14rem;
padding: 0.25rem 0.4rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-surface-light));
background: hsl(var(--color-bg));
color: hsl(var(--color-text-muted));
font-size: 0.7rem;
cursor: pointer;
}
.page-select:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.5);
}
.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(var(--color-surface-light));
background: hsl(var(--color-bg));
color: hsl(var(--color-text));
font-size: 0.8rem;
}
.admin-input:focus {
outline: none;
border-color: hsl(var(--color-primary) / 0.5);
}
.add-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-surface-light));
background: none;
color: hsl(var(--color-text-muted));
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.add-btn:hover:not(:disabled) {
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-primary));
}
.add-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
</style>