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:
@@ -34,6 +34,36 @@
|
||||
</div>
|
||||
</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>
|
||||
<AdminMarkdownEditor v-model="body" :rows="35" />
|
||||
</AdminFormSection>
|
||||
@@ -51,6 +81,7 @@ const route = useRoute()
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
|
||||
const { data: chapter } = await useFetch(() => `/api/admin/chapters/${slug.value}`)
|
||||
const { data: bookConfig } = await useFetch<any>('/api/content/config')
|
||||
|
||||
const title = ref('')
|
||||
const description = ref('')
|
||||
@@ -65,7 +96,6 @@ const wordCount = computed(() => {
|
||||
|
||||
watch(chapter, (val) => {
|
||||
if (val) {
|
||||
// Parse frontmatter fields
|
||||
const fm = val.frontmatter ?? ''
|
||||
title.value = extractFmField(fm, 'title')
|
||||
description.value = extractFmField(fm, 'description')
|
||||
@@ -79,6 +109,52 @@ function extractFmField(fm: string, field: string): string {
|
||||
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 saved = ref(false)
|
||||
|
||||
@@ -86,6 +162,7 @@ async function save() {
|
||||
saving.value = true
|
||||
saved.value = false
|
||||
try {
|
||||
// 1. Sauvegarder le contenu du chapitre
|
||||
const order = chapter.value?.frontmatter?.match(/order:\s*(\d+)/)?.[1] ?? '1'
|
||||
const frontmatter = [
|
||||
`title: "${title.value}"`,
|
||||
@@ -98,6 +175,27 @@ async function save() {
|
||||
method: 'PUT',
|
||||
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
|
||||
setTimeout(() => { saved.value = false }, 2000)
|
||||
}
|
||||
@@ -129,4 +227,70 @@ async function save() {
|
||||
outline: none;
|
||||
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>
|
||||
|
||||
@@ -20,12 +20,21 @@
|
||||
<div class="i-lucide-grip-vertical h-4 w-4" />
|
||||
</div>
|
||||
<span class="chapter-order">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||
<NuxtLink
|
||||
:to="`/admin/book/${chapter.slug}`"
|
||||
class="chapter-title"
|
||||
>
|
||||
{{ chapter.title }}
|
||||
</NuxtLink>
|
||||
<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)"
|
||||
@@ -64,6 +73,19 @@ definePageMeta({
|
||||
})
|
||||
|
||||
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('')
|
||||
@@ -176,8 +198,13 @@ async function removeChapter(slug: string) {
|
||||
width: 1.75rem;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
.chapter-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chapter-title {
|
||||
display: block;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
@@ -187,6 +214,22 @@ async function removeChapter(slug: string) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user