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
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:
@@ -22,6 +22,7 @@
|
|||||||
<!-- PDF embed -->
|
<!-- PDF embed -->
|
||||||
<div class="pdf-viewport">
|
<div class="pdf-viewport">
|
||||||
<iframe
|
<iframe
|
||||||
|
:key="pdfUrl"
|
||||||
:src="pdfUrl"
|
:src="pdfUrl"
|
||||||
class="pdf-frame"
|
class="pdf-frame"
|
||||||
:title="bpContent?.pdf.iframeTitle"
|
:title="bpContent?.pdf.iframeTitle"
|
||||||
@@ -33,10 +34,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const props = defineProps<{ modelValue: boolean }>()
|
const props = defineProps<{ modelValue: boolean; page?: number }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
const { data: bpContent } = await usePageContent('book-player')
|
const { data: bpContent } = await usePageContent('book-player')
|
||||||
|
const bookData = useBookData()
|
||||||
|
await bookData.init()
|
||||||
|
|
||||||
const overlayRef = ref<HTMLElement>()
|
const overlayRef = ref<HTMLElement>()
|
||||||
|
|
||||||
@@ -45,7 +48,13 @@ const isOpen = computed({
|
|||||||
set: (v) => emit('update:modelValue', v),
|
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() {
|
function close() {
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
|
|||||||
@@ -20,13 +20,24 @@
|
|||||||
<button
|
<button
|
||||||
v-for="song in songs"
|
v-for="song in songs"
|
||||||
:key="song.id"
|
: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)"
|
@click="playSong(song)"
|
||||||
>
|
>
|
||||||
<div class="i-lucide-music h-3 w-3" />
|
<div class="i-lucide-music h-3 w-3" />
|
||||||
{{ song.title }}
|
{{ song.title }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,6 +58,9 @@ const { loadAndPlay } = useAudioPlayer()
|
|||||||
await bookData.init()
|
await bookData.init()
|
||||||
|
|
||||||
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
const songs = computed(() => bookData.getChapterSongs(props.chapterSlug))
|
||||||
|
const pdfPage = computed(() => bookData.getChapterPage(props.chapterSlug))
|
||||||
|
|
||||||
|
const showPdf = ref(false)
|
||||||
|
|
||||||
function playSong(song: Song) {
|
function playSong(song: Song) {
|
||||||
loadAndPlay(song)
|
loadAndPlay(song)
|
||||||
@@ -59,4 +73,57 @@ function playSong(song: Song) {
|
|||||||
padding-bottom: 0.75rem;
|
padding-bottom: 0.75rem;
|
||||||
border-bottom: 2px solid hsl(var(--color-primary) / 0.4);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<span
|
<span
|
||||||
v-for="song in songs"
|
v-for="song in songs"
|
||||||
:key="song.id"
|
: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" />
|
<div class="i-lucide-music h-2.5 w-2.5" />
|
||||||
{{ song.title }}
|
{{ song.title }}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Song } from '~/types/song'
|
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
|
let _configCache: BookConfig | null = null
|
||||||
|
|
||||||
@@ -13,9 +13,11 @@ async function loadConfig(): Promise<BookConfig> {
|
|||||||
author: parsed.book.author,
|
author: parsed.book.author,
|
||||||
description: parsed.book.description,
|
description: parsed.book.description,
|
||||||
coverImage: parsed.book.coverImage,
|
coverImage: parsed.book.coverImage,
|
||||||
|
pdfFile: parsed.book.pdfFile,
|
||||||
chapters: [],
|
chapters: [],
|
||||||
songs: parsed.songs as Song[],
|
songs: parsed.songs as Song[],
|
||||||
chapterSongs: parsed.chapterSongs as ChapterSongLink[],
|
chapterSongs: parsed.chapterSongs as ChapterSongLink[],
|
||||||
|
chapterPages: (parsed.chapterPages ?? []) as ChapterPageLink[],
|
||||||
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
|
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +78,14 @@ export function useBookData() {
|
|||||||
return link?.chapterSlug
|
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() {
|
function getBookMeta() {
|
||||||
if (!config.value) return null
|
if (!config.value) return null
|
||||||
return {
|
return {
|
||||||
@@ -98,5 +108,7 @@ export function useBookData() {
|
|||||||
getChapterForSong,
|
getChapterForSong,
|
||||||
getPlaylistOrder,
|
getPlaylistOrder,
|
||||||
getBookMeta,
|
getBookMeta,
|
||||||
|
getChapterPage,
|
||||||
|
getPdfUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,30 @@
|
|||||||
<AdminSaveButton :saving="saving" :saved="saved" @save="saveOrder" />
|
<AdminSaveButton :saving="saving" :saved="saved" @save="saveOrder" />
|
||||||
</div>
|
</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 class="flex flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="(chapter, i) in chapters"
|
v-for="(chapter, i) in chapters"
|
||||||
@@ -91,6 +115,29 @@ const saved = ref(false)
|
|||||||
const newTitle = ref('')
|
const newTitle = ref('')
|
||||||
const newSlug = 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
|
// Drag & drop state
|
||||||
const dragIdx = ref<number | null>(null)
|
const dragIdx = ref<number | null>(null)
|
||||||
const dropIdx = ref<number | null>(null)
|
const dropIdx = ref<number | null>(null)
|
||||||
@@ -156,6 +203,39 @@ async function removeChapter(slug: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.chapter-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -12,13 +12,20 @@ export interface ChapterSongLink {
|
|||||||
primary: boolean
|
primary: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChapterPageLink {
|
||||||
|
chapterSlug: string
|
||||||
|
page: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface BookConfig {
|
export interface BookConfig {
|
||||||
title: string
|
title: string
|
||||||
author: string
|
author: string
|
||||||
description: string
|
description: string
|
||||||
coverImage?: string
|
coverImage?: string
|
||||||
|
pdfFile?: string
|
||||||
chapters: ChapterMeta[]
|
chapters: ChapterMeta[]
|
||||||
songs: import('./song').Song[]
|
songs: import('./song').Song[]
|
||||||
chapterSongs: ChapterSongLink[]
|
chapterSongs: ChapterSongLink[]
|
||||||
|
chapterPages: ChapterPageLink[]
|
||||||
defaultPlaylistOrder: string[]
|
defaultPlaylistOrder: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ FROM base AS production
|
|||||||
ENV PORT=${PORT}
|
ENV PORT=${PORT}
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
RUN apt-get update && apt-get -fy install curl && rm -rf /var/cache/apt/*
|
RUN apt-get update && apt-get -fy install curl git && rm -rf /var/cache/apt/*
|
||||||
|
|
||||||
COPY --from=build /app/.output /app/.output
|
COPY --from=build /app/.output /app/.output
|
||||||
COPY --from=build /app/site /app/site
|
COPY --from=build /app/site /app/site
|
||||||
|
|||||||
@@ -10,11 +10,18 @@ services:
|
|||||||
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://librodrome.org}
|
NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-https://librodrome.org}
|
||||||
NUXT_ADMIN_PASSWORD: ${NUXT_ADMIN_PASSWORD}
|
NUXT_ADMIN_PASSWORD: ${NUXT_ADMIN_PASSWORD}
|
||||||
NUXT_ADMIN_SECRET: ${NUXT_ADMIN_SECRET}
|
NUXT_ADMIN_SECRET: ${NUXT_ADMIN_SECRET}
|
||||||
|
ADMIN_GIT_SYNC: ${ADMIN_GIT_SYNC:-false}
|
||||||
ports:
|
ports:
|
||||||
- 3000
|
- 3000
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/src/data
|
- ./data:/src/data
|
||||||
- ./public:/src/public
|
- ./public:/src/public
|
||||||
|
# Git sync admin → nécessite un clone du repo sur le serveur
|
||||||
|
# avec accès push (clé SSH ou token) + ADMIN_GIT_SYNC=true dans .env
|
||||||
|
- ../.git:/app/.git
|
||||||
|
- ../site:/app/site
|
||||||
|
- ../content:/app/content
|
||||||
|
- ../public:/app/public
|
||||||
restart: always
|
restart: always
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const filePath = join(process.cwd(), 'content', 'book', `${slug}.md`)
|
const filePath = join(process.cwd(), 'content', 'book', `${slug}.md`)
|
||||||
|
|
||||||
await unlink(filePath)
|
await unlink(filePath)
|
||||||
|
gitSyncContent(`Suppression chapitre ${slug}`, [`content/book/${slug}.md`])
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const content = `---\n${body.frontmatter.trim()}\n---\n${body.body}`
|
const content = `---\n${body.frontmatter.trim()}\n---\n${body.body}`
|
||||||
|
|
||||||
await writeFile(filePath, content, 'utf-8')
|
await writeFile(filePath, content, 'utf-8')
|
||||||
|
gitSyncContent(`Mise à jour chapitre ${slug}`, [`content/book/${slug}.md`])
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ readingTime: "5 min"
|
|||||||
`
|
`
|
||||||
|
|
||||||
await writeFile(filePath, content, 'utf-8')
|
await writeFile(filePath, content, 'utf-8')
|
||||||
|
gitSyncContent(`Nouveau chapitre ${body.slug}`, [`content/book/${body.slug}.md`])
|
||||||
|
|
||||||
return { ok: true, slug: body.slug }
|
return { ok: true, slug: body.slug }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,5 +25,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
gitSyncContent('Réorganisation chapitres', ['content/book/'])
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
await writeYaml('bookplayer.config.yml', body)
|
await writeYaml('bookplayer.config.yml', body)
|
||||||
|
gitSyncContent('Mise à jour config livre/player', ['site/bookplayer.config.yml'])
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
await writeYaml(`pages/${name}.yml`, body)
|
await writeYaml(`pages/${name}.yml`, body)
|
||||||
|
gitSyncContent(`Mise à jour page ${name}`, [`site/pages/${name}.yml`])
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
await writeYaml('site.yml', body)
|
await writeYaml('site.yml', body)
|
||||||
|
gitSyncContent('Mise à jour config site', ['site/site.yml'])
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await unlink(filePath)
|
await unlink(filePath)
|
||||||
|
gitSyncContent(`Suppression média ${path}`, [`public/${path}`])
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
|
|||||||
@@ -33,5 +33,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
uploaded.push(`/${subdir}/${safeName}`)
|
uploaded.push(`/${subdir}/${safeName}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uploaded.length > 0) {
|
||||||
|
gitSyncContent(`Upload média : ${uploaded.join(', ')}`, uploaded.map(f => `public${f}`))
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, files: uploaded }
|
return { ok: true, files: uploaded }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
data.messages.splice(index, 1)
|
data.messages.splice(index, 1)
|
||||||
await writeYaml('messages.yml', data)
|
await writeYaml('messages.yml', data)
|
||||||
|
gitSyncContent(`Suppression message #${id}`, ['site/messages.yml'])
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (body.author !== undefined) message.author = body.author
|
if (body.author !== undefined) message.author = body.author
|
||||||
|
|
||||||
await writeYaml('messages.yml', data)
|
await writeYaml('messages.yml', data)
|
||||||
|
gitSyncContent(`Mise à jour message #${id}`, ['site/messages.yml'])
|
||||||
|
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
|
|||||||
44
server/utils/gitSync.ts
Normal file
44
server/utils/gitSync.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
import { promisify } from 'node:util'
|
||||||
|
|
||||||
|
const exec = promisify(execFile)
|
||||||
|
|
||||||
|
const enabled = process.env.ADMIN_GIT_SYNC === 'true'
|
||||||
|
const cwd = process.cwd()
|
||||||
|
|
||||||
|
async function git(...args: string[]): Promise<string> {
|
||||||
|
const { stdout } = await exec('git', args, { cwd })
|
||||||
|
return stdout.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit et push les modifications admin vers le dépôt git.
|
||||||
|
* Activé uniquement si ADMIN_GIT_SYNC=true (prod).
|
||||||
|
* Ne bloque jamais la réponse HTTP — exécution fire-and-forget.
|
||||||
|
*/
|
||||||
|
export function gitSyncContent(description: string, paths: string[] = ['.']) {
|
||||||
|
if (!enabled) return
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
for (const p of paths) {
|
||||||
|
await git('add', p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier s'il y a vraiment des changements stagés
|
||||||
|
const status = await git('diff', '--cached', '--name-only')
|
||||||
|
if (!status) return
|
||||||
|
|
||||||
|
await git('commit', '-m', `[admin] ${description}`)
|
||||||
|
await git('push')
|
||||||
|
|
||||||
|
console.log(`[gitSync] pushed: ${description}`)
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
console.error(`[gitSync] error:`, err.message ?? err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget : ne pas bloquer la réponse HTTP
|
||||||
|
run()
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
book:
|
book:
|
||||||
title: Une économie du don — enfin concevable
|
title: Une économie du don — enfin concevable
|
||||||
author: Yvv
|
author: Yvv
|
||||||
|
pdfFile: /pdf/une-economie-du-don.pdf
|
||||||
description: Un livre et 9 chansons pour explorer ensemble les fondements d'une économie fondée sur le don.
|
description: Un livre et 9 chansons pour explorer ensemble les fondements d'une économie fondée sur le don.
|
||||||
coverImage: /images/book-cover.jpg
|
coverImage: /images/book-cover.jpg
|
||||||
license: CC-BY-NC
|
license: CC-BY-NC
|
||||||
@@ -560,6 +561,29 @@ chapterSongs:
|
|||||||
- chapterSlug: 11-annexes
|
- chapterSlug: 11-annexes
|
||||||
songId: coder-la-liberte
|
songId: coder-la-liberte
|
||||||
primary: true
|
primary: true
|
||||||
|
chapterPages:
|
||||||
|
- chapterSlug: 01-introduction
|
||||||
|
page: 5
|
||||||
|
- chapterSlug: 02-don
|
||||||
|
page: 15
|
||||||
|
- chapterSlug: 03-mesure
|
||||||
|
page: 30
|
||||||
|
- chapterSlug: 04-monnaie
|
||||||
|
page: 45
|
||||||
|
- chapterSlug: 05-trm
|
||||||
|
page: 60
|
||||||
|
- chapterSlug: 06-economie
|
||||||
|
page: 75
|
||||||
|
- chapterSlug: 07-echange
|
||||||
|
page: 90
|
||||||
|
- chapterSlug: 08-institution
|
||||||
|
page: 105
|
||||||
|
- chapterSlug: 09-greffes
|
||||||
|
page: 120
|
||||||
|
- chapterSlug: 10-maintenant
|
||||||
|
page: 135
|
||||||
|
- chapterSlug: 11-annexes
|
||||||
|
page: 150
|
||||||
defaultPlaylistOrder:
|
defaultPlaylistOrder:
|
||||||
- ce-livre-est-une-facon
|
- ce-livre-est-une-facon
|
||||||
- de-quel-don-nous-parlons
|
- de-quel-don-nous-parlons
|
||||||
|
|||||||
Reference in New Issue
Block a user