Files
Valentin Le Moign ef1f14335d 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
2026-07-06 13:26:06 +02:00

136 lines
5.6 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 { Background, ResolvedMedia } from '~~/types'
import type { ImageOrientation } from '~~/composables/useGrids'
const props = withDefaults(
defineProps<{
media: ResolvedMedia[]
grid: string // id de famille (useGrids)
orientation?: ImageOrientation
bandColor?: string
background?: Background // fond : blanc / teinté / plein (couleur du filet)
}>(),
{ orientation: 'mixed' },
)
const def = computed(() => gridDef(props.grid, props.media.length, props.orientation))
const strip = computed(() => filetColor(props.bandColor))
// Fond « plein » = couleur du filet (le filet est alors masqué).
const pageBg = computed(() => backgroundColorOf(props.background, strip.value))
// 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="{ backgroundColor: pageBg }">
<!-- Filet couleur fin en haut (masqué en fond plein) -->
<div v-if="background?.mode !== 'full'" class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip-light)', 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>