diff --git a/blocks/block-gallery/component.js b/blocks/block-gallery/component.js index 284b2291a..b99e32b90 100644 --- a/blocks/block-gallery/component.js +++ b/blocks/block-gallery/component.js @@ -1,16 +1,8 @@ import { LitElement, html, css } from 'lit' +import { resolveFilePath } from '../shared/files.js' import { DarkMode } from '../shared/theme.js' -/** Where an uploaded file is served from, and so what a bare path in the body is taken to mean. */ -const FILES_PREFIX = '/_files/' - -/** - * An address that already says where it points: a full URL, a protocol-relative one, a data URI — - * or one of the wiki's own `/_` routes, `/_files/` among them. - */ -const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/_)/i - /** * An attribute that means "off" when it says so. * @@ -33,24 +25,6 @@ const ICONS = { 'M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z' } -/** - * The address a line of the body points at. - * - * Anything that names its own location is left exactly as written. Everything else is a path into the - * file manager, which is where the images on a wiki page live — so `photos/summer.jpg` and - * `/photos/summer.jpg` both mean `/_files/photos/summer.jpg`, and an author can paste the path the - * file manager shows without having to remember the prefix. Wiki routes are spared that: they all - * start with `/_`, and `/_files/` is one of them, so a path already carrying the prefix is not given - * a second one. - */ -function resolveSource(value) { - const address = value.trim() - if (ABSOLUTE.test(address)) { - return address - } - return FILES_PREFIX + address.replace(/^\/+/, '') -} - /** * The box the block actually scrolls in. * @@ -416,10 +390,10 @@ https://example.com/photo-2.jpg` firstUpdated() { const fence = this.querySelector('pre') const source = ((fence ?? this).textContent ?? '').trim() - const found = source.split(/\s+/).filter(Boolean).map(resolveSource) + const found = source.split(/\s+/).filter(Boolean).map(resolveFilePath) if (!fence) { for (const image of this.querySelectorAll('img')) { - found.push(resolveSource(image.getAttribute('src') ?? '')) + found.push(resolveFilePath(image.getAttribute('src') ?? '')) } } // -> An address written once and drawn twice -- as a link and as the image it points at -- is one diff --git a/blocks/block-index/component.js b/blocks/block-index/component.js index 1d3e5aa88..ce8a09dc7 100644 --- a/blocks/block-index/component.js +++ b/blocks/block-index/component.js @@ -210,8 +210,13 @@ export class BlockIndexElement extends LitElement { li a { display: flex; color: var(--q-primary); - /* -> Vertical only: the horizontal inset is what the arrow's own offset is set against */ - padding: 0.75rem 1rem; + /* + -> The right inset clears the arrow, which is positioned out of the flow and so takes no + room in the row on its own: its 16px offset, its 32px width, and the same 14px gap the + icon keeps on the left. Without it a long title or description runs under the arrow + instead of wrapping before it. + */ + padding: 0.75rem calc(16px + 32px + 14px) 0.75rem 1rem; text-decoration: none; flex: 1; flex-direction: row; diff --git a/blocks/block-links-grid/component.js b/blocks/block-links-grid/component.js new file mode 100644 index 000000000..56d47126b --- /dev/null +++ b/blocks/block-links-grid/component.js @@ -0,0 +1,567 @@ +import { LitElement, html, css, nothing } from 'lit' +import { styleMap } from 'lit/directives/style-map.js' +import { unsafeSVG } from 'lit/directives/unsafe-svg.js' +import { load as parseYaml } from 'js-yaml' +import { resolveFilePath } from '../shared/files.js' +import { fetchIcon, iconImageUrl } from '../shared/icons.js' +import { DarkMode } from '../shared/theme.js' + +/** + * The accent a card's `color` may name: Tailwind's palette at shade 500, copied from + * `tailwindcss/theme.css`. + * + * Copied rather than read off `--color-*-500`, because Tailwind only emits the theme variables the + * app's own stylesheet uses — most of these are not declared on any page, and a name that resolved + * on one build and not the next is worse than a list that is fixed. + */ +const COLORS = { + red: 'oklch(63.7% 0.237 25.331)', + orange: 'oklch(70.5% 0.213 47.604)', + amber: 'oklch(76.9% 0.188 70.08)', + yellow: 'oklch(79.5% 0.184 86.047)', + lime: 'oklch(76.8% 0.233 130.85)', + green: 'oklch(72.3% 0.219 149.579)', + emerald: 'oklch(69.6% 0.17 162.48)', + teal: 'oklch(70.4% 0.14 182.503)', + cyan: 'oklch(71.5% 0.143 215.221)', + sky: 'oklch(68.5% 0.169 237.323)', + blue: 'oklch(62.3% 0.214 259.815)', + indigo: 'oklch(58.5% 0.233 277.117)', + violet: 'oklch(60.6% 0.25 292.717)', + purple: 'oklch(62.7% 0.265 303.9)', + fuchsia: 'oklch(66.7% 0.295 322.15)', + pink: 'oklch(65.6% 0.241 354.308)', + rose: 'oklch(64.5% 0.246 16.439)', + slate: 'oklch(55.4% 0.046 257.417)', + gray: 'oklch(55.1% 0.027 264.364)', + zinc: 'oklch(55.2% 0.016 285.938)', + neutral: 'oklch(55.6% 0 none)', + stone: 'oklch(55.3% 0.013 58.071)', + mauve: 'oklch(54.2% 0.034 322.5)', + olive: 'oklch(58% 0.031 107.3)', + mist: 'oklch(56% 0.021 213.5)', + taupe: 'oklch(54.7% 0.021 43.1)' +} + +/** + * The schemes a card may link to. + * + * A closed list, because the body is text that anybody who may edit the page can write, and an href + * is somewhere a script can hide: `javascript:` on a card would be a way round `write:scripts`. The + * scheme is read off the parsed URL rather than off the text, since the URL parser drops tabs and + * newlines from the middle of one — `java script:` is `javascript:` by the time a browser follows it. + */ +const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']) + +/** + * What a card's `url` becomes as an href, or null for one it may not link to. + * + * Relative addresses are resolved against the page the block is on, as any link in it would be. + */ +function hrefOf(value) { + const text = String(value ?? '').trim() + if (!text) { + return null + } + let url + try { + url = new URL(text, globalThis.location?.href) + } catch { + return null + } + return SAFE_PROTOCOLS.has(url.protocol) ? text : null +} + +/** + * Whether an href is a page of this wiki, and so the router's to open rather than the browser's. + * + * The wiki's own `/_` routes are server endpoints — a file under `/_files/` is a download, not a page + * — so those load for real. + */ +function isWikiPage(href) { + return href.startsWith('/') && !href.startsWith('//') && !href.startsWith('/_') +} + +/** A YAML value as text: an empty string for one left out, and a number or a boolean spelled out. */ +function textOf(value) { + return value === undefined || value === null ? '' : String(value) +} + +/** A card's `tags`: a list, or a single tag written as a string. Empty entries are dropped. */ +function tagsOf(value) { + const list = Array.isArray(value) ? value : [value] + return list.map((tag) => textOf(tag).trim()).filter(Boolean) +} + +/** + * Block Links Grid + * + * A grid of cards, each one a link, listed as YAML in the block's body: + * + * ::block-links-grid + * ```yaml + * - title: Page A + * url: /page-a + * ``` + * :: + */ +export class BlockLinksGridElement extends LitElement { + /** + * Metadata for the admin area and the editor's block picker. Collected at build time into + * `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be + * plain literals. See `props` in `block-index` for what the picker does with that list. + */ + static definition = { + block: 'links-grid', + name: 'Links Grid', + description: 'A custom grid of cards, each linking to a page or an external URL.', + icon: 'grid', + template: `\`\`\`yaml +- title: Getting Started + url: /getting-started + description: Everything you need on your first day. + icon: 'mdi:rocket-launch-outline' + color: blue +- title: Guides + url: /guides + description: Step-by-step instructions for common tasks. + icon: 'mdi:book-open-variant' + color: amber + tags: + - How-to + - Reference +\`\`\``, + /* + The body is a source an author types, edited as one: YAML on the left, this block on the right. + As for block-infobox, it is also the only way to edit the block in the Visual editor, which + parses a block whose body is a single fence as an atom. + */ + contentEditor: 'code', + props: [ + { + name: 'minWidth', + type: 'string', + label: 'Min Card Width', + hint: 'Narrowest a card may be, as a CSS length. The grid fits as many as the width allows.', + default: '300px' + }, + { + name: 'maxWidth', + type: 'string', + label: 'Max Card Width', + hint: 'Widest a card may be, as a CSS length. 1fr shares the leftover space between cards.', + default: '1fr' + }, + { + name: 'maxHeight', + type: 'string', + label: 'Max Card Height', + hint: 'Tallest a card may be, as a CSS length. A picture is cropped shorter to fit; the writing under it is not. Empty means no limit.' + } + ] + } + + static get styles() { + return css` + :host { + display: block; + } + + /* + The gap below the block lives here rather than on :host, which the app's margin reset in the + page would beat. See block-index. + + The columns themselves come from the props, as an inline style on this element. + */ + ul { + display: grid; + gap: 0.75rem; + padding: 0; + margin: 0 0 16px; + list-style: none; + } + + /* + Drawn as block-index draws a row: the same ground, the same hairline right and bottom, the + same shadow. Clipped so a picture at the top takes the card's rounded corners. + */ + /* -> The cap comes from the maxHeight prop, set on the list by render() */ + li { + display: flex; + max-height: var(--card-max-height, none); + overflow: hidden; + background-color: #fafafa; + background-image: linear-gradient(to bottom, #fff, #fafafa); + border: 1px solid rgba(0, 0, 0, 0.05); + border-radius: 5px; + box-shadow: 0 3px 8px 0 rgba(116, 129, 141, 0.1); + transition: box-shadow 0.15s ease; + } + :host([dark]) li { + background-color: #222; + background-image: linear-gradient(to bottom, #161b22, #0d1117); + border-color: rgba(0, 0, 0, 0.5); + box-shadow: 0 3px 8px 0 rgba(0, 0, 0, 0.25); + } + /* + -> The width block-index gives its left border, in the colour the card asked for. Repeated + under :host([dark]), whose border-color above outranks a bare li.has-accent and would + otherwise paint the accent over in the dark theme's hairline. + */ + li.has-accent, + :host([dark]) li.has-accent { + border-left: 5px solid var(--card-accent); + } + li:hover { + box-shadow: 0 4px 14px 0 rgba(116, 129, 141, 0.25); + } + :host([dark]) li:hover { + background-image: linear-gradient(to bottom, #1e232a, #161b22); + box-shadow: 0 4px 14px 0 rgba(0, 0, 0, 0.45); + } + + /* -> The whole card is the link, so everything below is laid out inside the anchor */ + a { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + color: var(--q-primary); + text-decoration: none; + } + a:focus-visible { + outline: 2px solid var(--q-primary); + outline-offset: -2px; + border-radius: 5px; + } + + /* + Cropped to fill, at a fixed shape so a row of cards carrying pictures of different sizes + still lines up. On a card with nothing but the picture it grows to fill the card instead, + which is as tall as whatever else shares its row. + + The picture is what gives way to a card's max height, and the writing is not: it shrinks + (min-height: 0 is what lets an image with an aspect ratio go below the height that ratio + gives it) while .body holds its own height, so a capped card still shows its title. + */ + .cover { + display: block; + flex: 0 1 auto; + min-height: 0; + width: 100%; + aspect-ratio: 16 / 9; + object-fit: cover; + } + li.is-image-only .cover { + flex: 1 1 auto; + } + + /* + A grid rather than a row, for the sake of the tags: they sit under the description and line + up with its left edge, but the icon is centred on the title and description alone. So the + icon and the writing share the first row, and the tags take the second row of the writing's + column, which the icon does not span. A card without an icon has the one column. + + align-content centres the rows as a group when the card is stretched taller than they are, + which is what the row of a flex box used to do. + */ + .body { + display: grid; + flex: 1 0 auto; + grid-template-columns: minmax(0, 1fr); + align-items: center; + align-content: center; + column-gap: 14px; + padding: 0.75rem 1rem; + } + .body.has-icon { + grid-template-columns: auto minmax(0, 1fr); + } + .body > .text, + .body > .tags { + grid-column: -2; + } + + /* + Sized and spaced as block-index's, so the two blocks side by side read as one family. Drawn in + the card's accent when it has one, so the icon and the border beside it are the same colour, + and in the link's primary otherwise. An Iconify SVG paints with currentColor; an img: icon + keeps its own colours. + */ + .icon { + display: flex; + flex: none; + align-items: center; + width: 1.75em; + color: var(--card-accent, var(--q-primary)); + } + .icon svg, + .icon img { + width: 1.75em; + height: 1.75em; + } + + .text { + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + min-width: 0; + } + .title { + font-weight: 500; + overflow-wrap: anywhere; + } + /* -> The lightened brand shade on a dark card, as block-index does. See its note on .text */ + :host([dark]) .title { + color: var(--color-primary-light); + } + .description { + color: #666; + font-size: 0.8em; + overflow-wrap: anywhere; + } + :host([dark]) .description { + color: rgba(255, 255, 255, 0.6); + } + + /* + The card's accent, or primary on a card without one, as the icon is. The writing is the + accent darkened in the light theme and lightened in the dark one: a shade 500 on a pale wash + of itself is too faint to read for the lighter hues, amber and yellow above all. + */ + .tags { + --badge: var(--card-accent, var(--q-primary)); + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + } + .tags span { + padding: 1px 8px; + border: 1px solid color-mix(in oklch, var(--badge) 35%, transparent); + border-radius: 999px; + background-color: color-mix(in oklch, var(--badge) 12%, transparent); + color: color-mix(in oklch, var(--badge) 70%, black); + font-size: 0.7em; + font-weight: 500; + line-height: 1.6; + white-space: nowrap; + } + :host([dark]) .tags span { + background-color: color-mix(in oklch, var(--badge) 20%, transparent); + color: color-mix(in oklch, var(--badge) 60%, white); + } + + .error { + margin-bottom: 16px; + color: var(--q-negative); + border: 1px dashed color-mix(in srgb, currentColor 50%, transparent); + border-radius: 5px; + padding: 1rem; + } + ` + } + + static get properties() { + return { + /** + * Narrowest a card may be, as a CSS length + * @type {string} + */ + minWidth: { type: String }, + + /** + * Widest a card may be, as a CSS length or a fraction + * @type {string} + */ + maxWidth: { type: String }, + + /** + * Tallest a card may be, as a CSS length, or empty for no limit + * @type {string} + */ + maxHeight: { type: String }, + + // Internal Properties + _cards: { state: true }, + _error: { state: true } + } + } + + constructor() { + super() + this.minWidth = '300px' + this.maxWidth = '1fr' + this.maxHeight = '' + this._cards = [] + this._error = '' + // -> Puts `dark` on this element for the styles above to key off + this._darkMode = new DarkMode(this) + } + + /** + * Read the cards out of the block's body. + * + * A fenced code block is the way to write it: markdown reads a line opening with `-` as a list of + * its own and hands this the text with the indentation gone, which is no longer the YAML that was + * typed. The text of the body is tried anyway, so a single flat card written without a fence works. + */ + connectedCallback() { + super.connectedCallback() + const source = (this.querySelector('pre') ?? this).textContent ?? '' + if (!source.trim()) { + return + } + let parsed + try { + parsed = parseYaml(source) + } catch (err) { + this._error = `These cards could not be read: ${err.reason ?? err.message}. The list has to go inside a fenced code block.` + return + } + if (!Array.isArray(parsed)) { + this._error = 'A links grid is a list of cards, each one starting with "- url:".' + return + } + const cards = [] + const refused = [] + parsed.forEach((entry, index) => { + const href = entry && typeof entry === 'object' ? hrefOf(entry.url) : null + if (!href) { + refused.push(index + 1) + return + } + cards.push({ + href, + title: textOf(entry.title), + description: textOf(entry.description), + // -> A bare path is a file from the file manager, as it is in block-gallery + image: entry.image ? resolveFilePath(textOf(entry.image)) : '', + icon: textOf(entry.icon), + tags: tagsOf(entry.tags), + accent: COLORS[textOf(entry.color).trim().toLowerCase()] ?? null + }) + }) + // -> Named by position, since a card with no usable url may well have nothing else to go by + if (refused.length > 0) { + this._error = `${refused.length > 1 ? 'Cards' : 'Card'} ${refused.join(', ')} ${refused.length > 1 ? 'have' : 'has'} no url, or one that is not a page, a web address, an email address or a phone number.` + } + this._cards = cards + this._loadIcons() + } + + /** + * Fetch the icons the cards carry, all at once. The shared cache collapses repeats, and a failure + * is an empty string — a card whose icon could not be had is drawn without one. + */ + async _loadIcons() { + await Promise.all( + this._cards.map(async (card) => { + if (card.icon && !iconImageUrl(card.icon)) { + card.svg = await fetchIcon(card.icon) + } + }) + ) + // -> The cards were mutated rather than replaced, which Lit has no way of noticing on its own + this.requestUpdate() + } + + _icon(card) { + const image = iconImageUrl(card.icon) + return html` + ${image ? html`` : card.svg ? unsafeSVG(card.svg) : null} + ` + } + + /* + -> A card that is only a picture has no words for its link, so it is named by where it goes. One + with writing is named by that writing, which is what a screen reader then reads, description + included. + */ + _card(card) { + const hasText = card.title || card.description + const hasBody = hasText || card.icon || card.tags.length > 0 + const classes = [ + card.accent ? 'has-accent' : '', + card.image && !hasBody ? 'is-image-only' : '' + ].join(' ') + return html`
  • + + ${card.image ? html`` : null} + ${ + hasBody + ? html`
    + ${card.icon ? this._icon(card) : null} + ${ + hasText + ? html`
    + ${card.title ? html`${card.title}` : null} + ${ + card.description + ? html`${card.description}` + : null + } +
    ` + : null + } + ${ + card.tags.length > 0 + ? html`
    + ${card.tags.map((tag) => html`${tag}`)} +
    ` + : null + } +
    ` + : null + } +
    +
  • ` + } + + render() { + /* + min() on the narrow end, so a column never asks for more than the block has: a 300px minimum on + a 280px phone would otherwise push the card out past the edge of the page. + */ + const columns = `repeat(auto-fit, minmax(min(${this.minWidth || '300px'}, 100%), ${this.maxWidth || '1fr'}))` + return html` + ${this._error ? html`
    ${this._error}
    ` : null} + ${ + this._cards.length > 0 + ? html`` + : null + } + ` + } + + /** + * A page of this wiki opens through the router, as a link in the article would. Anything else — + * another site, a file, a click with a modifier asking for a new tab — is left to the browser. + */ + _navigate(e) { + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) { + return + } + const href = e.currentTarget.getAttribute('href') + if (!isWikiPage(href)) { + return + } + e.preventDefault() + WIKI_ROUTER.push(href) + } +} + +window.customElements.define('block-links-grid', BlockLinksGridElement) diff --git a/blocks/shared/files.js b/blocks/shared/files.js new file mode 100644 index 000000000..3e2d45a36 --- /dev/null +++ b/blocks/shared/files.js @@ -0,0 +1,36 @@ +/** + * File addresses, for blocks. + * + * Shared because more than one block takes a picture by its path, and an author who learned to write + * one in one block should not have to learn it again in the next. + */ + +/** Where an uploaded file is served from, and so what a bare path is taken to mean. */ +const FILES_PREFIX = '/_files/' + +/** + * An address that already says where it points: a full URL, a protocol-relative one, a data URI — + * or one of the wiki's own `/_` routes, `/_files/` among them. + */ +const ABSOLUTE = /^(?:[a-z][a-z0-9+.-]*:|\/\/|\/_)/i + +/** + * The address a path written into a block points at. + * + * Anything that names its own location is left exactly as written. Everything else is a path into the + * file manager, which is where the images on a wiki page live — so `photos/summer.jpg` and + * `/photos/summer.jpg` both mean `/_files/photos/summer.jpg`, and an author can paste the path the + * file manager shows without having to remember the prefix. Wiki routes are spared that: they all + * start with `/_`, and `/_files/` is one of them, so a path already carrying the prefix is not given + * a second one. + * + * @param {string} value + * @returns {string} + */ +export function resolveFilePath(value) { + const address = value.trim() + if (ABSOLUTE.test(address)) { + return address + } + return FILES_PREFIX + address.replace(/^\/+/, '') +} diff --git a/frontend/public/_assets/icons/ultraviolet-grid.svg b/frontend/public/_assets/icons/ultraviolet-grid.svg new file mode 100644 index 000000000..3f2278c5e --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-grid.svg @@ -0,0 +1 @@ + \ No newline at end of file