Card images: hierarchical setting, compact-card image, explicit card-mode override

This commit is contained in:
2026-09-20 00:13:00 +00:00
parent 4bccf6855e
commit b248241af0
11 changed files with 609 additions and 90 deletions
+215 -4
View File
@@ -1,6 +1,8 @@
<script setup>
// Banner editor tab: per-page banner HTML and banner design, previewed into
// the real #page-banner region. Close and tab switching live in EditorShell.
// the real #page-banner region, plus the page's card image (Node.image,
// inherited by the subtree — the effective one previews, dimmed when
// inherited). Close and tab switching live in EditorShell.
import { computed, onActivated, onMounted, onUnmounted, ref, watch } from 'vue'
import { EditorView, basicSetup } from 'codemirror'
import { Compartment, EditorState } from '@codemirror/state'
@@ -23,6 +25,7 @@ const path = ref('')
const banner = ref('')
const saveError = ref('')
const fileInput = ref(null)
const imageInput = ref(null)
const bannerEl = ref(null)
let ws = null
@@ -63,6 +66,91 @@ const bannerDesignInherited = ref('')
// whose re-render must not race the save it triggers).
let refreshOnSave = null
// --- Card image (Node.image, '' = inherit, like the banner design) ------
// The node's own setting, the effective image after inheritance ("" =
// none) and which node supplied an inherited one ("" = the front page,
// "" also when own/none — mirrors bannerFrom).
const image = ref('')
const imageResolved = ref('')
const imageSource = ref('')
// The image the server would mine from the article itself — the card
// previews fall back to it when no node image resolves (mirrors og:image).
const imageMined = ref('')
// Whether the page has children (from the doc message): an own share
// image is inherited by the whole section.
const hasChildren = ref(false)
// The block label states which image is currently in use.
const imageLabel = computed(() => {
if (image.value) {
return hasChildren.value
? `card image: set for this article — used in /${path.value}/*`
: 'card image: set for this article'
}
if (imageResolved.value) {
const where = imageSource.value === '' ? 'the front page' : `/${imageSource.value}`
return `card image: inherited from ${where}`
}
if (imageMined.value) return 'card image: from the article'
return 'card image: none'
})
// The page title and description (from the doc message) feed the mock card
// previews; empty shows placeholder bars / text instead.
const pageTitle = ref('')
const pageDesc = ref('')
// The image the Twitter cards preview with: the resolved node card image,
// else the mined article image (what og:image would use). image_resolved is
// a bare store hash; image_mined is already a src path.
const cardImage = computed(() =>
imageResolved.value ? `/_f/${imageResolved.value}` : imageMined.value,
)
// Card-mode override (Node.large, per-article, NOT inherited):
// null = automatic, false = small, true = large.
const large = ref(null)
// Approximation of the server's automatic pick for the "automatic"
// marker: the real check probes image dimensions (>= 600px wide,
// landscape-ish AR) server-side, unavailable here — presence of an
// effective image stands in for "large".
const autoLarge = computed(() => !!cardImage.value)
const effectiveLarge = computed(() => large.value ?? autoLarge.value)
function toggleCard(forced) {
// Clicking the already-selected card deselects back to automatic.
const msg = {
type: 'save',
path: normPath(path.value),
large: large.value === forced ? null : forced,
}
large.value = msg.large
pendingSave = msg
send(msg)
// twitter:card is part of the page head: re-render on ack.
refreshOnSave = rerender
}
function saveImage(hash) {
const msg = { type: 'save', path: normPath(path.value), image: hash }
pendingSave = msg
send(msg)
// The card image feeds the card previews, card covers and social meta:
// on ack re-open the doc (fresh image/image_resolved/image_source — the
// banner itself saves in real time, so nothing is lost) and re-render.
refreshOnSave = () => {
openPath(normPath(path.value))
rerender()
}
}
async function uploadCardImage(ev) {
// Card images go to the shared content store, like banner media.
const file = ev.target.files[0]
ev.target.value = '' // allow re-picking the same file
if (!file || !file.type.startsWith('image/')) return
const name = file.name.replace(/[^\w.-]/g, '-')
const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: file })
if (!res.ok) return
const { path: stored } = await res.json() // "/_f/<hash>[.ext]"
saveImage(stored.split('/').pop().split('.')[0])
}
// The inherit option names the design actually in effect and its source.
const inheritLabel = computed(() => {
if (bannerDesignFrom.value === null) {
@@ -234,6 +322,14 @@ function onMessage(ev) {
bannerDesignFrom.value = msg.banner_design_from ?? null
bannerDesignInherited.value = msg.banner_design_inherited ?? ''
bannerFrom.value = msg.banner_from ?? null
image.value = msg.image ?? ''
imageResolved.value = msg.image_resolved ?? ''
imageMined.value = msg.image_mined ?? ''
imageSource.value = msg.image_source ?? ''
hasChildren.value = msg.has_children ?? false
large.value = msg.large ?? null
pageTitle.value = msg.title ?? ''
pageDesc.value = msg.description ?? ''
if (banner.value.trim()) previewBanner()
} else if (msg.type === 'saved') {
saveError.value = ''
@@ -339,7 +435,66 @@ onUnmounted(() => {
<div v-if="saveError">{{ saveError }}</div>
<ConnNote :text="connNote" />
<section class="block" @paste="onBannerPaste">
<section class="block card-image">
<div class="block-head">
<span class="block-label">{{ imageLabel }}</span>
<button
v-if="image"
type="button"
class="icon-btn del"
title="clear the card image (back to inherit)"
@click="saveImage('')"
></button>
<button
type="button"
class="icon-btn"
title="upload card image (og:image / card covers) — the subtree inherits it"
@click="imageInput.click()"
>🖼</button>
<input
ref="imageInput"
type="file"
accept="image/*"
hidden
@change="uploadCardImage"
/>
</div>
<!-- The site's own cards double as the card-mode selector: rendered
with the real .card styles from pagerite.css (theme variables
and all — they ARE the site's look). Clicking one forces that
mode (Node.large), clicking the selected one returns to
automatic. The description only exists in the small format,
like the backend's _card. -->
<div class="site-previews">
<button
type="button"
class="card compact preview"
:class="{ selected: large === false, auto: large === null && !effectiveLarge }"
title="small card — click to force it, click again for automatic"
@click="toggleCard(false)"
>
<span class="top">
<img v-if="cardImage" class="cover" :src="cardImage" alt="" />
<span class="title">{{ pageTitle || 'page title' }}</span>
</span>
<span class="bottom">
<span v-if="pageDesc" class="desc">{{ pageDesc }}</span>
</span>
</button>
<button
type="button"
class="card preview"
:class="{ selected: large === true, auto: large === null && effectiveLarge }"
title="large card — click to force it, click again for automatic"
@click="toggleCard(true)"
>
<span class="cover" :style="cardImage ? `background-image: url('${cardImage}')` : null" />
<span class="title">{{ pageTitle || 'page title' }}</span>
</button>
</div>
</section>
<section class="block banner-block" @paste="onBannerPaste">
<div class="block-head">
<select
v-model="bannerDesign"
@@ -356,7 +511,7 @@ onUnmounted(() => {
class="icon-btn"
title="upload banner image/video (replaces existing media) — pasting works too"
@click="fileInput.click()"
>🖼</button>
>🖼︎</button>
<input
ref="fileInput"
type="file"
@@ -385,10 +540,61 @@ onUnmounted(() => {
gap: 0.4rem;
padding: 0.5rem 1rem;
background: var(--surface);
}
.banner-block {
flex: 1;
min-height: 0;
}
/* The card-image section: a real preview of the effective image (the
node's own or the inherited one, dimmed then), with upload/clear in the
head row like the banner media button. */
.card-image {
flex: 0 0 auto;
border-bottom: 1px solid var(--line);
}
.block-label {
color: var(--muted);
font-size: 0.8rem;
}
/* The site's own card previews: real .card markup/styles from pagerite.css,
scaled down via font-size (the card internals are all em, so the layout
proportions match the real cards exactly). They double as the card-mode
selector: thin outlines only (no border changes, so selecting never
shifts the layout) — solid accent for a forced mode, dashed muted for
the mode "automatic" currently resolves to (approximated from image
presence). */
.site-previews {
display: flex;
gap: 0.8rem;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 0.8rem;
}
.site-previews .card.preview {
font: inherit;
font-size: 0.67rem;
width: 24em;
max-width: 100%;
padding: 0;
text-align: start;
cursor: pointer;
}
.site-previews .card.preview.selected {
outline: 1px solid var(--accent);
outline-offset: 2px;
}
.site-previews .card.preview.auto:not(.selected) {
outline: 1px dashed var(--muted);
outline-offset: 2px;
}
.block-head {
display: flex;
align-items: center;
@@ -401,11 +607,16 @@ onUnmounted(() => {
}
.block-head .icon-btn {
margin-left: auto;
padding: 0 0.2rem;
font-size: 1rem;
}
/* The first icon button pushes itself (and any siblings after it, like
the card-image clear button) to the end of the row. */
.block-head .icon-btn:first-of-type {
margin-left: auto;
}
/* The banner design selector stays compact; the upload button is pushed
right by its auto margin. */
.design-select {
+3 -3
View File
@@ -2,9 +2,9 @@
// Recursive site-structure tree with drag-and-drop ordering (vue-draggable).
// Nodes come from the server (GET /_api/pages via StructureEditor.vue) as
// {slug, path, title, translated, order, published, has_content, language,
// primary, children}. The row's flag (LangSelect) sets the node's primary
// language (language; '' = inherit — dimmed, showing the resolved flag);
// the setting covers the whole subtree.
// primary, children}. The row's flag
// (LangSelect) sets the node's primary language (language; '' = inherit —
// dimmed, showing the resolved flag); the setting covers the whole subtree.
// With a `lang` prop (StructureEditor's language strip) the titles shown
// are that language's; `translated` marks rows with an actual translation
// (untranslated rows show the original title, dimmed).
+147 -42
View File
@@ -287,7 +287,7 @@ body {
#nav span {
color: var(--muted);
font-size: 0.95rem;
font-size: 0.95em;
}
#nav .current {
@@ -485,16 +485,25 @@ main {
404) lists its published children after the markdown content — one
card per child (a child without a page of its own is represented by
its first leaf page; see _cards in views.py). The row bleeds to full
page width (div.wide): the cards first grow to fill it, then shrink
rather than wrap. Every card has the same fixed 16/10 shape, covered
entirely by the
page's share image (og:image heuristics, as a background — a gradient
placeholder when it has none) with the title overlaid on a translucent
band at the bottom. The card is one <a> holding only phrasing-level
spans; the spans lay out as blocks. */
page width (div.wide); every card has the same fixed 16/10 shape,
scaling only with the available width. Cards come in two modes
following the child's twitter:card selection (_card_large — the
per-article override, else the card image's dimensions): large cards
are covered entirely by the page's card image (og:image heuristics, as
a background — a gradient placeholder when it has none) with the title
overlaid on a translucent band at the bottom; small cards (.card.compact)
split at the golden ratio: a square cover in the top part with the
title beside it, the article description below. The card is one <a>
holding only phrasing-level spans; the
spans lay out as blocks. */
/* Equal-width columns (grid, not flex: no differential shrink, no
cross-axis stretch — every card keeps the fixed 16/10 shape, scaling
only with the available width). Cards stop growing at their cap; the
row centers in the bleed then. */
.cards {
display: flex;
/* Cards stop growing at their cap; center the row in the bleed then. */
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 24em);
justify-content: center;
gap: 1.25rem;
margin-top: 2.5rem;
@@ -503,39 +512,36 @@ main {
padding-inline: 1.25rem;
}
/* Phones: the cards stack vertically instead of shrinking to slivers.
Text-only cards (gradient cover + description) then fit their content —
the fixed 16/10 shape only makes sense for image covers. */
/* Phones: the cards stack vertically instead of shrinking to slivers. */
@media (max-width: 48rem) {
.cards {
flex-direction: column;
}
.card:has(.desc) {
aspect-ratio: auto;
grid-auto-flow: row;
grid-auto-columns: unset;
grid-template-columns: minmax(0, 24em);
}
}
.card {
/* Grow to fill the row (up to the cap — full-width rows would make huge
cards), shrink (not wrap) when there are too many. */
flex: 1 1 0;
max-width: 24rem;
/* Fill the (equal-width) grid column; the height comes only from the
fixed aspect ratio (align-self: start — the row must not stretch it).
Overflowing content is clipped. */
width: 100%;
align-self: start;
position: relative;
display: flex;
flex-direction: column;
aspect-ratio: 16 / 10;
/* Allow shrinking below the text's min-content width: without this the
text cards hold their row wider than the image-only cards. */
min-width: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 0.5rem;
background: var(--surface);
border-radius: 0.5em;
/* Lifted from the page background (a touch of the text color) so cards
read as distinct surfaces. */
background: color-mix(var(--surface) 88%, var(--text));
color: var(--text);
text-decoration: none;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 0 0.1rem black;
box-shadow: 0 0 0.1em black;
}
.card:hover {
@@ -555,16 +561,22 @@ main {
.card .title {
position: relative;
display: block;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
margin-top: auto;
padding: 0.75rem 1.25rem 0.9rem;
background: color-mix(var(--bg) 40%, transparent);
padding: 0.75em 1.25em 0.9em;
background: color-mix(var(--bg) 65%, transparent);
/* Pinned: the article a:hover accent must not leak through the card. */
color: var(--text);
font-family: var(--font-heading);
font-size: 1.25rem;
font-size: 1.25em;
font-weight: 900;
line-height: 1.25;
/* Hyphenation needs the card's lang (the target article's language). */
hyphens: auto;
}
/* Image-less cards carry the description under the title, extending the
@@ -575,19 +587,112 @@ main {
.card .desc {
position: relative;
display: block;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
line-clamp: 4;
overflow: hidden;
/* Four lines exactly: the text itself is truncated server-side (_cards),
this is just the safety net. max-height spans the lines plus the
vertical padding (border-box), so nothing bleeds past the clip. */
/* Four lines exactly, with an ellipsis on overflow: the text itself is
truncated server-side (_cards), this is just the safety net. */
line-height: 1.4;
max-height: calc(4 * 1.4em + 1.3rem);
padding: 0.4rem 1.25rem 0.9rem;
background: color-mix(var(--bg) 30%, transparent);
padding: 0.4em 1.25em 0.9em;
background: color-mix(var(--bg) 55%, transparent);
color: var(--muted);
font-size: 0.95rem;
font-size: 0.95em;
text-align: start;
hyphens: none;
hyphens: auto;
}
/* Small cards (_card_large false; the twitter "summary" style): externally
the same 16/10 shape as the large card, split horizontally in two
sub-grids at the golden ratio — the top part (the image, its full
height, with the title beside it at the bottom) takes φ, the bottom
part (the description — which only the small format carries — above
spare space) takes 1, so the image's bottom edge sits at ~62% of the
card instead of centering wherever the differing title/description
sizes land it. The title and description carry the translucent band
(the same band color as the large cards' title) as their own
backgrounds. Imageless cards are just the gradient with the title
spanning the full width. */
.card.compact {
display: grid;
/* minmax(0, …): the parts never grow past the card — overlong text is
line-clamped (title/description) instead of pushing the layout. */
grid-template-rows: minmax(0, 1.618fr) minmax(0, 1fr);
background: linear-gradient(
135deg,
color-mix(var(--surface) 88%, var(--text)),
var(--bg)
);
}
/* Top part: spare space above the title row. The image column is as wide
as the top part is tall — the card is 16/10 and the top is φ/(φ+1) of
its height, i.e. ~38.6% of its width — so a square image at that width
fills the whole top part. */
.card.compact .top {
display: grid;
grid-template-columns: 38.6% 1fr;
grid-template-rows: minmax(0, 1fr) auto;
min-height: 0;
}
/* The image fills its whole grid area (column 1, both rows); cover crops
rather than letterboxes. */
.card.compact img.cover {
position: static;
grid-column: 1;
grid-row: 1 / 3;
z-index: 2;
width: 100%;
height: 100%;
margin: 0;
object-fit: cover;
background: none;
}
.card.compact .title {
/* Full width: the translucent band runs behind the image to the left
edge (the image paints over it, z-index 2), the text indented past
the image column. */
grid-column: 1 / -1;
grid-row: 2;
z-index: 1;
margin: 0;
padding: 0.4em 0.6em 0 calc(38.6% + 0.6em);
background: color-mix(var(--bg) 65%, transparent);
}
/* No image: no indentation, the text starts at the left edge. */
.card.compact .top:not(:has(img.cover)) .title {
padding-left: 0.6em;
}
/* No description: the title supplies the band's bottom padding. */
.card.compact:not(:has(.desc)) .title {
padding-bottom: 0.4em;
}
/* Bottom part: the description above spare space. */
.card.compact .bottom {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
}
.card.compact .desc {
grid-column: 1 / -1;
grid-row: 1;
z-index: 1;
margin: 0;
padding: 0.4em 0.6em;
background: color-mix(var(--bg) 65%, transparent);
/* Three lines fit the bottom part (⅜ of the card) even at the card cap;
the line clamp adds the ellipsis past that. */
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
max-height: none;
}
article h1,
@@ -786,7 +891,7 @@ article {
/* pagerite.js tucks the pen at the end of the article's first h1. */
article h1 .edit-link {
position: static;
font-size: 1.1rem;
font-size: 1.1em;
vertical-align: 0.3em;
margin-inline-start: 0.4rem;
}
@@ -1431,7 +1536,7 @@ article figure img {
max-width: 65ch;
padding: 0.8rem 1rem;
color: var(--lightbox-text);
font-size: 0.95rem;
font-size: 0.95em;
text-align: center;
opacity: 0.85;
}