From 9a1ca3b1355c56de6e6007394fd9333257b2c585 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Fri, 14 Aug 2026 18:54:18 -0400 Subject: [PATCH] feat: block-index show icons option --- blocks/block-index/component.js | 128 +++++++++++++++++- blocks/block-tabs/component.js | 26 +--- blocks/shared/icons.js | 56 ++++++++ .../_assets/icons/ultraviolet-youtube.svg | 1 + 4 files changed, 181 insertions(+), 30 deletions(-) create mode 100644 blocks/shared/icons.js create mode 100644 frontend/public/_assets/icons/ultraviolet-youtube.svg diff --git a/blocks/block-index/component.js b/blocks/block-index/component.js index 2400c7c6d..2a39c182a 100644 --- a/blocks/block-index/component.js +++ b/blocks/block-index/component.js @@ -1,6 +1,30 @@ import { LitElement, html, css } from 'lit' +import { unsafeSVG } from 'lit/directives/unsafe-svg.js' +import { fetchIcon, iconImageUrl } from '../shared/icons.js' import { DarkMode } from '../shared/theme.js' +/** + * An attribute that means "off" when it says so. + * + * MDC writes every prop with a value, and Lit's own Boolean converter reads any string at all as + * true — `showIcons="false"` included. The picker never writes that one, since it leaves a prop out + * while it holds its default, but a page written by hand can say it and means it. + */ +const boolean = { + converter: { + fromAttribute: (value) => value !== null && value !== 'false', + toAttribute: (value) => (value ? 'true' : null) + } +} + +/** + * What to draw for a page carrying no icon of its own. + * + * The same one the app gives a new page (`DEFAULT_PAGE_ICON` in the page store), so that a listing + * mixing pages made in the editor with pages made through the API still lines up down the left. + */ +const DEFAULT_PAGE_ICON = 'mdi:file-document-outline' + /** * Block Index */ @@ -69,6 +93,14 @@ export class BlockIndexElement extends LitElement { hint: 'How many folders below the path to include. 0 is the folder itself.', default: 0 }, + { + name: 'showIcons', + type: 'boolean', + label: 'Show Icons', + hint: "Draw each page's icon to the left of its title.", + // -> Stated, so that a toggle switched on and then off again writes nothing into the page + default: false + }, { name: 'noResultMsg', type: 'string', @@ -140,23 +172,60 @@ export class BlockIndexElement extends LitElement { background-image: linear-gradient(to bottom,#1e232a, #161b22); border-left-color: var(--q-primary); } + /* + -> The row runs across rather than down, so an icon can sit beside the writing rather than + above it. The title and its description stack inside .text, which is the column the + anchor itself used to be. + */ li a { display: flex; color: var(--q-primary); - padding: 1rem; + /* -> Vertical only: the horizontal inset is what the arrow's own offset is set against */ + padding: 0.75rem 1rem; text-decoration: none; flex: 1; + flex-direction: row; + align-items: center; + gap: 14px; + position: relative; + } + .text { + display: flex; flex-direction: column; justify-content: center; - position: relative; + /* -> The row less the icon. min-width is what lets a long title wrap inside the card + rather than pushing the row wider than it. */ + flex: 1; + min-width: 0; } - li a > span { + .text span { display: block; color: #666; font-size: .8em; font-weight: normal; pointer-events: none; } + + /* + The page's own icon. Sized in em so it keeps its place beside writing at whatever size the + article is set in, and left to take the anchor's colour: an Iconify SVG paints with + currentColor, which is the whole reason it is inlined rather than pointed at with an an . + */ + /* + -> The width is on the slot as well as on the drawing, so a row whose icon could not be had + keeps its place in the column rather than sliding its writing left of every other row's. + */ + .icon { + display: flex; + align-items: center; + flex: none; + width: 1.75em; + } + .icon svg, + .icon img { + width: 1.75em; + height: 1.75em; + } li a > svg { width: 32px; position: absolute; @@ -227,6 +296,12 @@ export class BlockIndexElement extends LitElement { */ noResultMsg: { type: String }, + /** + * Whether each page's icon is drawn beside its title + * @type {boolean} + */ + showIcons: boolean, + // Internal Properties _loading: { state: true }, _pages: { state: true } @@ -244,6 +319,7 @@ export class BlockIndexElement extends LitElement { this.orderByDirection = 'asc' this.depth = 0 this.noResultMsg = 'No pages matching your query.' + this.showIcons = false // -> Puts `dark` on this element for the styles above to key off this._darkMode = new DarkMode(this) } @@ -265,12 +341,45 @@ export class BlockIndexElement extends LitElement { } }).json() this._pages = pages.map((p) => ({ ...p, href: `/${p.path}` })) + if (this.showIcons) { + await this._loadIcons() + } } catch (err) { console.warn(err) } this._loading = false } + /** + * Fetch the icons the listing is about to draw. + * + * All of them at once rather than one after another, since the shared cache collapses the repeats: + * a listing of pages that never had an icon chosen for them is one request for the default, however + * many rows there are. An `img:` icon is a file to point at and needs nothing fetched. + * + * Failures are already an empty string, so a row whose icon could not be had is a row without one. + */ + async _loadIcons() { + await Promise.all( + this._pages.map(async (page) => { + const reference = page.icon || DEFAULT_PAGE_ICON + if (!iconImageUrl(reference)) { + page.svg = await fetchIcon(reference) + } + }) + ) + // -> The pages were mutated rather than replaced, which Lit has no way of noticing on its own + this.requestUpdate() + } + + /** One page's icon: an inlined SVG, or an `` for a reference that names a file. */ + _icon(page) { + const image = iconImageUrl(page.icon || DEFAULT_PAGE_ICON) + return html` + ${image ? html`` : page.svg ? unsafeSVG(page.svg) : null} + ` + } + render() { return this._pages.length > 0 || this._loading ? html` @@ -279,7 +388,10 @@ export class BlockIndexElement extends LitElement { (p) => html`
  • - ${p.title} ${p.description ? html`${p.description}` : null} + ${this.showIcons ? this._icon(p) : null} +
    + ${p.title} ${p.description ? html`${p.description}` : null} +
    ${this.noResultMsg} ` } + /* + -> `currentTarget` is the anchor the handler is bound to; `target` is whatever was clicked, which + is the anchor only for a click that landed on the title. The rest of the row got there by + being marked `pointer-events: none`, one declaration at a time -- an icon is one more thing + inside the anchor, and asking the element it was bound to is what makes that unnecessary. + */ _navigate(e) { e.preventDefault() - WIKI_ROUTER.push(e.target.getAttribute('href')) + WIKI_ROUTER.push(e.currentTarget.getAttribute('href')) } // createRenderRoot() { diff --git a/blocks/block-tabs/component.js b/blocks/block-tabs/component.js index 71a4b358c..2df493d0f 100644 --- a/blocks/block-tabs/component.js +++ b/blocks/block-tabs/component.js @@ -1,5 +1,6 @@ import { LitElement, html, css } from 'lit' import { unsafeSVG } from 'lit/directives/unsafe-svg.js' +import { fetchIcon } from '../shared/icons.js' import { DarkMode } from '../shared/theme.js' /** @@ -11,31 +12,6 @@ import { DarkMode } from '../shared/theme.js' */ const REVEAL_EVENT = 'block-reveal' -/** Icons already fetched, by `prefix:name`, so a page of tabs asks for each one once. */ -const iconCache = new Map() - -/** - * Fetch an icon as inline SVG. - * - * Inline rather than an `` so the drawing takes the colour of the tab it sits in — Iconify's - * SVGs paint with `currentColor`, which an image cannot see. The instance serves them from its own - * `/_icons`, cached hard, so this is a local request. - */ -async function fetchIcon(reference) { - if (iconCache.has(reference)) { - return iconCache.get(reference) - } - const [prefix, name] = reference.split(':') - if (!prefix || !name) { - return '' - } - const promise = fetch(`/_icons/${encodeURIComponent(prefix)}/${encodeURIComponent(name)}.svg`) - .then((resp) => (resp.ok ? resp.text() : '')) - .catch(() => '') - iconCache.set(reference, promise) - return promise -} - /** * Block Tabs */ diff --git a/blocks/shared/icons.js b/blocks/shared/icons.js new file mode 100644 index 000000000..4d044e74f --- /dev/null +++ b/blocks/shared/icons.js @@ -0,0 +1,56 @@ +/** + * Icons, for blocks. + * + * A block draws an icon from the same reference the rest of the app uses — `mdi:account-edit` — and + * gets it from this instance's own `/_icons`, which serves the part of the Iconify API protocol the + * frontend speaks. Nothing here reaches Iconify itself: the server is what decides whether an icon + * can be had, and an instance that is offline still answers for every icon it has been asked for + * before. + * + * Shared because more than one block needs it, and one cache across all of them means a page whose + * every row carries the same icon asks for it once. + */ + +/** Icons already fetched, by `prefix:name`. Holds the promise, so concurrent callers share a request. */ +const iconCache = new Map() + +/** + * Fetch an icon as inline SVG. + * + * Inline rather than an `` so the drawing takes the colour of whatever it sits in — Iconify's + * SVGs paint with `currentColor`, which an image cannot see. The instance serves them from its own + * `/_icons`, cached hard, so this is a local request. + * + * An empty string for anything that is not a `prefix:name` reference, an icon the server will not + * serve, or a request that failed: a missing icon is a row without one, not a row that breaks. + * + * @param {string} reference An Iconify reference, e.g. `mdi:home`. + * @returns {Promise} The SVG markup, or an empty string. + */ +export async function fetchIcon(reference) { + if (iconCache.has(reference)) { + return iconCache.get(reference) + } + const [prefix, name] = reference.split(':') + if (!prefix || !name) { + return '' + } + const promise = fetch(`/_icons/${encodeURIComponent(prefix)}/${encodeURIComponent(name)}.svg`) + .then((resp) => (resp.ok ? resp.text() : '')) + .catch(() => '') + iconCache.set(reference, promise) + return promise +} + +/** + * The address an `img:` reference points at, or null for one that is not an image. + * + * The icon picker's other tab hands back `img:/_assets/icons/…`, which is a file to point an `` + * at rather than an icon to resolve — so it is the caller's to draw, and its colour is its own. + * + * @param {string} reference + * @returns {string|null} + */ +export function iconImageUrl(reference) { + return reference.startsWith('img:') ? reference.slice(4) : null +} diff --git a/frontend/public/_assets/icons/ultraviolet-youtube.svg b/frontend/public/_assets/icons/ultraviolet-youtube.svg new file mode 100644 index 000000000..abbce778e --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-youtube.svg @@ -0,0 +1 @@ + \ No newline at end of file