Guides: write a procedure in ShopDB, not beside it
The knowledgebase plugin is a link registry - a description, a URL and some keywords pointing at a document held somewhere else. That leaves the documents themselves as HTML files on a share: no search, no history, no way to know whether the copy pinned to a noticeboard is current, and no way for a technician to tick off where they are. A guide is stored as an ordered list of typed blocks rather than a blob of HTML, because three of the four things this feature exists for depend on it. A tickable step needs a stable id to be remembered; dark mode needs semantic markup rather than pasted colours; and print needs to know where a step begins so it does not split one across a page. Free HTML gives none of that and lets pasted styling fight the theme. Inline text is a restricted Markdown subset - bold, italic, inline code, links, line breaks - escaped BEFORE formatting, so nothing an author pasted can reach the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the theme decides how it looks. That is what makes a guide written next year match one written today, and dark mode work without an author thinking about it. DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes something writes an immutable revision; and a separate pointer says which revision READERS get. Without that pointer, editing a live procedure changes it under a technician who is halfway through following it. Publishing promotes an existing revision rather than snapshotting, so a publish with unsaved edits cannot ship a version nobody reviewed. Restoring writes a new revision with old content, so the record of what people were told in between survives. Saves dedup on a content hash covering title, summary, tags and blocks, so an autosave that changed nothing writes no version - otherwise the history fills with identical entries and hides the three edits that mattered. Progress lives in the browser, not the database: shopfloor PCs share a login, so per-user ticks would show one technician's progress to the next person on that bay, which reads as "already done". Keyed by guide and version, because step 4 of version 3 is not step 4 of version 2. Five permissions, and the split between edit and publish is the point: a technician can write a procedure up while somebody else decides it is fit for the floor. create-without-edit works too - an author may edit their own guide until it is published, after which other people are following it.
This commit is contained in:
240
plugins/guides/frontend/components/GuideBlocks.vue
Normal file
240
plugins/guides/frontend/components/GuideBlocks.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<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>
|
||||
<!-- Copies the code ONLY: no caption, no prompt. A prompt glued to a
|
||||
copied command is how somebody ends up pasting "PS C:\>" into a
|
||||
terminal. -->
|
||||
<button type="button" class="gb-copy" @click="copy(block)">
|
||||
{{ copied === block.id ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<pre><code>{{ block.text }}</code></pre>
|
||||
</div>
|
||||
|
||||
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
||||
<img :src="block.url" :alt="block.alt || ''" loading="lazy">
|
||||
<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 } from 'vue'
|
||||
import { renderInline } from '../utils/inline.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 copied = ref('')
|
||||
|
||||
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 || '')
|
||||
}
|
||||
|
||||
async function copy (block) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.text || '')
|
||||
copied.value = block.id
|
||||
setTimeout(() => { copied.value = '' }, 2000)
|
||||
} catch (err) {
|
||||
// Clipboard needs a secure context. Select the text instead of failing
|
||||
// silently, so the tech can copy it by hand.
|
||||
const node = document.querySelector('.gb-code pre')
|
||||
if (node && window.getSelection) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(node)
|
||||
const selection = window.getSelection()
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
}
|
||||
}
|
||||
</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-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. */
|
||||
.gb-copy { display: none; }
|
||||
.gb-step, .gb-callout, .gb-code, .gb-figure { break-inside: avoid; }
|
||||
.gb-check input { /* keep the box, printed empty, so it can be ticked by hand */ }
|
||||
.gb-done { opacity: 1; text-decoration: none; }
|
||||
.gb-code pre { white-space: pre-wrap; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user