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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user