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
+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 {