Textes projet éditables suivant Grav + écriture atomique du store
- Champ Présentation prérempli avec le texte Grav, suivi modifié/synchro (textOverride undefined = suit Grav, sinon figé ; bouton Réinitialiser) - Fix génération PDF : savePortfolio écrit en atomique (temp+rename) pour éviter la corruption de lecture par l'autosave concurrent du rendu print - Toast d'échec de génération explicite (message serveur) - Refonte architecture visuelle, fond unifié white/tint/full, dispositions couverture bandeau/colonne, instantané public complet, index 3 lignes
This commit is contained in:
+68
-39
@@ -67,30 +67,51 @@ async function downloadPdf(p: PortfolioSummary) {
|
||||
|
||||
async function copyPublicLink(id: string) {
|
||||
const url = `${window.location.origin}/view/${id}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
if (await copyToClipboard(url)) {
|
||||
toast.add({ title: 'Lien public copié', color: 'success' })
|
||||
} catch {
|
||||
} else {
|
||||
toast.add({ title: 'Copie impossible', description: url, color: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
const sharing = ref<string | null>(null)
|
||||
|
||||
/** Active le lien public (fige le contenu) puis copie l'URL. */
|
||||
async function enableShare(id: string) {
|
||||
sharing.value = id
|
||||
try {
|
||||
await $fetch(`/api/portfolios/${id}/share`, { method: 'POST', body: {} })
|
||||
await refresh()
|
||||
await copyPublicLink(id)
|
||||
} catch {
|
||||
toast.add({ title: 'Échec de la génération du lien', color: 'error' })
|
||||
} finally {
|
||||
sharing.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UContainer class="py-8">
|
||||
<div class="mb-6 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--fl-ink)] dark:text-white">Portfolios</h1>
|
||||
<div class="flex h-screen">
|
||||
<!-- Colonne gauche : header logo (fond + bordure), colonne transparente -->
|
||||
<aside class="w-60 shrink-0">
|
||||
<BrandBar />
|
||||
</aside>
|
||||
|
||||
<!-- Grille des portfolios, pleine hauteur -->
|
||||
<main class="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex h-14 items-center justify-end gap-2 border-b border-neutral-200 bg-white px-6 dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<RefreshButton />
|
||||
<UButton icon="i-lucide-plus" size="sm" label="Nouveau portfolio" @click="showCreate = true" />
|
||||
</div>
|
||||
<UButton icon="i-lucide-plus" label="Nouveau portfolio" @click="showCreate = true" />
|
||||
</div>
|
||||
|
||||
<div v-if="!portfolios.length" class="rounded-lg border border-dashed border-neutral-300 p-16 text-center text-neutral-500 dark:border-neutral-700">
|
||||
Aucun portfolio pour l'instant — créez-en un pour composer votre premier PDF.
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-6">
|
||||
<div v-if="!portfolios.length" class="rounded-lg border border-dashed border-neutral-300 p-16 text-center text-neutral-500 dark:border-neutral-700">
|
||||
Aucun portfolio pour l'instant — créez-en un pour composer votre premier PDF.
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<UCard v-for="p in portfolios" :key="p.id">
|
||||
<div v-else class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<UCard v-for="p in portfolios" :key="p.id">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<NuxtLink :to="`/portfolios/${p.id}`" class="font-semibold text-[var(--fl-ink)] hover:underline dark:text-white">
|
||||
@@ -105,31 +126,39 @@ async function copyPublicLink(id: string) {
|
||||
<UBadge v-if="p.hasPdf" color="success" variant="subtle" size="sm">PDF</UBadge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<UButton :to="`/portfolios/${p.id}`" size="sm" variant="soft" icon="i-lucide-pencil" label="Ouvrir" />
|
||||
<UButton size="sm" variant="ghost" color="neutral" icon="i-lucide-copy" label="Dupliquer" @click="duplicate(p.id)" />
|
||||
<UButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:icon="p.pdfCurrent ? 'i-lucide-download' : 'i-lucide-file-down'"
|
||||
:label="p.pdfCurrent ? 'Télécharger' : (p.hasPdf ? 'Régénérer' : 'Générer')"
|
||||
:loading="downloading === p.id"
|
||||
@click="downloadPdf(p)"
|
||||
/>
|
||||
<UButton
|
||||
v-if="p.publicShare"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-link"
|
||||
aria-label="Copier le lien public"
|
||||
@click="copyPublicLink(p.id)"
|
||||
/>
|
||||
<UButton size="sm" variant="ghost" color="error" icon="i-lucide-trash-2" class="ml-auto" @click="remove(p.id, p.name)" />
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<UButton :to="`/portfolios/${p.id}`" class="flex-1 justify-center" size="sm" variant="soft" icon="i-lucide-pencil" label="Ouvrir" />
|
||||
<UButton class="flex-1 justify-center" size="sm" variant="ghost" color="neutral" icon="i-lucide-copy" label="Dupliquer" @click="duplicate(p.id)" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<UButton
|
||||
class="flex-1 justify-center"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:icon="p.pdfCurrent ? 'i-lucide-download' : 'i-lucide-file-down'"
|
||||
:label="p.pdfCurrent ? 'Télécharger' : (p.hasPdf ? 'Régénérer' : 'Générer')"
|
||||
:loading="downloading === p.id"
|
||||
@click="downloadPdf(p)"
|
||||
/>
|
||||
<UButton
|
||||
class="flex-1 justify-center"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
:icon="p.publicShare ? 'i-lucide-link' : 'i-lucide-link-2'"
|
||||
:label="p.publicShare ? 'Copier le lien' : 'Générer le lien'"
|
||||
:loading="sharing === p.id"
|
||||
@click="p.publicShare ? copyPublicLink(p.id) : enableShare(p.id)"
|
||||
/>
|
||||
</div>
|
||||
<UButton class="w-full justify-center" size="sm" variant="ghost" color="error" icon="i-lucide-trash-2" label="Supprimer" @click="remove(p.id, p.name)" />
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<UModal v-model:open="showCreate" title="Nouveau portfolio">
|
||||
<template #body>
|
||||
@@ -142,5 +171,5 @@ async function copyPublicLink(id: string) {
|
||||
</form>
|
||||
</template>
|
||||
</UModal>
|
||||
</UContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+204
-53
@@ -27,6 +27,17 @@ const selectedPageId = ref<string | undefined>(portfolio.value.pages[0]?.id)
|
||||
const selectedIndex = computed(() => portfolio.value.pages.findIndex(p => p.id === selectedPageId.value))
|
||||
const selectedPage = computed<Page | undefined>(() => portfolio.value.pages[selectedIndex.value])
|
||||
|
||||
// La liste des pages (colonne de gauche) suit la page active : quand la
|
||||
// sélection sort de la vue (beaucoup de pages), on la ramène dans le conteneur.
|
||||
watch(selectedPageId, (id) => {
|
||||
if (!id) return
|
||||
nextTick(() => {
|
||||
document
|
||||
.querySelector(`[data-page-nav-id="${CSS.escape(id)}"]`)
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Sauvegarde automatique (debounce) ----
|
||||
const saveState = ref<'saved' | 'saving' | 'dirty' | 'error'>('saved')
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -51,11 +62,27 @@ async function save() {
|
||||
}
|
||||
}
|
||||
|
||||
// Réassignation programmatique de `portfolio` (publier/couper/dégeler) : ne
|
||||
// compte pas comme une édition (ni autosave, ni « modifié depuis publication »).
|
||||
let programmatic = false
|
||||
|
||||
// Le lien public a-t-il pris du retard sur les éditions ? (édité après le gel)
|
||||
const sharedStale = ref(
|
||||
Boolean(portfolio.value.publicShare)
|
||||
&& !!portfolio.value.frozenContent
|
||||
&& portfolio.value.updatedAt > (portfolio.value.frozenContent.generatedAt ?? ''),
|
||||
)
|
||||
|
||||
watch(
|
||||
portfolio,
|
||||
() => {
|
||||
if (programmatic) {
|
||||
programmatic = false
|
||||
return
|
||||
}
|
||||
saveState.value = 'dirty'
|
||||
pdfStale.value = true // toute modif rend le dernier PDF périmé
|
||||
sharedStale.value = true // et décale le lien public par rapport à l'instantané
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(save, 800)
|
||||
},
|
||||
@@ -233,6 +260,16 @@ function setInk(value: (typeof inkItems)[number]['value']) {
|
||||
if (selectedPage.value?.kind === 'project') selectedPage.value.ink = value
|
||||
}
|
||||
|
||||
// Disposition de la page couverture de projet
|
||||
const coverLayoutItems = [
|
||||
{ value: 'band', label: 'Bandeau' },
|
||||
{ value: 'split', label: 'Colonne' },
|
||||
] as const
|
||||
|
||||
function setCoverLayout(value: (typeof coverLayoutItems)[number]['value']) {
|
||||
if (selectedPage.value?.kind === 'project') selectedPage.value.layout = value === 'band' ? undefined : value
|
||||
}
|
||||
|
||||
// ---- Calage de l'image de couverture (pages projet) ----
|
||||
const coverFitItems = [
|
||||
{ value: 'width', label: 'Largeur' },
|
||||
@@ -272,6 +309,51 @@ const bandHeight = computed({
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// Taille du texte de labeur des pages libres (%). Undefined = 100.
|
||||
const freeTextScale = computed({
|
||||
get: () => (selectedPage.value?.kind === 'free' ? selectedPage.value.textScale ?? 100 : 100),
|
||||
set: (v: number) => {
|
||||
if (selectedPage.value?.kind === 'free') selectedPage.value.textScale = v === 100 ? undefined : v
|
||||
},
|
||||
})
|
||||
|
||||
// Année par défaut (placeholder) déduite de la date Grav du projet sélectionné.
|
||||
const projectYearPlaceholder = computed(() => {
|
||||
if (selectedPage.value?.kind !== 'project') return 'Année'
|
||||
const iso = projects.value[selectedPage.value.projectId]?.date
|
||||
const y = iso ? new Date(iso).getFullYear() : NaN
|
||||
return Number.isNaN(y) ? 'Année' : String(y)
|
||||
})
|
||||
|
||||
// Texte de présentation du projet : le champ est prérempli avec le texte Grav
|
||||
// pour édition rapide. `textOverride` reste `undefined` tant que le texte n'a pas
|
||||
// été modifié → la page suit alors automatiquement Grav (donc se met à jour lors
|
||||
// d'un rafraîchissement manuel). Dès qu'il diffère de Grav, il est « figé » et
|
||||
// n'est plus resynchronisé ; le bouton « Réinitialiser » le rattache à Grav.
|
||||
const presentationText = computed({
|
||||
get: () => {
|
||||
if (selectedPage.value?.kind !== 'project') return ''
|
||||
const grav = projects.value[selectedPage.value.projectId]?.bodyMarkdown ?? ''
|
||||
return selectedPage.value.textOverride ?? grav
|
||||
},
|
||||
set: (v: string) => {
|
||||
if (selectedPage.value?.kind !== 'project') return
|
||||
const grav = projects.value[selectedPage.value.projectId]?.bodyMarkdown ?? ''
|
||||
// Ré-édité à l'identique de Grav → on relâche l'override (re-suit Grav).
|
||||
selectedPage.value.textOverride = v === grav ? undefined : v
|
||||
},
|
||||
})
|
||||
|
||||
// Le texte de présentation a-t-il été modifié (ne suit plus Grav) ?
|
||||
const presentationEdited = computed(() =>
|
||||
selectedPage.value?.kind === 'project' && selectedPage.value.textOverride !== undefined,
|
||||
)
|
||||
|
||||
function resetPresentation() {
|
||||
if (selectedPage.value?.kind === 'project') selectedPage.value.textOverride = undefined
|
||||
}
|
||||
|
||||
// ---- Image de couverture / médias de page libre ----
|
||||
const showMediaPicker = ref(false)
|
||||
const mediaPickerTarget = ref<'cover' | 'free'>('cover')
|
||||
@@ -301,6 +383,7 @@ const showGenerate = ref(false)
|
||||
const generating = ref(false)
|
||||
const pdfFileName = ref('')
|
||||
const frozenAt = ref(portfolio.value.frozenContent?.generatedAt)
|
||||
const lastGeneratedAt = ref(portfolio.value.lastExport?.at)
|
||||
const lastResult = ref<{ fileName: string; downloadUrl: string; sizeBytes: number; pageCount: number } | null>(null)
|
||||
|
||||
function openGenerate() {
|
||||
@@ -319,11 +402,15 @@ async function generate() {
|
||||
timeout: 300_000,
|
||||
})
|
||||
frozenAt.value = frozenAt.value ?? new Date().toISOString()
|
||||
lastGeneratedAt.value = new Date().toISOString()
|
||||
exportFileName.value = lastResult.value!.fileName
|
||||
pdfStale.value = false // le PDF reflète désormais l'état courant
|
||||
toast.add({ title: 'PDF généré', description: lastResult.value!.fileName, color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: 'Échec de la génération', color: 'error' })
|
||||
} catch (err: any) {
|
||||
// Détail de l'erreur pour diagnostic (message serveur, timeout, réseau…).
|
||||
const detail = err?.data?.statusMessage || err?.statusMessage || err?.message || String(err)
|
||||
toast.add({ title: 'Échec de la génération', description: detail, color: 'error' })
|
||||
console.error('[generate] échec:', err)
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
@@ -339,11 +426,9 @@ function formatBytes(n: number): string {
|
||||
return n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} Mo` : `${Math.round(n / 1024)} Ko`
|
||||
}
|
||||
|
||||
const frozenDateLabel = computed(() =>
|
||||
frozenAt.value
|
||||
? new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(frozenAt.value))
|
||||
: '',
|
||||
)
|
||||
const dateTimeFmt = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
const frozenDateLabel = computed(() => (frozenAt.value ? dateTimeFmt.format(new Date(frozenAt.value)) : ''))
|
||||
const lastGeneratedLabel = computed(() => (lastGeneratedAt.value ? dateTimeFmt.format(new Date(lastGeneratedAt.value)) : ''))
|
||||
|
||||
// ---- Partage d'un lien public ----
|
||||
const showShare = ref(false)
|
||||
@@ -365,9 +450,11 @@ async function enableShare() {
|
||||
// La réponse est le portfolio complet resynchronisé (frozenContent frais) :
|
||||
// on remplace l'état local pour éviter tout écrasement par l'auto-save.
|
||||
const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: {} })
|
||||
programmatic = true
|
||||
portfolio.value = updated
|
||||
publicShare.value = true
|
||||
frozenAt.value = updated.frozenContent?.generatedAt
|
||||
sharedStale.value = false // l'instantané reflète l'état courant
|
||||
toast.add({ title: 'Lien public activé', color: 'success' })
|
||||
} catch {
|
||||
toast.add({ title: 'Échec de l’activation du lien', color: 'error' })
|
||||
@@ -381,6 +468,7 @@ async function disableShare() {
|
||||
sharing.value = true
|
||||
try {
|
||||
const updated = await $fetch<Portfolio>(`/api/portfolios/${portfolio.value.id}/share`, { method: 'POST', body: { enabled: false } })
|
||||
programmatic = true
|
||||
portfolio.value = updated
|
||||
publicShare.value = false
|
||||
toast.add({ title: 'Lien public coupé', color: 'success' })
|
||||
@@ -392,19 +480,19 @@ async function disableShare() {
|
||||
}
|
||||
|
||||
async function copyShareUrl() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl.value)
|
||||
if (await copyToClipboard(shareUrl.value)) {
|
||||
toast.add({ title: 'Lien copié', color: 'success' })
|
||||
} catch {
|
||||
} else {
|
||||
toast.add({ title: 'Copie impossible', description: shareUrl.value, color: 'error' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-3.5rem)]">
|
||||
<div class="flex h-screen">
|
||||
<!-- Colonne pages -->
|
||||
<aside class="flex w-60 shrink-0 flex-col border-r border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-900">
|
||||
<BrandBar />
|
||||
<div class="flex items-center justify-between border-b border-neutral-200 p-3 dark:border-neutral-800">
|
||||
<span class="text-sm font-semibold">Pages ({{ portfolio.pages.length }})</span>
|
||||
<UDropdownMenu :items="addItems">
|
||||
@@ -420,6 +508,7 @@ async function copyShareUrl() {
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div
|
||||
:data-page-nav-id="element.id"
|
||||
class="group flex cursor-pointer items-center gap-2 rounded-md border p-2 text-sm transition-colors"
|
||||
:class="element.id === selectedPageId
|
||||
? 'border-[var(--fl-ink)] bg-neutral-50 dark:border-white dark:bg-neutral-800'
|
||||
@@ -475,6 +564,7 @@ async function copyShareUrl() {
|
||||
variant="outline"
|
||||
@click="openShare"
|
||||
/>
|
||||
<RefreshButton size="sm" />
|
||||
<UButton
|
||||
icon="i-lucide-file-down"
|
||||
label="Générer le PDF"
|
||||
@@ -520,10 +610,13 @@ async function copyShareUrl() {
|
||||
<UFormField label="Titre">
|
||||
<UInput v-model="portfolio.cover.title" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Lien du site (header public)">
|
||||
<UFormField label="Date">
|
||||
<UInput v-model="portfolio.cover.date" class="w-full" :placeholder="formatMonthYear()" />
|
||||
</UFormField>
|
||||
<UFormField label="Site">
|
||||
<UInput v-model="portfolio.cover.siteUrl" class="w-full" :placeholder="DEFAULT_SITE_URL" />
|
||||
</UFormField>
|
||||
<UFormField label="Mail de contact (header public)">
|
||||
<UFormField label="Mail">
|
||||
<UInput v-model="portfolio.cover.contactEmail" class="w-full" :placeholder="DEFAULT_CONTACT_EMAIL" />
|
||||
</UFormField>
|
||||
<BandColorPicker v-model="selectedPage.bandColor" label="Couleur du filet" :default-color="FL_INK" />
|
||||
@@ -539,7 +632,7 @@ async function copyShareUrl() {
|
||||
<UInput v-model="selectedPage.title" class="w-full" />
|
||||
</UFormField>
|
||||
<BackgroundEditor v-model="selectedPage.background" />
|
||||
<p class="text-xs text-neutral-500">Les entrées sont générées automatiquement depuis les pages-projet.</p>
|
||||
<p class="text-xs text-neutral-500">Entrées et fond hérités de la couverture.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -550,9 +643,15 @@ async function copyShareUrl() {
|
||||
<UFormField label="Titre">
|
||||
<UInput v-model="selectedPage.title" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Texte (Markdown)">
|
||||
<UFormField label="Texte">
|
||||
<UTextarea v-model="selectedPage.body" :rows="10" class="w-full" />
|
||||
</UFormField>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Taille du texte — {{ freeTextScale }} %
|
||||
</p>
|
||||
<USlider v-model="freeTextScale" :min="100" :max="180" :step="10" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Images ({{ selectedPage.media?.length ?? 0 }}/3)</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@@ -571,7 +670,7 @@ async function copyShareUrl() {
|
||||
icon="i-lucide-plus"
|
||||
size="xs"
|
||||
variant="soft"
|
||||
class="size-16"
|
||||
class="size-16 justify-center"
|
||||
@click="openMediaPicker('free')"
|
||||
/>
|
||||
</div>
|
||||
@@ -586,8 +685,22 @@ async function copyShareUrl() {
|
||||
<h3 class="mb-1 font-semibold">{{ projects[selectedPage.projectId]?.title }}</h3>
|
||||
<p class="mb-4 text-xs text-neutral-500">Page couverture du projet</p>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Disposition</p>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in coverLayoutItems"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
size="xs"
|
||||
:variant="(selectedPage.layout ?? 'band') === opt.value ? 'solid' : 'outline'"
|
||||
color="neutral"
|
||||
@click="setCoverLayout(opt.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="projects[selectedPage.projectId]" class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Image de couverture</p>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Image</p>
|
||||
<div class="grid grid-cols-4 gap-1.5">
|
||||
<button
|
||||
v-for="f in projectImages(selectedPage.projectId)"
|
||||
@@ -603,8 +716,8 @@ async function copyShareUrl() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Calage de l'image</p>
|
||||
<div v-if="(selectedPage.layout ?? 'band') === 'band'" class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Calage</p>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in coverFitItems"
|
||||
@@ -632,23 +745,24 @@ async function copyShareUrl() {
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Taille du titre — {{ titleScale }} %
|
||||
Titre — {{ titleScale }} %
|
||||
</p>
|
||||
<USlider v-model="titleScale" :min="50" :max="150" :step="5" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-if="(selectedPage.layout ?? 'band') === 'band'" class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
Hauteur du bandeau — {{ bandHeight }} mm
|
||||
Hauteur bandeau — {{ bandHeight }} mm
|
||||
</p>
|
||||
<USlider v-model="bandHeight" :min="40" :max="160" :step="5" />
|
||||
</div>
|
||||
<BandColorPicker
|
||||
v-model="selectedPage.bandColor"
|
||||
label="Couleur du projet"
|
||||
:default-color="CATEGORY_BAND[projects[selectedPage.projectId]?.category ?? '']"
|
||||
label="Couleur du filet"
|
||||
:default-color="FL_INK"
|
||||
/>
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Texte du bandeau</p>
|
||||
<BackgroundEditor v-model="selectedPage.background" />
|
||||
<div v-if="selectedPage.background?.mode === 'full'" class="space-y-2">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-neutral-500">Couleur du texte</p>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in inkItems"
|
||||
@@ -661,27 +775,60 @@ async function copyShareUrl() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UFormField label="Type de travail">
|
||||
<UFormField label="Sous-titre">
|
||||
<UInput v-model="selectedPage.subtitle" class="w-full" placeholder="Sous le titre" />
|
||||
</UFormField>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<UFormField label="Type">
|
||||
<UInput
|
||||
v-model="selectedPage.subtitleOverride"
|
||||
class="w-full"
|
||||
:placeholder="projects[selectedPage.projectId]?.categories.join(', ') || 'ex. édition'"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Année">
|
||||
<UInput v-model="selectedPage.year" class="w-full" :placeholder="projectYearPlaceholder" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="Lien">
|
||||
<UInput
|
||||
v-model="selectedPage.subtitleOverride"
|
||||
v-model="selectedPage.link"
|
||||
class="w-full"
|
||||
:placeholder="projects[selectedPage.projectId]?.categories.join(', ')"
|
||||
:placeholder="projects[selectedPage.projectId]?.externalUrl || 'https://…'"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Présentation">
|
||||
<UTextarea
|
||||
v-model="selectedPage.textOverride"
|
||||
v-model="presentationText"
|
||||
:rows="6"
|
||||
class="w-full"
|
||||
placeholder="Vide = texte du projet Grav"
|
||||
placeholder="Aucun texte"
|
||||
/>
|
||||
<div class="mt-1.5 flex items-center justify-between gap-2">
|
||||
<p v-if="presentationEdited" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
|
||||
<UIcon name="i-lucide-pencil" class="size-3.5 shrink-0" />
|
||||
Texte modifié — ne suit plus Grav
|
||||
</p>
|
||||
<p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
|
||||
Synchronisé avec Grav
|
||||
</p>
|
||||
<UButton
|
||||
v-if="presentationEdited"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
label="Réinitialiser"
|
||||
@click="resetPresentation"
|
||||
/>
|
||||
</div>
|
||||
</UFormField>
|
||||
<USeparator />
|
||||
<UButton
|
||||
icon="i-lucide-layout-grid"
|
||||
variant="soft"
|
||||
block
|
||||
:label="`Ajouter une page de grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
|
||||
:label="`Ajouter une grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
|
||||
@click="addGridPage(selectedPage.projectId)"
|
||||
/>
|
||||
</div>
|
||||
@@ -725,17 +872,13 @@ async function copyShareUrl() {
|
||||
:project="projects[selectedPage.projectId]!"
|
||||
:max="gridFamily(selectedPage.grid).max"
|
||||
/>
|
||||
<USwitch
|
||||
:model-value="selectedPage.greyBg ?? false"
|
||||
label="Fond gris clair"
|
||||
@update:model-value="(v: boolean) => { if (selectedPage?.kind === 'project-grid') selectedPage.greyBg = v || undefined }"
|
||||
/>
|
||||
<BackgroundEditor v-model="selectedPage.background" />
|
||||
<USeparator />
|
||||
<UButton
|
||||
icon="i-lucide-layout-grid"
|
||||
variant="soft"
|
||||
block
|
||||
:label="`Ajouter une page de grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
|
||||
:label="`Ajouter une grille (${unusedImages(selectedPage.projectId).length} images restantes)`"
|
||||
@click="addGridPage(selectedPage.projectId)"
|
||||
/>
|
||||
</div>
|
||||
@@ -756,21 +899,21 @@ async function copyShareUrl() {
|
||||
</UInput>
|
||||
</UFormField>
|
||||
|
||||
<UAlert
|
||||
v-if="frozenAt"
|
||||
icon="i-lucide-snowflake"
|
||||
color="info"
|
||||
variant="subtle"
|
||||
:title="`Contenu figé le ${frozenDateLabel}`"
|
||||
description="La régénération produira le même PDF, même si Grav a changé depuis."
|
||||
>
|
||||
<template #actions>
|
||||
<UButton size="xs" variant="soft" color="info" label="Réactualiser depuis Grav" @click="unfreeze" />
|
||||
</template>
|
||||
</UAlert>
|
||||
<p v-else class="text-xs text-neutral-500">
|
||||
Le contenu utilisé sera figé à la génération : le portfolio restera régénérable à l'identique.
|
||||
</p>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p v-if="!exportFileName" class="flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<UIcon name="i-lucide-info" class="size-3.5 shrink-0" />
|
||||
Aucun PDF généré pour l'instant.
|
||||
</p>
|
||||
<p v-else-if="pdfStale" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
|
||||
<UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
|
||||
Modifié depuis la dernière génération — régénérez pour mettre à jour.
|
||||
</p>
|
||||
<p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
|
||||
À jour · généré le {{ lastGeneratedLabel }}
|
||||
</p>
|
||||
<UButton v-if="frozenAt" size="xs" variant="soft" color="info" label="Réactualiser depuis Grav" @click="unfreeze" />
|
||||
</div>
|
||||
|
||||
<div v-if="lastResult" class="rounded-md border border-green-200 bg-green-50 p-3 text-sm dark:border-green-900 dark:bg-green-950">
|
||||
<p class="font-medium">{{ lastResult.fileName }} — {{ lastResult.pageCount }} pages, {{ formatBytes(lastResult.sizeBytes) }}</p>
|
||||
@@ -808,6 +951,14 @@ async function copyShareUrl() {
|
||||
</template>
|
||||
</UInput>
|
||||
</UFormField>
|
||||
<p v-if="sharedStale" class="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-500">
|
||||
<UIcon name="i-lucide-triangle-alert" class="size-3.5 shrink-0" />
|
||||
Modifié depuis la dernière publication — republiez pour mettre à jour.
|
||||
</p>
|
||||
<p v-else class="flex items-center gap-1.5 text-xs text-neutral-500">
|
||||
<UIcon name="i-lucide-check" class="size-3.5 shrink-0" />
|
||||
À jour<span v-if="frozenDateLabel"> · publié le {{ frozenDateLabel }}</span>
|
||||
</p>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<UButton icon="i-lucide-external-link" :href="shareUrl" target="_blank" external variant="soft" color="neutral" size="sm" label="Ouvrir" />
|
||||
<div class="flex gap-2">
|
||||
|
||||
Reference in New Issue
Block a user