App Nuxt 3 + Nuxt UI qui lit les projets du CMS Grav et compose des portfolios A3 paysage exportés en PDF (gabarit book_v3) : composeur WYSIWYG, templates cover/toc/projet/grille/page libre, pipeline Playwright + Ghostscript, gel du contenu à la génération, partage public opt-in. Dépôt autonome : Dockerfile (base Playwright + Ghostscript + ffmpeg + rsync) inclus. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
5.4 KiB
Vue
135 lines
5.4 KiB
Vue
<script setup lang="ts">
|
|
// Page « grille » de projet — gabarit_book_v3 p.2/4/6/8.
|
|
// Filet couleur 5 mm en haut, images ENTIÈRES (object-contain) calées sur
|
|
// une grille ADAPTATIVE : la famille choisie se construit selon le nombre
|
|
// d'images et leur orientation. Marges 8 mm, gouttières 3,8 mm.
|
|
import type { ResolvedMedia } from '~~/types'
|
|
import type { ImageOrientation } from '~~/composables/useGrids'
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
media: ResolvedMedia[]
|
|
grid: string // id de famille (useGrids)
|
|
orientation?: ImageOrientation
|
|
category?: string
|
|
bandColor?: string
|
|
greyBg?: boolean
|
|
}>(),
|
|
{ orientation: 'mixed' },
|
|
)
|
|
|
|
const def = computed(() => gridDef(props.grid, props.media.length, props.orientation))
|
|
const strip = computed(() => stripColorOf(props.category, props.bandColor))
|
|
|
|
// Métriques du canvas A3 paysage (theme.css) — zone utile hors marges 8 mm.
|
|
const MARGIN = 30.2
|
|
const GUTTER = 14.4
|
|
const AVAIL_W = 1587.4 - 2 * MARGIN // 1527 px
|
|
const AVAIL_H = 1122.5 - 2 * MARGIN // 1062,1 px
|
|
|
|
// Familles « grande image + bandeaux/file » : la colonne étroite empile ses
|
|
// images à leur HAUTEUR RÉELLE (à la largeur de colonne), si bien que les
|
|
// gouttières entre bandeaux valent exactement --fl-gutter — comme la colonne
|
|
// de la grille « pleine largeur empilées ». Si la pile dépasse la hauteur de
|
|
// page, on réduit toutes les images proportionnellement (aucun letterbox,
|
|
// images toujours entières).
|
|
const hero = computed(() => {
|
|
const id = def.value.id
|
|
if (id !== 'hero-left' && id !== 'hero-right') return null
|
|
const left = id === 'hero-left'
|
|
const b = def.value.cells.length - 1 // nombre de bandeaux/vignettes
|
|
if (b < 1) return null
|
|
|
|
const cols = def.value.cols
|
|
const bigFrac = left ? cols[0] : cols[1]
|
|
const smallFrac = left ? cols[1] : cols[0]
|
|
const total = bigFrac + smallFrac
|
|
// largeur de la colonne étroite (les deux colonnes se partagent la largeur
|
|
// utile moins la gouttière qui les sépare)
|
|
const smallW = (smallFrac / total) * (AVAIL_W - GUTTER)
|
|
|
|
const big = left ? props.media[0] : props.media[b]
|
|
const bandeaux = left ? props.media.slice(1, b + 1) : props.media.slice(0, b)
|
|
|
|
const natH = bandeaux.map(m => smallW / (m.ratio && m.ratio > 0 ? m.ratio : 1.5))
|
|
const gaps = (b - 1) * GUTTER
|
|
const sum = natH.reduce((a, h) => a + h, 0)
|
|
const maxH = AVAIL_H - gaps
|
|
const k = sum > maxH ? maxH / sum : 1
|
|
const heights = natH.map(h => h * k)
|
|
|
|
return { left, big, bandeaux, heights, bigFrac, smallFrac }
|
|
})
|
|
|
|
const gridStyle = computed(() => ({
|
|
gridTemplateColumns: def.value.cols.map(c => `${c}fr`).join(' '),
|
|
gridTemplateRows: def.value.rows.map(r => `${r}fr`).join(' '),
|
|
gap: 'var(--fl-gutter)',
|
|
}))
|
|
|
|
function cellStyle(i: number) {
|
|
const cell = def.value.cells[i]
|
|
if (!cell) return {}
|
|
return {
|
|
gridColumn: `${cell.c + 1} / span ${cell.cs ?? 1}`,
|
|
gridRow: `${cell.r + 1} / span ${cell.rs ?? 1}`,
|
|
}
|
|
}
|
|
|
|
/** Cale l'image sur les lignes extérieures de la grille (jamais tronquée). */
|
|
function objectPosition(i: number) {
|
|
const cell = def.value.cells[i]
|
|
if (!cell) return 'left top'
|
|
const lastCol = cell.c + (cell.cs ?? 1) === def.value.cols.length
|
|
const firstCol = cell.c === 0
|
|
const x = firstCol && lastCol ? 'center' : lastCol ? 'right' : 'left'
|
|
return `${x} top`
|
|
}
|
|
|
|
const items = computed(() =>
|
|
def.value.cells.map((_, i) => props.media[i]).filter((m): m is ResolvedMedia => !!m),
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="fl-page" :style="greyBg ? { backgroundColor: FL_BASE } : undefined">
|
|
<!-- Filet couleur pleine largeur -->
|
|
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
|
|
|
<!-- Grande image + bandeaux/file : bandeaux à leur hauteur réelle -->
|
|
<div v-if="hero" class="absolute flex" :style="{ inset: 'var(--fl-margin)', gap: 'var(--fl-gutter)' }">
|
|
<!-- grande image (à gauche pour hero-left, à droite pour hero-right) -->
|
|
<div v-if="!hero.left" class="min-h-0 min-w-0 flex flex-col" :style="{ flex: `${hero.smallFrac} 0 0`, gap: 'var(--fl-gutter)' }">
|
|
<div v-for="(m, i) in hero.bandeaux" :key="m.filename + i" class="min-h-0 w-full" :style="{ height: `${hero.heights[i]}px` }">
|
|
<img :src="m.url" :alt="m.filename" class="size-full object-contain">
|
|
</div>
|
|
</div>
|
|
<div class="min-h-0 min-w-0" :style="{ flex: `${hero.bigFrac} 0 0` }">
|
|
<img
|
|
:src="hero.big.url"
|
|
:alt="hero.big.filename"
|
|
class="size-full object-contain"
|
|
:style="{ objectPosition: hero.left ? 'left top' : 'right top' }"
|
|
>
|
|
</div>
|
|
<div v-if="hero.left" class="min-h-0 min-w-0 flex flex-col" :style="{ flex: `${hero.smallFrac} 0 0`, gap: 'var(--fl-gutter)' }">
|
|
<div v-for="(m, i) in hero.bandeaux" :key="m.filename + i" class="min-h-0 w-full" :style="{ height: `${hero.heights[i]}px` }">
|
|
<img :src="m.url" :alt="m.filename" class="size-full object-contain">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Grille d'images standard dans les marges de 8 mm -->
|
|
<div v-else class="absolute grid" :style="{ inset: 'var(--fl-margin)', ...gridStyle }">
|
|
<div v-for="(m, i) in items" :key="m.filename + i" class="min-h-0 min-w-0" :style="cellStyle(i)">
|
|
<img
|
|
:src="m.url"
|
|
:alt="m.filename"
|
|
class="size-full object-contain"
|
|
:style="{ objectPosition: objectPosition(i) }"
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|