Générateur de portfolios PDF Figures Libres (app Nuxt autonome)
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>
This commit is contained in:
59
components/templates/CoverTemplate.vue
Normal file
59
components/templates/CoverTemplate.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
// Couverture du portfolio — reprend l'accueil du site figureslibres.cc
|
||||
// (docs/portfolios-examples/screenshot-site-figures-libres.png) : la phrase
|
||||
// d'intro seule, bloc centré à 65 % de large, chaque keyword dans sa fonte
|
||||
// (sans soulignement, contrairement au site), flèche vers le bas. Rien d'autre.
|
||||
import type { Background } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string // utilisé pour les métadonnées PDF, pas affiché
|
||||
subtitle?: string
|
||||
recipient?: string
|
||||
background: Background
|
||||
bandColor?: string // filet couleur en bas (définit aussi celui du sommaire)
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ renderMode: 'preview' },
|
||||
)
|
||||
|
||||
// Le site affiche l'intro sur fond gris clair : « Blanc » rend le gris charte.
|
||||
const pageStyle = computed(() => {
|
||||
const s = backgroundStyle(props.background)
|
||||
if (props.background.mode === 'white') s.backgroundColor = FL_BASE
|
||||
return s
|
||||
})
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page" :style="pageStyle">
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<!-- Phrase d'intro du site (pages/01.home) — Lato, keywords dans les
|
||||
fontes d'« utilité » du thème Grav, non soulignés -->
|
||||
<p class="w-[65%]" :style="{ fontFamily: 'Lato, sans-serif', fontSize: '54px', lineHeight: '1.35' }">
|
||||
<span class="font-bold" style="font-family: 'Syne', sans-serif">Figures Libres</span>
|
||||
est un collectif de design graphique et interactif porteur de messages d'utilité
|
||||
<span class="fl-font-publique">publique</span>,
|
||||
<span class="fl-font-sociale">sociale</span> et
|
||||
<span class="fl-font-culturelle">culturelle</span>
|
||||
qui œuvre sur logiciels libres.
|
||||
</p>
|
||||
|
||||
<!-- Flèche vers le bas (theme://images/pictos/arrow-down.svg) -->
|
||||
<svg viewBox="0 0 24 30" :style="{ width: '60px', height: '54px', marginTop: '90px' }" aria-hidden="true">
|
||||
<g transform="rotate(90,11,12.999996)">
|
||||
<path
|
||||
d="m 16.681932,5.2684395 0.08629,0.091376 5.042819,6.0548205 v 0 l 0.06362,0.100098 v 0 l 0.06561,0.144066 v 0 l 0.0389,0.137168 v 0 l 0.01717,0.117449 v 0 L 22,12 v 0 l -0.0025,0.07061 v 0 l -0.01594,0.12126 v 0 l -0.02359,0.09573 -0.0317,0.08965 v 0 l -0.06326,0.128377 v 0 l -0.09484,0.134549 -5,6 c -0.353564,0.424277 -0.984128,0.481601 -1.408405,0.128037 C 14.968124,18.441846 14.889154,17.879455 15.15741,17.46116 L 15.231779,17.359816 18.864,12.999975 3,13 C 2.4477153,13 2,12.552285 2,12 2,11.487164 2.3860402,11.064493 2.8833789,11.006728 L 3,11 18.864,10.999975 15.231779,6.6401844 c -0.353564,-0.4242769 -0.29624,-1.0548416 0.128037,-1.4084057 0.39164,-0.3263668 0.959052,-0.3026278 1.322116,0.036661 z"
|
||||
fill="currentColor"
|
||||
fill-rule="nonzero"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Filet couleur pleine largeur en bas -->
|
||||
<div class="absolute inset-x-0 bottom-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
</div>
|
||||
</template>
|
||||
39
components/templates/FreeTextPage.vue
Normal file
39
components/templates/FreeTextPage.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
// Page libre (équipe, méthodologie…) — langage du gabarit : filet couleur
|
||||
// 5 mm, marges 8 mm, titre Syne 45 pt, texte Syne 12/16 pt, médias entiers
|
||||
// calés à droite. Pas de multi-colonnes CSS (Chromium les imprime en blanc).
|
||||
import type { Background, ResolvedMedia } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
text?: string // HTML
|
||||
media?: ResolvedMedia[]
|
||||
background: Background
|
||||
bandColor?: string
|
||||
page: { index: number; total: number }
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ renderMode: 'preview' },
|
||||
)
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page" :style="backgroundStyle(background)">
|
||||
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
|
||||
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
|
||||
<h2 v-if="title" class="max-w-[1000px]" :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ title }}</h2>
|
||||
<div class="mt-12 flex min-h-0 flex-1" :style="{ gap: '48px' }">
|
||||
<div v-if="text" class="fl-prose max-w-[740px] flex-1 overflow-hidden" v-html="text" />
|
||||
<div v-if="media?.length" class="flex w-[560px] shrink-0 flex-col" :style="{ gap: 'var(--fl-gutter)' }">
|
||||
<div v-for="m in media.slice(0, 3)" :key="m.filename" class="min-h-0 flex-1">
|
||||
<img :src="m.url" :alt="m.filename" class="size-full object-contain" style="object-position: right top">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
95
components/templates/ProjectCoverPage.vue
Normal file
95
components/templates/ProjectCoverPage.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
// Page « couverture » de projet — gabarit_book_v3 p.1/3/5/7.
|
||||
// Bandeau pleine largeur 55 mm : titre (fonte de la catégorie, 45 pt),
|
||||
// pastille catégorie (10 pt), présentation (Syne 12/16 pt, colonne à 50,5 %).
|
||||
// Sous le bandeau : image de couverture. Taille du titre et hauteur du
|
||||
// bandeau (= calage haut de l'image) ajustables pour éviter les débords.
|
||||
import type { InkMode, ResolvedMedia } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
category: string // "publique" | "sociale" | "culturelle"
|
||||
badge?: string // texte de la pastille (ex. « Utilité publique »)
|
||||
intro?: string // ligne(s) d'intro en gras
|
||||
text?: string // présentation, HTML
|
||||
image?: ResolvedMedia
|
||||
imageFit?: 'width' | 'height' // calage : pleine largeur ou pleine hauteur
|
||||
imageAlign?: 'left' | 'center' | 'right' // fit hauteur : marge gauche / centrée / marge droite
|
||||
titleScale?: number // % de la taille gabarit (100 = 45 pt)
|
||||
bandHeight?: number // mm (gabarit : 55)
|
||||
bandColor?: string
|
||||
ink?: InkMode
|
||||
}>(),
|
||||
{ ink: 'auto', imageFit: 'width', imageAlign: 'center', titleScale: 100, bandHeight: 55 },
|
||||
)
|
||||
|
||||
const PX_PER_MM = 3.7796
|
||||
|
||||
const band = computed(() => bandColorOf(props.category, props.bandColor))
|
||||
const inkColor = computed(() => inkOn(band.value, props.ink))
|
||||
const titleClass = computed(() => CATEGORY_TITLE_CLASS[props.category] ?? '')
|
||||
// L'Avara est plus grasse : le gabarit la compose en 42 pt au lieu de 45 pt.
|
||||
const titleSize = computed(() => {
|
||||
const base = props.category === 'culturelle' ? 56 : 60
|
||||
return `${(base * props.titleScale) / 100}px`
|
||||
})
|
||||
const bandPx = computed(() => `${props.bandHeight * PX_PER_MM}px`)
|
||||
|
||||
// Fit hauteur : calage horizontal sur la marge gauche, centré, ou marge droite
|
||||
const alignStyle = computed(() => {
|
||||
if (props.imageFit !== 'height') return { justifyContent: 'center' }
|
||||
switch (props.imageAlign) {
|
||||
case 'left': return { justifyContent: 'flex-start', paddingLeft: 'var(--fl-margin)' }
|
||||
case 'right': return { justifyContent: 'flex-end', paddingRight: 'var(--fl-margin)' }
|
||||
default: return { justifyContent: 'center' }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page">
|
||||
<!-- Bandeau -->
|
||||
<div class="absolute inset-x-0 top-0" :style="{ height: bandPx, backgroundColor: band, color: inkColor }">
|
||||
<h1
|
||||
class="absolute"
|
||||
:class="titleClass"
|
||||
:style="{ left: 'var(--fl-margin)', top: '14px', fontSize: titleSize, lineHeight: '1.15', maxWidth: '620px' }"
|
||||
>
|
||||
{{ title }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="badge"
|
||||
class="absolute whitespace-nowrap rounded-full border px-[12px] py-[3px]"
|
||||
:style="{ left: '42.33%', top: '54px', transform: 'translateY(-50%)', fontSize: '13.3px', lineHeight: '1.2', borderColor: 'currentColor' }"
|
||||
>
|
||||
{{ badge }}
|
||||
</span>
|
||||
<div
|
||||
class="absolute overflow-hidden"
|
||||
:style="{ left: '50.47%', right: 'var(--fl-margin)', top: '33px', bottom: '12px' }"
|
||||
>
|
||||
<p v-if="intro" class="font-bold">{{ intro }}</p>
|
||||
<div v-if="text" class="fl-prose" v-html="text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image de couverture : calée sur la largeur (pleine largeur, fixée en
|
||||
bas de page, débord haut coupé) ou sur la hauteur (pleine hauteur,
|
||||
centrée). Le fond visible sous/à côté de l'image reprend la couleur
|
||||
du bandeau. -->
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex overflow-hidden"
|
||||
:style="{ top: bandPx, backgroundColor: image ? band : '#ffffff', ...alignStyle }"
|
||||
>
|
||||
<img
|
||||
v-if="image"
|
||||
:src="image.url"
|
||||
:alt="title"
|
||||
:style="imageFit === 'height'
|
||||
? { height: '100%', width: 'auto', maxWidth: 'none' }
|
||||
: { width: '100%', height: 'auto', alignSelf: 'flex-end' }"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
134
components/templates/ProjectGridPage.vue
Normal file
134
components/templates/ProjectGridPage.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<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>
|
||||
77
components/templates/TocTemplate.vue
Normal file
77
components/templates/TocTemplate.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// Sommaire — langage du gabarit : filet couleur 5 mm en haut, marges 8 mm,
|
||||
// titre Syne 45 pt. La taille des entrées et leur disposition (colonne unique
|
||||
// ou grille) s'adaptent au nombre de projets listés.
|
||||
import type { Background } from '~~/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
entries: { title: string; subtitle?: string; category?: string; pageNumber: number }[]
|
||||
background: Background
|
||||
bandColor?: string
|
||||
page: { index: number; total: number }
|
||||
renderMode?: 'preview' | 'print'
|
||||
}>(),
|
||||
{ title: 'Sommaire', renderMode: 'preview' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ navigate: [pageNumber: number] }>()
|
||||
|
||||
const strip = computed(() => props.bandColor || FL_INK)
|
||||
const isPrint = computed(() => props.renderMode === 'print')
|
||||
|
||||
// En print, chaque entrée est un lien interne #page-N (converti en lien PDF par
|
||||
// Chromium). En aperçu, le même clic défile jusqu'à la page dans la preview.
|
||||
function hrefFor(n: number) {
|
||||
return isPrint.value ? `#page-${n}` : `#preview-page-${n}`
|
||||
}
|
||||
function onEntryClick(n: number, e: MouseEvent) {
|
||||
if (isPrint.value) return // laisse le lien natif au PDF
|
||||
e.preventDefault()
|
||||
e.stopPropagation() // n'active pas la sélection de la page-sommaire elle-même
|
||||
emit('navigate', n)
|
||||
}
|
||||
|
||||
// Peu de projets = gros titres sur une colonne ; beaucoup = grille dense.
|
||||
const layout = computed(() => {
|
||||
const n = props.entries.length
|
||||
if (n <= 4) return { cols: 1, size: 48, sub: 18.7, gapX: 0, gapY: 44 }
|
||||
if (n <= 8) return { cols: 1, size: 32, sub: 14.7, gapX: 0, gapY: 30 }
|
||||
if (n <= 16) return { cols: 2, size: 24, sub: 13.3, gapX: 96, gapY: 24 }
|
||||
if (n <= 26) return { cols: 2, size: 18.7, sub: 12, gapX: 96, gapY: 18 }
|
||||
return { cols: 3, size: 14.7, sub: 10.7, gapX: 60, gapY: 14 }
|
||||
})
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `repeat(${layout.value.cols}, minmax(0, 1fr))`,
|
||||
columnGap: `${layout.value.gapX}px`,
|
||||
rowGap: `${layout.value.gapY}px`,
|
||||
gridAutoRows: 'min-content',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fl-page" :style="backgroundStyle(background)">
|
||||
<div class="absolute inset-x-0 top-0" :style="{ height: 'var(--fl-strip)', backgroundColor: strip }" />
|
||||
|
||||
<div class="absolute flex flex-col" :style="{ inset: 'var(--fl-margin)', top: '60px' }">
|
||||
<h2 :style="{ fontSize: '60px', lineHeight: '1.1' }">{{ title }}</h2>
|
||||
<div class="mt-14 grid min-h-0 flex-1 content-start" :style="gridStyle">
|
||||
<a
|
||||
v-for="entry in entries"
|
||||
:key="`${entry.pageNumber}-${entry.title}`"
|
||||
:href="hrefFor(entry.pageNumber)"
|
||||
class="flex items-baseline gap-3 text-current no-underline"
|
||||
:class="{ 'cursor-pointer': !isPrint }"
|
||||
@click="onEntryClick(entry.pageNumber, $event)"
|
||||
>
|
||||
<span :class="CATEGORY_TITLE_CLASS[entry.category ?? '']" :style="{ fontSize: `${layout.size}px`, lineHeight: '1.2' }">{{ entry.title }}</span>
|
||||
<span v-if="entry.subtitle" class="whitespace-nowrap opacity-70" :style="{ fontSize: `${layout.sub}px` }">{{ entry.subtitle }}</span>
|
||||
<span class="flex-1 border-b border-dotted border-current opacity-40" />
|
||||
<span class="tabular-nums opacity-80" :style="{ fontSize: `${layout.sub + 2}px` }">{{ entry.pageNumber }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user