mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: add block-links-grid content block
This commit is contained in:
5 files changed
+614
-31
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`<span class="icon">
|
||||
${image ? html`<img src="${image}" alt="" />` : card.svg ? unsafeSVG(card.svg) : null}
|
||||
</span>`
|
||||
}
|
||||
|
||||
/*
|
||||
-> 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`<li
|
||||
class="${classes}"
|
||||
style="${styleMap(card.accent ? { '--card-accent': card.accent } : {})}">
|
||||
<a
|
||||
href="${card.href}"
|
||||
aria-label="${hasBody ? nothing : card.href}"
|
||||
@click="${this._navigate}">
|
||||
${card.image ? html`<img class="cover" src="${card.image}" alt="" />` : null}
|
||||
${
|
||||
hasBody
|
||||
? html`<div class="body ${card.icon ? 'has-icon' : ''}">
|
||||
${card.icon ? this._icon(card) : null}
|
||||
${
|
||||
hasText
|
||||
? html`<div class="text">
|
||||
${card.title ? html`<span class="title">${card.title}</span>` : null}
|
||||
${
|
||||
card.description
|
||||
? html`<span class="description">${card.description}</span>`
|
||||
: null
|
||||
}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
${
|
||||
card.tags.length > 0
|
||||
? html`<div class="tags">
|
||||
${card.tags.map((tag) => html`<span>${tag}</span>`)}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
</div>`
|
||||
: null
|
||||
}
|
||||
</a>
|
||||
</li>`
|
||||
}
|
||||
|
||||
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`<div class="error">${this._error}</div>` : null}
|
||||
${
|
||||
this._cards.length > 0
|
||||
? html`<ul
|
||||
style="${styleMap({
|
||||
gridTemplateColumns: columns,
|
||||
// -> Left off entirely when empty, so the rule falls back to no limit
|
||||
'--card-max-height': this.maxHeight || undefined
|
||||
})}">
|
||||
${this._cards.map((card) => this._card(card))}
|
||||
</ul>`
|
||||
: 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)
|
||||
@@ -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(/^\/+/, '')
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="80px" height="80px"><path fill="#dff0fe" d="M6,35.5c-1.379,0-2.5-1.121-2.5-2.5V6c0-1.379,1.121-2.5,2.5-2.5h27c1.379,0,2.5,1.121,2.5,2.5v27 c0,1.379-1.121,2.5-2.5,2.5H6z"/><path fill="#4788c7" d="M33,4c1.103,0,2,0.897,2,2v27c0,1.103-0.897,2-2,2H6c-1.103,0-2-0.897-2-2V6c0-1.103,0.897-2,2-2 H33 M33,3H6C4.343,3,3,4.343,3,6v27c0,1.657,1.343,3,3,3h27c1.657,0,3-1.343,3-3V6C36,4.343,34.657,3,33,3L33,3z"/><path fill="#98ccfd" d="M9,13.5c-0.827,0-1.5-0.673-1.5-1.5V9c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H9z"/><path fill="#4788c7" d="M12,8c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1H9c-0.551,0-1-0.449-1-1V9c0-0.551,0.449-1,1-1H12 M12,7H9C7.895,7,7,7.895,7,9v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2V9C14,7.895,13.105,7,12,7L12,7z"/><path fill="#98ccfd" d="M18,13.5c-0.827,0-1.5-0.673-1.5-1.5V9c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H18z"/><path fill="#4788c7" d="M21,8c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1V9c0-0.551,0.449-1,1-1 H21 M21,7h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2V9C23,7.895,22.105,7,21,7L21,7z"/><path fill="#98ccfd" d="M27,13.5c-0.827,0-1.5-0.673-1.5-1.5V9c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H27z"/><path fill="#4788c7" d="M30,8c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1V9c0-0.551,0.449-1,1-1 H30 M30,7h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2V9C32,7.895,31.105,7,30,7L30,7z"/><path fill="#98ccfd" d="M9,22.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H9z"/><path fill="#4788c7" d="M12,17c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1H9c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H12 M12,16H9c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C14,16.895,13.105,16,12,16L12,16z"/><g><path fill="#98ccfd" d="M18,22.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H18z"/><path fill="#4788c7" d="M21,17c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H21 M21,16h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C23,16.895,22.105,16,21,16L21,16z"/></g><g><path fill="#98ccfd" d="M27,22.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H27z"/><path fill="#4788c7" d="M30,17c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H30 M30,16h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C32,16.895,31.105,16,30,16L30,16z"/></g><g><path fill="#98ccfd" d="M9,31.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H9z"/><path fill="#4788c7" d="M12,26c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1H9c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H12 M12,25H9c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C14,25.895,13.105,25,12,25L12,25z"/></g><g><path fill="#98ccfd" d="M18,31.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H18z"/><path fill="#4788c7" d="M21,26c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H21 M21,25h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C23,25.895,22.105,25,21,25L21,25z"/></g><g><path fill="#98ccfd" d="M27,31.5c-0.827,0-1.5-0.673-1.5-1.5v-3c0-0.827,0.673-1.5,1.5-1.5h3c0.827,0,1.5,0.673,1.5,1.5v3 c0,0.827-0.673,1.5-1.5,1.5H27z"/><path fill="#4788c7" d="M30,26c0.551,0,1,0.449,1,1v3c0,0.551-0.449,1-1,1h-3c-0.551,0-1-0.449-1-1v-3c0-0.551,0.449-1,1-1 H30 M30,25h-3c-1.105,0-2,0.895-2,2v3c0,1.105,0.895,2,2,2h3c1.105,0,2-0.895,2-2v-3C32,25.895,31.105,25,30,25L30,25z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
Reference in new issue
Block a user