Card image: paste button and site-icon (@favicon) option

The banner panel's card-image block gains a pasteboard button (an image
uploads; an image URL goes as the save message's image setting itself and
is fetched/stored server-side, cross-origin being CORS-blocked for the
browser) and a site-icon button saving the @favicon sentinel, which
resolves to Data.favicon at render time and follows favicon changes.
This commit is contained in:
2026-09-23 06:31:06 +00:00
parent 4d6735f609
commit 580ebac06c
7 changed files with 154 additions and 29 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ Files are content-addressed (blake3[:12] + extension) and stored **on disk** und
## Card images
`Node.image` names a content-addressed store file (12-hex hash, served at `/_f/{name}`) used as the page's card image: `og:image`/`twitter:image` meta and the card cover in listings. The effective image follows the priority: the node's own `image`, then one mined from the rendered article (hero → first raster → first SVG), then the inherited image (the nearest ancestor's, the front page last). Set in the editor's banner panel (upload → `PUT /_api/files/{name}`, then a `save` with `image` over the editor WebSocket), stored at `IMAGE_MAXSIZE` like other uploads. `twitter:card` picks `summary_large_image` vs `summary` from the image's probed dimensions (views.py `_image_dims`).
`Node.image` names a content-addressed store file (12-hex hash, served at `/_f/{name}`) used as the page's card image: `og:image`/`twitter:image` meta and the card cover in listings. The effective image follows the priority: the node's own `image`, then one mined from the rendered article (hero → first raster → first SVG), then the inherited image (the nearest ancestor's, the front page last). The special value `@favicon` resolves to the site icon (`Data.favicon`) at render time — it follows favicon changes rather than copying the current icon. Set in the editor's banner panel (upload → `PUT /_api/files/{name}` or paste from the pasteboard — an image uploads, an image URL is sent as the `image` setting itself and fetched/stored server-side by the save handler, since cross-origin URLs are CORS-blocked for the browser — then a `save` with `image` over the editor WebSocket; the site-icon button saves `@favicon`), stored at `IMAGE_MAXSIZE` like other uploads. `twitter:card` picks `summary_large_image` vs `summary` from the image's probed dimensions (views.py `_image_dims`).
`Node.large: bool | None` overrides the automatic card-mode pick per article: None = automatic, False forces a small card, True a large one. Unlike `image`, it is NOT inherited down the tree. Set from the banner panel's card previews (a `save` with `large` over the editor WebSocket).
+1 -1
View File
@@ -7,7 +7,7 @@ The Vue editor is a single tabbed `EditorShell.vue` mounted in a host div create
The shell hosts five kept-alive tabs (ordered site-wide first — site, structure, localization — then, after a visual break, the per-page tabs — article, banner):
- `PageEditor.vue` — CodeMirror + server-rendered preview over WebSocket `/_api/ws/editor`, previewing into the visible article; editor and article scrolls are linked piecewise-linearly, keyed on the section anchors' `data-line` (markdown source line the backend stamps on top-level anchored h1/h2s): the page follows the cursor (fractional, wrap-aware, scrolling only when the cursor's page position leaves the viewport, with an edge margin), the editor follows page scroll with a progress-based viewport anchor, applied instantly (the window keeps scrolling normally while any editor is open — the panel is fixed to the viewport's left edge, its top tracking the banner's bottom edge until the banner scrolls away — and the panel scrolls internally); anchored h2s carry their own edit pens that open the editor scrolled to that section; a format bar offers Markdown helpers — bold/italic/code/link/table/image upload (always block-level on a fresh blank-separated line of its own — a cursor on a non-empty line, e.g. inside an existing image tag, inserts after that line, never into it; always with an empty `""` caption, cursor inside the quotes), toggling fences (` ``` ` code blocks and `::: aside` containers share the same machinery: clicked inside one they remove it and select the content, otherwise they wrap the selection or the cursor's line, keeping it selected), and `.left`/`.right`/`.wide`/`.margin` placement toggles plus `.small`/`.large`/`.huge` text-size toggles (brace attributes on the block at the cursor, mutually exclusive within each group; on `:::` containers a placement class replaces the container name instead — `::: aside``::: margin`), with Ctrl/Cmd-B/I/S bindings — for the hard-to-remember syntax. Edits content and title only, never the path.
- `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`, plus the page's card image (`Node.image`, inherited by the subtree): just an upload button and a ✕ clearing the node's own (back to inherit) — the label states which image is in use (none / set for this article, “used in /<path>/*” when it has children / mined from the article / inherited from …) and the card previews below show it. Below it, the site's own cards preview in both modes (small and large) with the real `.card` markup and styles from pagerite.css — theme variables included, they are the site's look — scaled down via font-size (the card internals are all em, so the layout proportions match real cards exactly); both render the effective card image (the node's own, else the image the server mines from the article, else the inherited one), and the description appears only in the small format, like the backend's `_card`. The previews double as the card-mode selector for the per-article `Node.large` override: clicking one forces that mode (thin solid outline), clicking the selected one returns to automatic; under automatic the mode auto currently resolves to gets a dashed marker (both outlines — selection never shifts the layout), approximated from image presence only (the server's dimension probe is not available in the panel).
- `BannerEditor.vue` — per-page banner HTML + banner design selector, previewed into `#page-banner`, plus the page's card image (`Node.image`, inherited by the subtree): upload and paste (pasteboard: an image uploads, an image URL is fetched and stored server-side via the `save` socket message) buttons, a site-icon button (saves `@favicon`, which resolves to the current site icon at render time, following favicon changes) and a ✕ clearing the node's own (back to inherit) — the label states which image is in use (none / set for this article, “used in /<path>/*” when it has children / the site icon / mined from the article / inherited from …) and the card previews below show it. Below it, the site's own cards preview in both modes (small and large) with the real `.card` markup and styles from pagerite.css — theme variables included, they are the site's look — scaled down via font-size (the card internals are all em, so the layout proportions match real cards exactly); both render the effective card image (the node's own, else the image the server mines from the article, else the inherited one), and the description appears only in the small format, like the backend's `_card`. The previews double as the card-mode selector for the per-article `Node.large` override: clicking one forces that mode (thin solid outline), clicking the selected one returns to automatic; under automatic the mode auto currently resolves to gets a dashed marker (both outlines — selection never shifts the layout), approximated from image presence only (the server's dimension probe is not available in the panel).
- `SiteEditor.vue` — site brand + optional custom brand HTML with image/video upload + theme selector + page-transition selector + font picker + favicon upload — clicking the preview tile picks a new one — + site-wide custom CSS, CSS injected into `<head id="pagerite-user">`.
- `StructureEditor.vue` — the vue-draggable structure tree with always-editable title/slug inputs per row and a per-row flag dropdown setting the page's primary language (`Node.language`, inherited by the subtree).
- `LocalizationEditor.vue` — the site-wide translation settings: target languages as a flag grid (toggles, grouped in geographic rows; see docs/localization.md), the refresh-all-translations button, and the translator service WebSocket URL(s) to connect `scripts/translator.py` to.
+73 -14
View File
@@ -67,12 +67,17 @@ const bannerDesignInherited = ref('')
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).
// The node's own setting ('@favicon' = the site icon, resolved live
// against the favicon ref), the effective image after inheritance ("" =
// none, already resolved server-side) 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 site icon (bare store hash, "" = none): the "@favicon" own setting
// resolves against it, live, in the previews.
const favicon = ref('')
// The image the server would mine from the article itself — the card
// previews fall back to it when the node has no image of its own, and it
// beats an inherited one (mirrors og:image).
@@ -82,6 +87,9 @@ const imageMined = ref('')
const hasChildren = ref(false)
// The block label states which image is currently in use.
const imageLabel = computed(() => {
if (image.value === '@favicon') {
return 'card image: the site icon (follows favicon changes)'
}
if (image.value) {
return hasChildren.value
? `card image: set for this article — used in /${path.value}/*`
@@ -98,15 +106,18 @@ const imageLabel = computed(() => {
// previews; empty shows placeholder bars / text instead.
const pageTitle = ref('')
const pageDesc = ref('')
// The image the Twitter cards preview with: the node's own card image,
// else the mined article image, else the inherited node image (what
// og:image would use). image/image_resolved are bare store hashes;
// image_mined is already a src path.
const cardImage = computed(() =>
image.value
? `/_f/${image.value}`
: imageMined.value || (imageResolved.value ? `/_f/${imageResolved.value}` : ''),
)
// The image the Twitter cards preview with: the node's own card image
// ("@favicon" resolves to the site icon), else the mined article image,
// else the inherited node image (what og:image would use).
// image/image_resolved are bare store hashes; image_mined is already a
// src path.
const cardImage = computed(() => {
// "@favicon" with no site icon configured falls through like no own
// image at all (mirrors the backend's resolution).
const own = image.value === '@favicon' ? favicon.value : image.value
if (own) return `/_f/${own}`
return imageMined.value || (imageResolved.value ? `/_f/${imageResolved.value}` : '')
})
// Card-mode override (Node.large, per-article, NOT inherited):
// null = automatic, false = small, true = large.
const large = ref(null)
@@ -148,13 +159,39 @@ async function uploadCardImage(ev) {
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 })
storeCardImage(file, file.name)
}
async function storeCardImage(blob, filename) {
const name = filename.replace(/[^\w.-]/g, '-')
const res = await apiFetch(`/_api/files/${encodeURIComponent(name)}`, { method: 'PUT', body: blob })
if (!res.ok) return
const { path: stored } = await res.json() // "/_f/<hash>[.ext]"
saveImage(stored.split('/').pop().split('.')[0])
}
async function pasteCardImage() {
// The pasteboard button (unlike pasting into a text editor, where the
// paste event carries files) must read the clipboard explicitly.
try {
for (const item of await navigator.clipboard.read()) {
const type = item.types.find((t) => t.startsWith('image/'))
if (type) {
const blob = await item.getType(type)
await storeCardImage(blob, `paste.${type.split('/')[1].replace('+xml', '')}`)
return
}
}
// No image on the pasteboard: a pasted URL goes as the setting
// itself — the server fetches and stores it (cross-origin URLs are
// CORS-blocked for the browser).
const text = (await navigator.clipboard.readText()).trim()
if (/^https?:\/\/\S+$/.test(text)) saveImage(text)
} catch {
// Clipboard read denied or empty: nothing to do.
}
}
// The inherit option names the design actually in effect and its source.
const inheritLabel = computed(() => {
if (bannerDesignFrom.value === null) {
@@ -330,6 +367,7 @@ function onMessage(ev) {
imageResolved.value = msg.image_resolved ?? ''
imageMined.value = msg.image_mined ?? ''
imageSource.value = msg.image_source ?? ''
favicon.value = msg.favicon ?? ''
hasChildren.value = msg.has_children ?? false
large.value = msg.large ?? null
pageTitle.value = msg.title ?? ''
@@ -455,6 +493,19 @@ onUnmounted(() => {
title="upload card image (og:image / card covers) — the subtree inherits it"
@click="imageInput.click()"
>🖼</button>
<button
type="button"
class="icon-btn"
title="paste a card image from the pasteboard (image or image URL)"
@click="pasteCardImage"
>📋</button>
<button
v-if="favicon"
type="button"
class="icon-btn favicon-btn"
title="use the site icon as the card image — follows favicon changes"
@click="saveImage('@favicon')"
><img :src="`/_f/${favicon}`" alt="site icon" /></button>
<input
ref="imageInput"
type="file"
@@ -615,6 +666,14 @@ onUnmounted(() => {
font-size: 1rem;
}
/* The "use site icon" button shows the icon itself. */
.favicon-btn img {
display: block;
width: 1rem;
height: 1rem;
object-fit: contain;
}
/* 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 {
+30 -7
View File
@@ -407,8 +407,8 @@ async def editor_ws(ws: WebSocket) -> None:
-> {"type": "doc", "path", "exists", "title", "markdown", "published",
"banner", "banner_design", "banner_from", "banner_design_from",
"banner_design_inherited", "description", "image", "image_resolved",
"image_mined", "image_source", "has_children", "large", "lang",
"primary_lang", "langs",
"image_mined", "image_source", "favicon", "has_children", "large",
"lang", "primary_lang", "langs",
"translate_langs"}
<- {"type": "render", "path", "markdown"}
-> {"type": "html", "path", "html"}
@@ -416,7 +416,8 @@ async def editor_ws(ws: WebSocket) -> None:
"banner"?, "banner_design"?, "image"?, "large"?, "move_from"?,
"lang"?, "base"?}
(absent fields keep their old values; move_from: rename/move a
page, subtree included)
page, subtree included; image: a store hash, "@favicon", "" to
inherit, or an http(s) URL the server fetches and stores)
-> {"type": "saved", "path"} | {"type": "error", "detail"}
With "lang" (a translation, not the primary language), open returns the
@@ -458,11 +459,13 @@ async def editor_ws(ws: WebSocket) -> None:
# original (docs/localization.md editor flow).
markdown = i18n.hybrid_markdown(data, node, path, lang)
title = i18n.title_map(data, lang).get(path) or title
# The node's card image: its own setting ("" = inherit),
# the effective one after inheritance ("" = none) and
# The node's card image: its own setting ("" = inherit,
# "@favicon" = the site icon), the effective one after
# inheritance ("" = none, resolved to a store name) and
# which node supplied an inherited one ("" = front page;
# "" also when own/none — mirrors banner_from).
img, img_source = views.card_image(data.menu, path)
img = views._resolve_image_name(data, img)
# The card preview's description and mined image,
# from the same rendered-article heuristics as the
# og:/twitter: meta (_description, _media).
@@ -503,6 +506,9 @@ async def editor_ws(ws: WebSocket) -> None:
# /<path>/*"): the subtree inherits it.
"has_children": bool(node.children) if node else False,
"image_resolved": img,
# The site icon ("" = none): the "@favicon" own
# setting previews/resolves against it.
"favicon": data.favicon,
# The image the og:/twitter: heuristics would mine
# from the article itself ("" = none): the previews
# show it when the node has no image of its own
@@ -656,9 +662,26 @@ async def editor_ws(ws: WebSocket) -> None:
image = msg.get("image")
if image is not None:
# Card-image setting (inherited by the subtree): a
# 12-hex content-addressed store name, "" = inherit.
# 12-hex content-addressed store name, "@favicon" =
# the site icon, "" = inherit, or an http(s) URL —
# pasted image links are fetched and stored
# server-side (cross-origin is CORS-blocked for the
# browser), the stored name becomes the setting.
image = str(image).strip()
if image and not re.fullmatch(r"[0-9a-f]{12}", image):
if image.startswith(("http://", "https://")):
from pagerite.files import fetch_image
try:
# Bare hash name, like an upload's setting.
image = (await fetch_image(image)).split(".")[0]
except HTTPException as e:
await ws.send_json(
{"type": "error", "detail": str(e.detail)}
)
continue
if image and image != "@favicon" and not re.fullmatch(
r"[0-9a-f]{12}", image
):
await ws.send_json(
{
"type": "error",
+3 -1
View File
@@ -93,7 +93,9 @@ class Node(msgspec.Struct, omit_defaults=True):
#: Content-addressed card image name (served at "/_f/{name}") for
#: og:image/twitter:image and card covers. "" inherits the nearest
#: ancestor's image, the front page last; unset everywhere falls back
#: to mining the rendered article.
#: to mining the rendered article (which beats an inherited image).
#: "@favicon" resolves to the site icon (Data.favicon) at render time,
#: following favicon changes.
image: str = ""
#: Card-mode override (site cards + twitter:card): None = pick
#: automatically from the card image's dimensions, False forces a
+30
View File
@@ -17,8 +17,10 @@ import mimetypes
import tempfile
from contextlib import suppress
from pathlib import Path
from urllib.parse import urlsplit
import blake3
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse, Response
from mediapreview import dispatch
@@ -213,6 +215,34 @@ def store_image(
return digest
#: Pasted-URL image fetches (fetch_image) refuse bodies over this size.
FETCH_MAXSIZE = 20 * 1024 * 1024
async def fetch_image(url: str) -> str:
"""Fetch an image URL and store it like an upload (store_image's
derivative pipeline), returning the stored file name.
Server-side because arbitrary cross-origin URLs are CORS-blocked for
the browser; used by the editor socket when a card-image setting
arrives as a URL (paste). 415 for non-images, 413 over FETCH_MAXSIZE,
502 when the fetch itself fails.
"""
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=15) as client:
r = await client.get(url)
r.raise_for_status()
except httpx.HTTPError as e:
raise HTTPException(502, f"image fetch failed: {e}") from e
ctype = r.headers.get("content-type", "").split(";")[0].strip().lower()
if not ctype.startswith("image/"):
raise HTTPException(415, "the URL is not an image")
if len(r.content) > FETCH_MAXSIZE:
raise HTTPException(413, "image too large")
ext = _ext(Path(urlsplit(url).path).name) or mimetypes.guess_extension(ctype) or ""
return await asyncio.to_thread(store_image, r.content, ext, derive=ext != ".gif")
@router.put("/_api/files/{name}")
async def upload_file(name: str, request: Request) -> dict[str, str]:
"""Store an upload (image, video...) in the content-addressed store.
+16 -5
View File
@@ -786,14 +786,23 @@ def banner_source(menu: dict[str, Node], path: str) -> str | None:
return None
def _resolve_image_name(data: Data, name: str) -> str:
"""The store name a card-image setting resolves to: the "@favicon"
sentinel follows the site icon (set in the site editor — "" when
unset); anything else is already a store name."""
return data.favicon if name == "@favicon" else name
def card_image(menu: dict[str, Node], path: str) -> tuple[str, str]:
"""The effective card image at ``path`` and which node supplied it.
Nearest ancestor with ``image`` set wins (the node itself first), the
front page — a top-level sibling of the chain — last. ("", "") when no
node sets one: rendering falls back to mining the article HTML. The
source path ("" = front page) feeds the editor banner panel's inherit
label.
node sets one: rendering falls back to mining the article HTML (which
beats an inherited image — see _card/_social_meta). The returned name
is the raw setting (may be "@favicon"; resolve with
_resolve_image_name). The source path ("" = front page) feeds the
editor banner panel's inherit label.
"""
chain = resolve(menu, path) or []
segs = path.split("/")
@@ -1083,7 +1092,8 @@ def _card(
"""
image = html = ""
if node.image:
image = f"/_f/{node.image}"
if name := _resolve_image_name(data, node.image):
image = f"/_f/{name}"
if node.chunks and not image:
md = node_markdown(data, node) or ""
if lang and lang in node.langs:
@@ -1102,7 +1112,7 @@ def _card(
# The node's own setting was empty: an inherited image applies
# only when the article itself yielded none (card_image with no
# own image set resolves to the nearest ancestor's/front page's).
if name := card_image(menu, path)[0]:
if name := _resolve_image_name(data, card_image(menu, path)[0]):
image = f"/_f/{name}"
large = _card_large(node, image)
description = ""
@@ -1349,6 +1359,7 @@ def render_page(
# article (inside _social_meta), then the inherited one. card_image
# resolves both: its source equals the path iff the node itself set it.
img, img_from = card_image(menu, path)
img = _resolve_image_name(data, img)
social = _social_meta(
node,
path,