Files
shopdb-flask/plugins/guides/frontend/components/GuideBlocks.vue

287 lines
11 KiB
Vue

<template>
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
<template v-for="(block, index) in blocks" :key="block.id || index">
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
v-html="inline(block.text)"></h2>
<p v-else-if="block.type === 'paragraph'" class="gb-para"
v-html="inline(block.text)"></p>
<div v-else-if="block.type === 'step'" class="gb-step">
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
<div v-if="block.text" v-html="inline(block.text)"></div>
</div>
<!--
The reason this feature exists. Interactive ONLY when viewing: in the
editor a tick would be an edit, and an author clicking around their own
draft would quietly change what the next reader sees as "done".
-->
<ul v-else-if="block.type === 'checklist'" class="gb-checklist">
<li v-for="item in itemsOf(block)" :key="item.id" class="gb-check">
<label>
<input type="checkbox"
:checked="isTicked(item.id)"
:disabled="!interactive"
@change="toggle(item.id, $event.target.checked)">
<span :class="{ 'gb-done': interactive && isTicked(item.id) }"
v-html="inline(item.text)"></span>
</label>
</li>
</ul>
<ul v-else-if="block.type === 'bullets'" class="gb-list">
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
</ul>
<ol v-else-if="block.type === 'numbers'" class="gb-list">
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
</ol>
<div v-else-if="block.type === 'callout'"
class="gb-callout" :class="'gb-' + (block.variant || 'info')">
<strong v-if="block.title">{{ block.title }}</strong>
<div v-html="inline(block.text)"></div>
</div>
<div v-else-if="block.type === 'code'" class="gb-code">
<div class="gb-codehead">
<span class="gb-lang">{{ block.language || 'text' }}</span>
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
<!--
SELECTS the code; it does not write to the clipboard.
A page that programmatically copies a shell command is the exact
shape of a ClickFix attack - malware that plants a command in your
clipboard and talks you into pasting it into Run. uBlock Origin
blocks it, and it was right to: our button wrote PowerShell to the
clipboard on click.
Selecting the text and letting the person press Ctrl+C keeps the
convenience, moves the decision back to them, and gives security
tooling nothing to object to. It also selects the code ONLY - no
caption, no prompt, so nobody ends up pasting "PS C:\>" into a
terminal.
-->
<button type="button" class="gb-copy" @click="select(block)">
{{ selected === block.id ? 'Selected - press Ctrl+C' : 'Select' }}
</button>
</div>
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
</div>
<!--
The image is fetched by script, not by the browser.
An <img src> is a plain browser request: it carries no Authorization
header, and this app keeps its JWT in one. So a direct src against the
authenticated image route is a 401 and a broken-image icon for every
reader - which is exactly how this shipped. Fetching through the api
client sends the token, and it resolves against the client's baseURL, so
it also survives a subpath mount that a stored '/api/...' string does
not.
A failure says so in words. A guide whose screenshot is missing must not
look like a guide that never had one.
-->
<figure v-else-if="block.type === 'image'" class="gb-figure">
<img v-if="imageState(block).src" :src="imageState(block).src"
:alt="block.alt || ''">
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
</p>
<p v-else class="gb-imageloading">Loading image...</p>
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
</figure>
<div v-else-if="block.type === 'table'" class="gb-tablewrap">
<table class="gb-table">
<thead v-if="block.header && block.header.length">
<tr><th v-for="(cell, c) in block.header" :key="c" v-html="inline(cell)"></th></tr>
</thead>
<tbody>
<tr v-for="(row, r) in (block.rows || [])" :key="r">
<td v-for="(cell, c) in row" :key="c" v-html="inline(cell)"></td>
</tr>
</tbody>
</table>
</div>
<!-- An unknown type renders as nothing rather than as an error. A guide
written against a newer version must stay readable on an older one. -->
</template>
</div>
</template>
<script setup>
import { ref, computed, toRef } from 'vue'
import { renderInline } from '../utils/inline.js'
import { useGuideImages } from '../utils/images.js'
const props = defineProps({
blocks: { type: Array, default: () => [] },
// Live checkboxes belong to the viewer only.
interactive: { type: Boolean, default: false },
ticked: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['toggle'])
const selected = ref('')
const codeNodes = {}
function registerCode (id, el) { if (el) codeNodes[id] = el }
// Images are fetched by script, not by <img src>; see utils/images.js.
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
function inline (text) {
return renderInline(text)
}
// Items may be plain strings (bullets) or objects with ids (checklists). One
// shape here so the template does not branch on it.
function itemsOf (block) {
return (block.items || []).map((item, index) =>
typeof item === 'string'
? { id: (block.id || 'b') + '-' + index, text: item }
: item)
}
function isTicked (itemid) {
return Boolean(props.ticked[itemid])
}
function toggle (itemid, checked) {
if (!props.interactive) return
emit('toggle', itemid, checked)
}
// Steps number themselves by position among steps, so inserting a paragraph
// between two of them does not renumber anything by hand.
const stepIndex = computed(() => {
const map = {}
let n = 0
props.blocks.forEach((block, index) => {
if (block.type === 'step') { n += 1; map[index] = n }
})
return map
})
function stepNumber (index) {
return stepIndex.value[index] || 1
}
function anchorFor (block) {
return 'g-' + (block.id || '')
}
function select (block) {
const node = codeNodes[block.id]
if (!node || !window.getSelection) return
const range = document.createRange()
range.selectNodeContents(node)
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
selected.value = block.id
setTimeout(() => { selected.value = '' }, 4000)
}
</script>
<style scoped>
/* Every colour is a theme token. Nothing here is a literal, which is what makes
dark mode work with no dark-mode code. */
.gb-blocks { line-height: 1.65; }
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
.gb-step {
border-left: 3px solid var(--primary);
padding: 0.6rem 0 0.6rem 1rem;
margin: 0 0 1rem;
}
.gb-stepnum {
display: inline-block; font-size: 0.72rem; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--primary); font-weight: 600;
}
.gb-step h3 { margin: 0.2rem 0 0.4rem; font-size: 1.05rem; }
.gb-checklist { list-style: none; padding: 0; margin: 0 0 1rem; }
.gb-check { margin: 0.35rem 0; }
.gb-check label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; }
.gb-interactive .gb-check label:hover { color: var(--primary); }
.gb-check input { margin-top: 0.28rem; flex: none; width: 1rem; height: 1rem; }
.gb-done { opacity: 0.55; text-decoration: line-through; }
.gb-list { margin: 0 0 1rem 1.2rem; }
.gb-list li { margin: 0.25rem 0; }
.gb-callout {
border-left: 4px solid var(--info, var(--primary));
background: var(--surface-2, rgba(127, 127, 127, 0.08));
padding: 0.75rem 1rem; margin: 0 0 1rem; border-radius: 4px;
}
.gb-callout strong { display: block; margin-bottom: 0.25rem; }
.gb-warning { border-left-color: var(--warning, #d9822b); }
.gb-success { border-left-color: var(--success, #2e9e5b); }
.gb-code { margin: 0 0 1rem; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
.gb-codehead {
display: flex; align-items: center; gap: 0.6rem;
padding: 0.3rem 0.6rem; background: var(--surface-2, rgba(127,127,127,0.08));
border-bottom: 1px solid var(--border); font-size: 0.75rem;
}
.gb-lang { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-light); }
.gb-caption { color: var(--text-light); }
.gb-copy {
margin-left: auto; font-size: 0.72rem; padding: 0.15rem 0.5rem;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); border-radius: 3px; cursor: pointer;
}
.gb-copy:hover { border-color: var(--primary); color: var(--primary); }
.gb-code pre {
margin: 0; padding: 0.7rem 0.8rem; overflow-x: auto;
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.84rem;
/* WRAP rather than clip: these guides are full of long UNC paths, and a
line that scrolls out of sight is a command somebody copies half of. */
white-space: pre-wrap; word-break: break-word;
}
.gb-figure { margin: 0 0 1.2rem; }
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
.gb-imageloading, .gb-imagefailed {
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
border-radius: 4px; font-size: 0.9rem;
}
.gb-imageloading { color: var(--textmuted); }
.gb-imagefailed { color: var(--danger, #b3261e); }
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
.gb-table { border-collapse: collapse; width: 100%; }
.gb-table th, .gb-table td {
border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left;
vertical-align: top; font-size: 0.9rem;
}
.gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); }
@media print {
/* Paper has no hover, no clipboard and no theme. The light-mode token
overrides live in GuideView.vue, which can reach :root; this block is only
about what belongs on paper. */
.gb-copy { display: none; }
.gb-step, .gb-callout, .gb-code, .gb-figure, .gb-check { break-inside: avoid; }
/* Checkboxes print EMPTY on purpose, even where they are ticked on screen:
a printed guide is a worksheet somebody carries to the bay and ticks by
hand, and one person's progress is not part of the procedure. */
.gb-done { opacity: 1; text-decoration: none; }
.gb-check input { appearance: none; -webkit-appearance: none;
width: 0.85rem; height: 0.85rem; border: 1px solid #000;
border-radius: 2px; background: #fff; }
.gb-code pre { white-space: pre-wrap; }
.gb-stepnum { color: #000; }
}
</style>