Files
shopdb-flask/plugins/guides/frontend/components/GuideBlocks.vue
cproudlock 2331c03664
All checks were successful
CI / naming (push) Successful in 4s
CI / frontend (push) Successful in 12s
CI / migrations-mysql (push) Successful in 39s
CI / backend (push) Successful in 3m38s
Tick a guide's sections off as you work through it
Only checklist items could be ticked, which answers "what have I done" but not
the question a long procedure actually raises: where was I. Somebody looks away
to do the thing the guide asked for and looks back at a wall of text.

Every step and every heading now carries a checkbox while reading. Ticking one
strikes it through and fades it rather than hiding it, because somebody
scanning back up the page has to still be able to read what they did. The
number is struck through with it, but the step's own "Step 3" label is not -
that is the address of the thing, not part of what was done.

Headings count, not just steps. A guide converted from an HTML article often
has fifty headings and no steps at all, and those are exactly the guides long
enough to lose your place in. That also means the card's "N steps to tick"
stops reading as a broken feature on a guide that is entirely tickable.

Reading only, as the checklists already were: 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. Progress stays per browser and per version -
shopfloor PCs share a login, so one technician's ticks must not greet the next
person on that bay, and step 4 of version 3 is not step 4 of version 2.
2026-08-27 13:02:24 -04:00

323 lines
13 KiB
Vue

<template>
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
<template v-for="(block, index) in blocks" :key="block.id || index">
<!--
A heading and a step can be ticked off while reading, not just a
checklist item. On a long procedure the question is not "what have I
done" so much as "where was I" - somebody looks away to do the thing
the guide asked for, and looks back at a wall of text. A struck-through
section is where they were.
Reading only: in the editor a tick would be an edit.
-->
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
:class="{ 'gb-sectiondone': isTicked(block.id) }">
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
:checked="isTicked(block.id)"
:aria-label="'Mark this section done'"
@change="toggle(block.id, $event.target.checked)">
<span v-html="inline(block.text)"></span>
</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"
:class="{ 'gb-sectiondone': isTicked(block.id) }">
<label class="gb-stepnum">
<input v-if="interactive" type="checkbox" class="gb-sectiontick"
:checked="isTicked(block.id)"
:aria-label="'Mark step ' + stepNumber(index) + ' done'"
@change="toggle(block.id, $event.target.checked)">
Step {{ stepNumber(index) }}
</label>
<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); }
/* A ticked section is struck through and faded, not hidden: somebody scanning
back up the page has to be able to read what they already did. */
.gb-sectiondone { text-decoration: line-through; opacity: 0.55; }
.gb-sectiondone .gb-stepnum { text-decoration: none; }
.gb-sectiontick {
/* Touch-sized: a guide is read on a shopfloor PC, often with gloves on. */
width: 1.1rem;
height: 1.1rem;
margin-right: 0.5rem;
vertical-align: middle;
cursor: pointer;
}
.gb-heading .gb-sectiontick { text-decoration: none; }
.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(--text-light); }
.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>