Files
pagerite/frontend/src/BannerEditor.vue
T
LeoVasanko 580ebac06c 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.
2026-09-23 06:31:06 +00:00

726 lines
23 KiB
Vue

<script setup>
// Banner editor tab: per-page banner HTML and banner design, previewed into
// 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'
import { keymap } from '@codemirror/view'
import { indentWithTab } from '@codemirror/commands'
import { html } from '@codemirror/lang-html'
import { cmHighlight, cmTheme } from './cmtheme'
import ConnNote from './ConnNote.vue'
import { reconnectPolicy, socketSlot, watchConnecting } from './reconnect'
import { dropPageCache, loadPlain, runScripts } from './swapdoc'
import { apiFetch, apiJson } from 'paskia'
const props = defineProps({
pagePath: { type: String, default: '' },
})
// close/path-change are wired by EditorShell; this tab never emits them.
defineEmits(['close', 'pathChange'])
const path = ref('')
const banner = ref('')
const saveError = ref('')
const fileInput = ref(null)
const imageInput = ref(null)
const bannerEl = ref(null)
let ws = null
let pendingSave = null
let reconnectTimer = null
let connectWatchdog = null
// Reconnection pacing lives in ./reconnect (shared with the other sockets).
const reconnects = reconnectPolicy()
let everConnected = false
// Connection state drives the note at the top (ConnNote), and locks input
// until the banner's document has arrived (typing before it would be
// clobbered by the doc accept).
const conn = ref('connecting') // connecting | open | waiting
const retryIn = ref(0)
const docReady = ref(false)
const editable = new Compartment()
const connNote = computed(() =>
conn.value === 'connecting' ? 'connecting to the server…'
: conn.value === 'waiting' ? `connection lost — reconnecting in ~${retryIn.value} s…`
: docReady.value ? '' : 'loading the banner…',
)
let view = null // CodeMirror for the banner HTML
let syncing = false // set while replacing the document programmatically
// Banner design selector options come from the backend via settings.
const theme = ref('')
const bannerDesign = ref(null)
const bannerDesignFrom = ref(null)
const bannerDesigns = ref([])
// Where an empty banner code field falls back to (null = nowhere: the
// banner is just the design artwork), shown as a caption under the field.
const bannerFrom = ref(null)
// The design that "inherit" resolves to and where it comes from
// (bannerDesignFrom: an ancestor path, "" = the front page, null = the
// active theme's default).
const bannerDesignInherited = ref('')
// One-shot callback run on the next save ack (set by onBannerDesignChange,
// 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 ('@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).
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 === '@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}/*`
: 'card image: set for this article'
}
if (imageMined.value) return 'card image: from the article'
if (imageResolved.value) {
const where = imageSource.value === '' ? 'the front page' : `/${imageSource.value}`
return `card image: inherited from ${where}`
}
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 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)
// 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
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) {
return `— (from ${theme.value || 'none'})`
}
const where = bannerDesignFrom.value === ''
? 'the front page'
: `/${bannerDesignFrom.value}`
return `— (${bannerDesignInherited.value || 'none'} from ${where})`
})
function normPath(p) {
return p.trim().replace(/^\/+|\/+$/g, '')
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg))
} else {
ensureConnected()
}
}
function ensureConnected() {
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
connect()
}
}
const timers = {}
function debounce(key, fn, ms = 600) {
clearTimeout(timers[key])
timers[key] = setTimeout(fn, ms)
}
function save() {
const msg = { type: 'save', path: normPath(path.value), banner: banner.value }
pendingSave = msg
send(msg)
}
function openPath(p) {
path.value = p
// Lock input until the doc arrives (typing would be clobbered by it).
docReady.value = false
view?.dispatch({ effects: editable.reconfigure(EditorView.editable.of(false)) })
send({ type: 'open', path: p })
}
watch(() => props.pagePath, (p) => { openPath(normPath(p)) })
function updateWindowTitle() {
document.title = `banner: /${path.value} 🖊️`
}
onActivated(() => {
updateWindowTitle()
if (banner.value.trim()) previewBanner()
})
// The shell stays mounted while hidden: when it is re-shown with this tab
// active, restore the window title and banner preview.
function onEditorShown() {
if (document.body.dataset.editorMode !== 'banner') return
updateWindowTitle()
if (banner.value.trim()) previewBanner()
}
async function loadSettings() {
try {
const s = await apiJson('/_api/settings')
theme.value = s.theme || ''
bannerDesigns.value = s.banner_designs || []
} catch { /* keep default */ }
}
// Re-render the page from the server (after banner design changes), then
// re-overlay the page's own banner code if one is being edited.
async function rerender() {
if (await loadPlain(path.value)) {
if (banner.value.trim()) previewBanner()
}
}
// --- Banner design ---------------------------------------------------------
function onBannerDesignChange() {
const msg = {
type: 'save',
path: normPath(path.value),
banner_design: bannerDesign.value,
}
pendingSave = msg
send(msg)
refreshOnSave = rerender
}
// --- Banner editing ------------------------------------------------------
function setDocument(text) {
syncing = true
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text } })
syncing = false
banner.value = text
}
function previewBanner() {
const el = document.getElementById('page-banner')
if (!el) return
if (banner.value.trim()) {
// Own banner code supplements the design: the inlined design artwork
// (marked with [data-design]) is detached while the author code is
// swapped in (so runScripts never re-runs the design's own scripts),
// then put back first — author code stays last so its styles win.
const artwork = [...el.querySelectorAll('[data-design]')]
for (const a of artwork) a.remove()
el.innerHTML = banner.value
runScripts(el)
el.prepend(...artwork)
} else {
// No banner code of its own: the region must show the inherited/design
// banner — re-render from the server (an empty write here would wipe it).
loadPlain(path.value)
}
}
function onBannerInput() {
previewBanner()
debounce('banner-html', save, 400)
}
function stripBannerMedia(html) {
// A banner has one piece of media: uploading replaces earlier img/video
// tags instead of stacking them. (Other HTML, e.g. canvas+script, stays.)
const doc = new DOMParser().parseFromString(html, 'text/html')
for (const el of doc.querySelectorAll('img, video')) el.remove()
return doc.body.innerHTML.trim()
}
async function uploadBannerMedia(file) {
// Banner media goes to the shared content store, like article images.
if (!file || !/^(image|video)\//.test(file.type)) 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()
const tag = file.type.startsWith('video/')
? `<video src="${stored}" autoplay muted loop playsinline></video>`
: `<img src="${stored}" alt="">`
const rest = stripBannerMedia(banner.value)
setDocument(rest ? `${tag}\n${rest}` : tag)
previewBanner()
save()
}
function onBannerPaste(ev) {
const file = [...(ev.clipboardData?.files || [])]
.find((f) => /^(image|video)\//.test(f.type))
if (file) {
ev.preventDefault()
uploadBannerMedia(file)
}
}
function onMessage(ev) {
const msg = JSON.parse(ev.data)
if (msg.type === 'doc' && msg.path === path.value) {
setDocument(msg.banner ?? '')
docReady.value = true
view.dispatch({ effects: editable.reconfigure(EditorView.editable.of(true)) })
bannerDesign.value = msg.banner_design ?? null
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 ?? ''
favicon.value = msg.favicon ?? ''
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 = ''
pendingSave = null
// Banner HTML/design changes affect the rendered page; invalidate prefetches.
dropPageCache()
refreshOnSave?.()
refreshOnSave = null
} else if (msg.type === 'error') {
saveError.value = '⚠️ changes could not be saved'
}
}
function onKeydown(ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 's') {
ev.preventDefault()
save()
}
}
function connect() {
clearTimeout(reconnectTimer)
conn.value = 'connecting'
if (ws) {
// Replacing a stale socket: detach its handlers so its close is silent.
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null
if (ws.readyState !== WebSocket.CLOSED) ws.close()
}
ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_api/ws/editor`,
)
ws.onmessage = onMessage
clearTimeout(connectWatchdog)
connectWatchdog = watchConnecting(ws, 'banner')
ws.onopen = () => {
conn.value = 'open'
reconnects.opened()
if (everConnected) {
if (pendingSave) send(pendingSave)
} else {
openPath(normPath(props.pagePath))
}
everConnected = true
}
ws.onclose = () => {
// The wait is the policy's: doubling backoff with jitter (./reconnect),
// reset only by a healthy connection — rapid retries trip the browser's
// WebSocket throttling (sockets stuck "pending" for minutes).
const wait = reconnects.closed()
retryIn.value = Math.max(1, Math.round(wait / 1000))
conn.value = 'waiting'
reconnectTimer = setTimeout(connect, wait)
}
}
onMounted(async () => {
// The first connection takes a staggered slot (see ./reconnect).
reconnectTimer = setTimeout(connect, socketSlot())
view = new EditorView({
state: EditorState.create({
doc: '',
extensions: [
basicSetup,
// Tab/Shift-Tab indent and dedent instead of moving focus.
keymap.of([indentWithTab]),
html(),
cmTheme,
cmHighlight,
EditorView.lineWrapping,
// Locked until the banner's document arrives (docReady/ConnNote).
editable.of(EditorView.editable.of(false)),
EditorView.updateListener.of((u) => {
if (u.docChanged && !syncing) {
banner.value = view.state.doc.toString()
onBannerInput()
}
}),
],
}),
parent: bannerEl.value,
})
addEventListener('keydown', onKeydown)
addEventListener('pagerite:editor-shown', onEditorShown)
await loadSettings()
})
onUnmounted(() => {
clearTimeout(reconnectTimer)
clearTimeout(connectWatchdog)
for (const t of Object.values(timers)) clearTimeout(t)
if (ws) {
ws.onclose = null // intentional close, no reconnect
ws.close()
}
view?.destroy()
removeEventListener('keydown', onKeydown)
removeEventListener('pagerite:editor-shown', onEditorShown)
})
</script>
<template>
<div class="banner-editor">
<div v-if="saveError">{{ saveError }}</div>
<ConnNote :text="connNote" />
<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>
<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"
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"
class="text-input design-select"
title="Banner design (artwork + its own styles)"
@change="onBannerDesignChange"
>
<option :value="null">{{ inheritLabel }}</option>
<option value="">none</option>
<option v-for="d in bannerDesigns" :key="d" :value="d">{{ d }}</option>
</select>
<button
type="button"
class="icon-btn"
title="upload banner image/video (replaces existing media) — pasting works too"
@click="fileInput.click()"
>🖼︎</button>
<input
ref="fileInput"
type="file"
accept="image/*,video/*"
hidden
@change="(ev) => { uploadBannerMedia(ev.target.files[0]); ev.target.value = '' }"
/>
</div>
<div v-if="bannerFrom !== null" class="note">
left empty, the banner code is inherited from /{{ bannerFrom }}
</div>
<div ref="bannerEl" class="banner-cm" />
</section>
</div>
</template>
<style scoped>
.banner-editor {
display: flex;
flex-direction: column;
}
.block {
display: flex;
flex-direction: column;
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;
gap: 0.6rem;
}
.note {
color: var(--muted);
font-size: 0.8rem;
}
.block-head .icon-btn {
padding: 0 0.2rem;
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 {
margin-left: auto;
}
/* The banner design selector stays compact; the upload button is pushed
right by its auto margin. */
.design-select {
flex: 0 1 auto;
width: auto;
font-size: 0.85rem;
}
.text-input {
flex: 1;
min-width: 4rem;
font: inherit;
font-size: 0.9rem;
padding: 0.2rem 0.5rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--line);
border-radius: 4px;
}
/* CodeMirror window for the banner HTML; fills the tab and scrolls
internally. */
.banner-cm {
flex: 1;
min-height: 0;
border: 1px solid var(--line);
border-radius: 4px;
overflow: hidden;
}
.banner-cm :deep(.cm-editor) {
height: 100%;
font-size: 0.85rem;
}
.banner-cm :deep(.cm-scroller) {
overflow: auto;
}
.banner-cm :deep(.cm-gutters) {
display: none;
}
</style>