Guides: procedures written and read inside ShopDB

This commit is contained in:
2026-08-26 12:42:24 -04:00
parent 271d558f8b
commit f221fba540
27 changed files with 3152 additions and 12 deletions

View File

@@ -0,0 +1,257 @@
<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>
<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 selected = ref('')
const codeNodes = {}
function registerCode (id, el) { if (el) codeNodes[id] = el }
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-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>

View File

@@ -0,0 +1,42 @@
/**
* Guides plugin routes.
*
* Three views, and the split between the last two is deliberate: reading a
* guide and editing one are different jobs with different rights, and the
* checkboxes are live in exactly one of them.
*
* meta.plugin = 'guides' so the ADR-009 router guard redirects when the backend
* plugin is disabled. Every route requires auth because every guides API route
* is behind JWT plus a guides.* permission.
*/
export default [
{
path: 'guides',
name: 'guides',
component: () => import('./views/GuidesList.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
},
{
path: 'guides/new',
name: 'guide-new',
component: () => import('./views/GuideEditor.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
},
{
path: 'guides/:slug',
name: 'guide-view',
component: () => import('./views/GuideView.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
},
{
path: 'guides/:guideid/edit',
name: 'guide-edit',
component: () => import('./views/GuideEditor.vue'),
meta: { requiresAuth: true, plugin: 'guides' }
}
]
// No full-screen routes: every view renders inside AppLayout. Declared so
// routes.gen.js can read `.toplevel` off the module without Rollup warning
// about a missing export.
export const toplevel = []

View File

@@ -0,0 +1,82 @@
/**
* Inline text rendering for guide blocks.
*
* A guide stores its text as a RESTRICTED Markdown subset - bold, italic,
* inline code, links, line breaks - and nothing else. This turns that into
* HTML.
*
* ESCAPE FIRST, THEN FORMAT. The order is the whole security model: every
* angle bracket and ampersand becomes an entity before any pattern is applied,
* so no markup an author pasted can survive into the DOM. There is no path here
* that emits a tag the code below did not write itself.
*
* The restriction is not only about safety. Pasted rich text carries fonts and
* hardcoded colours, which then fight the theme - black text on a dark
* background, and nobody can say why. Emphasis is semantic; the stylesheet
* decides how it looks. That is what makes dark mode work without an author
* thinking about it, and why a guide written next year still matches one
* written today.
*/
function escapeHtml (text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
// Only http(s) and mailto. A javascript: or data: URL in a link is the one way
// an author could otherwise smuggle script past the escaping above.
function safeHref (url) {
const trimmed = String(url || '').trim()
if (/^(https?:|mailto:)/i.test(trimmed)) return trimmed
if (trimmed.startsWith('/')) return trimmed // in-app link
return null
}
export function renderInline (text) {
let html = escapeHtml(text)
// Inline code first, so formatting characters inside `code` stay literal -
// a path like C:\**temp** should not turn half of itself bold.
const codeSpans = []
html = html.replace(/`([^`]+)`/g, (match, code) => {
codeSpans.push(code)
return '\u0000CODE' + (codeSpans.length - 1) + '\u0000'
})
// [label](url). The URL pattern allows ONE level of balanced parentheses so
// real links survive - Wikipedia-style /wiki/Foo_(bar) is the common case,
// and a naive [^)]+ silently truncates it into a broken link.
html = html.replace(/\[([^\]]+)\]\(((?:[^()\s]|\([^()\s]*\))+)\)/g, (match, label, url) => {
const href = safeHref(url)
if (!href) return label
const external = /^https?:/i.test(href)
return '<a href="' + escapeHtml(href) + '"' +
(external ? ' target="_blank" rel="noopener noreferrer"' : '') +
'>' + label + '</a>'
})
// **bold** then *italic*, longest marker first so **x** is not read as
// *(*x*)*.
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/(^|[^*])\*([^*]+)\*(?!\*)/g, '$1<em>$2</em>')
html = html.replace(/\n/g, '<br>')
html = html.replace(/\u0000CODE(\d+)\u0000/g, (match, index) =>
'<code>' + codeSpans[Number(index)] + '</code>')
return html
}
/** Plain text, for titles and anywhere markup would be noise. */
export function stripInline (text) {
return String(text == null ? '' : text)
.replace(/`([^`]+)`/g, '$1')
.replace(/\[([^\]]+)\]\([^)\s]+\)/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
}

View File

@@ -0,0 +1,145 @@
/**
* Proofing: the mistakes that matter in a PROCEDURE, not a document.
*
* A spellchecker catches "teh". None of these are spelling, and every one has
* been seen for real:
*
* - a smart quote in a command, so the command does not run
* - a checklist item with no text, so a step is silently missing
* - a heading with nothing under it, left when a draft was reorganised
* - an image with no alt text, invisible to anyone who cannot see it
* - a TODO that was never finished, published anyway
*
* Warnings, not blocks. A person publishing a guide at 14:40 with one empty
* bullet should be told, not stopped - the tool does not know that the bullet is
* deliberate, and a procedure that exists imperfectly beats one nobody wrote.
*/
// Characters an editor or a word processor substitutes silently. In prose they
// are harmless; in a command they are the difference between working and a
// syntax error nobody can explain.
const SMARTCHARS = [
// Written as escapes, not literals: the repo's style gate forbids non-ASCII in
// source, and a file whose job is detecting smart quotes cannot contain any.
// The escapes are also unambiguous in a diff, where a curly quote and a
// straight one look identical.
['\u2018', "'"], ['\u2019', "'"], // single quotes
['\u201C', '"'], ['\u201D', '"'], // double quotes
['\u2013', '-'], ['\u2014', '-'], // en dash, em dash
['\u00A0', ' '], // non-breaking space
]
const UNFINISHED = /\b(TODO|TBD|FIXME|XXX|\?\?\?)\b/i
function issue (level, blockid, message, fix) {
return { level, blockid, message, fix: fix || null }
}
export function proof (guide, blocks) {
const issues = []
if (!guide.title || !guide.title.trim()) {
issues.push(issue('error', null, 'This guide has no title'))
}
if (!guide.summary || !guide.summary.trim()) {
issues.push(issue('warn', null,
'No summary. It is what people read in the list and in search results.'))
}
if (!guide.tags || !guide.tags.trim()) {
issues.push(issue('warn', null, 'No tags, so this will be hard to find'))
}
if (!blocks.length) {
issues.push(issue('error', null, 'This guide is empty'))
return issues
}
blocks.forEach((block, index) => {
const where = 'block ' + (index + 1) + ' (' + block.type + ')'
if (block.type === 'code') {
// The important one. An autocorrected quote in a command looks right and
// does not run.
//
// Reported ONCE per block, however many offending characters it has: a
// command with an opening and a closing smart quote is one problem with
// one fix, and listing it twice makes the panel look like it is nagging.
const found = SMARTCHARS.filter(([bad]) => (block.text || '').includes(bad))
if (found.length) {
issues.push(issue('error', block.id,
where + ': contains ' + found.map(([, good]) => 'a curly or long ' +
(good === "'" ? 'quote' : good === '"' ? 'double quote' :
good === '-' ? 'dash' : 'space')).filter(
(v, i, a) => a.indexOf(v) === i).join(' and ') +
' where a command needs the plain character. Autocorrect does this, ' +
'and the command will not run.',
{ type: 'replaceAll', pairs: found }))
}
if (!(block.text || '').trim()) {
issues.push(issue('error', block.id, where + ' is empty'))
}
} else if (['heading', 'paragraph', 'callout'].includes(block.type)) {
if (!(block.text || '').trim() && !(block.title || '').trim()) {
issues.push(issue('warn', block.id, where + ' is empty'))
}
}
if (block.type === 'step' && !(block.title || '').trim()
&& !(block.text || '').trim()) {
issues.push(issue('warn', block.id, where + ' has no title and no text'))
}
if (['checklist', 'bullets', 'numbers'].includes(block.type)) {
const empties = (block.items || []).filter(
item => !String(typeof item === 'string' ? item : item.text || '').trim())
if (empties.length) {
issues.push(issue('warn', block.id,
where + ': ' + empties.length + ' empty item' +
(empties.length > 1 ? 's' : '') +
'. An empty step reads as a step somebody forgot to write.'))
}
}
if (block.type === 'image' && !(block.alt || '').trim()) {
issues.push(issue('warn', block.id,
where + ': no alt text. Anyone who cannot see the image gets nothing.'))
}
if (block.type === 'table' && !(block.rows || []).length) {
issues.push(issue('warn', block.id, where + ' has a header but no rows'))
}
const text = [block.text, block.title].filter(Boolean).join(' ')
if (UNFINISHED.test(text)) {
issues.push(issue('warn', block.id,
where + ': still contains ' + text.match(UNFINISHED)[0]))
}
})
// A heading immediately followed by another heading usually means the section
// under the first one was moved or never written.
blocks.forEach((block, index) => {
if (block.type !== 'heading') return
const next = blocks[index + 1]
if (!next || next.type === 'heading') {
issues.push(issue('warn', block.id,
'Heading "' + (block.text || '').slice(0, 40) + '" has nothing under it'))
}
})
return issues
}
/** Apply a suggested fix in place, for the ones where the answer is obvious. */
export function applyFix (block, fix) {
if (!fix) return false
const before = block.text || ''
let after = before
if (fix.type === 'replaceAll') {
for (const [bad, good] of fix.pairs) after = after.split(bad).join(good)
} else if (fix.type === 'replace') {
after = after.split(fix.from).join(fix.to)
}
if (after === before) return false
block.text = after
return true
}

View File

@@ -0,0 +1,74 @@
/**
* Starting points for a new guide.
*
* The blank page is the real barrier to somebody writing a procedure down. A
* skeleton with the right SHAPE - what you need first, the steps, how you know
* it worked, what to do when it does not - gets a half-decent guide out of
* somebody who would otherwise have written nothing.
*
* The shape is lifted from the KB articles the team already writes, so guides
* written here read like the ones written before them.
*/
function id (prefix, n) {
return prefix + n
}
export const templates = [
{
key: 'blank',
name: 'Blank',
description: 'Start with nothing',
blocks: () => [],
},
{
key: 'procedure',
name: 'Procedure',
description: 'Before you start, numbered steps, how to check it worked',
blocks: () => [
{ id: id('t', 1), type: 'paragraph',
text: 'What this covers, and when somebody would need it.' },
{ id: id('t', 2), type: 'callout', variant: 'warning', title: 'Before you start',
text: 'Anything that has to be true first, or that will bite halfway through.' },
{ id: id('t', 3), type: 'heading', text: 'Steps' },
{ id: id('t', 4), type: 'step', title: 'First step', text: '' },
{ id: id('t', 5), type: 'step', title: 'Second step', text: '' },
{ id: id('t', 6), type: 'heading', text: 'Check it worked' },
{ id: id('t', 7), type: 'checklist', items: [
{ id: id('ti', 1), text: '' },
{ id: id('ti', 2), text: '' },
] },
],
},
{
key: 'troubleshooting',
name: 'Troubleshooting',
description: 'Symptom, likely causes, what to check first',
blocks: () => [
{ id: id('t', 1), type: 'paragraph', text: 'The symptom, as somebody would describe it.' },
{ id: id('t', 2), type: 'heading', text: 'Check these first' },
{ id: id('t', 3), type: 'checklist', items: [
{ id: id('ti', 1), text: '' },
{ id: id('ti', 2), text: '' },
] },
{ id: id('t', 4), type: 'heading', text: 'Likely causes' },
{ id: id('t', 5), type: 'table',
header: ['What you see', 'What it usually means'], rows: [['', '']] },
{ id: id('t', 6), type: 'callout', variant: 'info',
text: 'The thing that looks like the cause and is not.' },
],
},
{
key: 'checklist',
name: 'Checklist',
description: 'A list to work through, nothing else',
blocks: () => [
{ id: id('t', 1), type: 'paragraph', text: 'When to use this list.' },
{ id: id('t', 2), type: 'checklist', items: [
{ id: id('ti', 1), text: '' },
{ id: id('ti', 2), text: '' },
{ id: id('ti', 3), text: '' },
] },
],
},
]

View File

@@ -0,0 +1,691 @@
<template>
<div class="ge" @paste="onPaste" @dragover.prevent @drop.prevent="onDrop">
<div class="page-header">
<h2>{{ isNew ? 'New guide' : 'Edit guide' }}</h2>
<div class="ge-actions">
<span class="ge-status">{{ status }}</span>
<button type="button" class="ge-btn" @click="showPreview = !showPreview">
{{ showPreview ? 'Hide preview' : 'Preview' }}
</button>
<button type="button" class="ge-btn" @click="save()" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<button v-if="!isNew && canpublish" type="button" class="ge-btn ge-primary"
@click="publish" :disabled="saving">
{{ guide.ispublished ? 'Publish changes' : 'Publish' }}
</button>
<button v-if="!isNew && canpublish && guide.ispublished" type="button"
class="ge-btn" @click="unpublish">Withdraw</button>
<router-link v-if="!isNew && guide.slug" class="ge-btn"
:to="{ name: 'guide-view', params: { slug: guide.slug } }">View</router-link>
</div>
</div>
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div class="ge-fields">
<input v-model="guide.title" type="text" class="ge-title"
placeholder="Guide title">
<input v-model="guide.summary" type="text"
placeholder="One line: what this covers and when somebody needs it">
<input v-model="guide.tags" type="text"
placeholder="Tags, comma separated - e.g. partmarker, backups">
</div>
<!-- The blank page is the barrier, so offer a shape rather than an empty
canvas. Only on a guide that has nothing in it yet. -->
<div v-if="!blocks.length" class="ge-templates">
<span>Start from:</span>
<button v-for="tpl in templates" :key="tpl.key" type="button"
class="ge-tpl" @click="useTemplate(tpl)">
<strong>{{ tpl.name }}</strong>
<small>{{ tpl.description }}</small>
</button>
</div>
<!--
Proofing: warnings, never a block. Somebody publishing at 14:40 with one
empty bullet should be told, not stopped - the tool does not know the
bullet is deliberate, and a procedure that exists imperfectly beats one
nobody wrote.
-->
<div v-if="issues.length" class="ge-proof">
<div class="ge-proofhead">
<strong>{{ errorCount }} to fix, {{ issues.length - errorCount }} worth a look</strong>
<button type="button" @click="showProof = !showProof">
{{ showProof ? 'Hide' : 'Show' }}
</button>
</div>
<ul v-if="showProof">
<li v-for="(item, i) in issues" :key="i" :class="'ge-' + item.level">
<button v-if="item.blockid" type="button" class="ge-jump"
@click="jumpTo(item.blockid)">go</button>
<span>{{ item.message }}</span>
<button v-if="item.fix" type="button" class="ge-fix"
@click="fix(item)">fix it</button>
</li>
</ul>
</div>
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
<div class="ge-editor">
<div v-for="(block, index) in blocks" :key="block.id"
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
@focusin="focused = block.id">
<div class="ge-blockbar">
<span class="ge-type">{{ block.type }}</span>
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="success">success</option>
</select>
<input v-if="block.type === 'code'" v-model="block.language"
class="ge-mini" placeholder="language">
<input v-if="block.type === 'code'" v-model="block.caption"
class="ge-mini" placeholder="caption">
<!-- Formatting acts on the selection in this block's textarea, so
nobody has to know the markup exists. -->
<div v-if="hasText(block) && block.type !== 'code'" class="ge-format">
<button type="button" title="Bold" @click="wrap(block, '**')"><b>B</b></button>
<button type="button" title="Italic" @click="wrap(block, '*')"><i>I</i></button>
<button type="button" title="Code" @click="wrap(block, '`')">&lt;&gt;</button>
<button type="button" title="Link" @click="makeLink(block)">link</button>
</div>
<div class="ge-blockbtns">
<button type="button" title="Move up" @click="move(index, -1)"
:disabled="index === 0">^</button>
<button type="button" title="Move down" @click="move(index, 1)"
:disabled="index === blocks.length - 1">v</button>
<button type="button" title="Duplicate" @click="duplicate(index)">copy</button>
<button type="button" class="ge-del" title="Remove"
@click="remove(index)">x</button>
</div>
</div>
<input v-if="block.type === 'step'" v-model="block.title" class="ge-steptitle"
placeholder="Step title">
<!--
Spellcheck and autocorrect are ON for prose and OFF for code.
A code block that autocorrects is a guide that tells somebody to run
a command which does not work: smart quotes replacing ", an
autocapitalised cmdlet, a "corrected" flag. The author will not
notice, because it still LOOKS right - and the person at the bay
gets a syntax error with no idea why.
-->
<textarea v-if="hasText(block)" v-model="block.text"
:ref="el => registerArea(block.id, el)"
class="ge-text" :class="{ 'ge-mono': block.type === 'code' }"
:rows="block.type === 'code' ? 6 : 3"
:spellcheck="block.type !== 'code'"
:autocorrect="block.type === 'code' ? 'off' : 'on'"
:autocapitalize="block.type === 'code' ? 'off' : 'sentences'"
:autocomplete="block.type === 'code' ? 'off' : undefined"
:placeholder="placeholderFor(block)"></textarea>
<div v-if="hasItems(block)" class="ge-items">
<div v-for="(item, i) in block.items" :key="item.id" class="ge-item">
<span class="ge-bullet">{{ block.type === 'numbers' ? (i + 1) + '.' : '-' }}</span>
<input v-model="item.text" :placeholder="'Item ' + (i + 1)"
:ref="el => registerItem(item.id, el)"
@keydown.enter.prevent="addItemAfter(block, i)"
@keydown.backspace="maybeRemoveItem(block, i, $event)">
<button type="button" @click="removeItem(block, i)">x</button>
</div>
<button type="button" class="ge-additem" @click="addItemAfter(block, block.items.length - 1)">
Add item
</button>
</div>
<div v-if="block.type === 'image'" class="ge-image">
<img v-if="block.url" :src="block.url" alt="">
<input v-model="block.caption" placeholder="Caption (optional)">
<input v-model="block.alt" placeholder="Alt text, for anyone who cannot see it">
</div>
<div v-if="block.type === 'table'" class="ge-tableedit">
<textarea v-model="block.raw" rows="4" @input="parseTable(block)"
spellcheck="false" autocorrect="off" autocapitalize="off"
placeholder="One row per line, cells separated by |&#10;Header | Header&#10;Cell | Cell"></textarea>
</div>
</div>
<div class="ge-add">
<span>Add:</span>
<button v-for="type in blocktypes" :key="type" type="button"
@click="add(type)">{{ type }}</button>
<label class="ge-upload">
image
<input type="file" accept="image/*" hidden @change="onFilePick">
</label>
</div>
<p class="ge-hint">
Paste or drag an image anywhere to drop it in below the block you are working on.
Text supports **bold**, *italic*, `code` and [links](https://example.com) -
or use the buttons.
</p>
</div>
<!-- The same renderer readers get, so what you see here is what they see.
A separate preview implementation would drift within a month. -->
<div v-if="showPreview" class="ge-preview">
<div class="ge-previewhead">Preview</div>
<h1 class="ge-previewtitle">{{ guide.title || 'Untitled guide' }}</h1>
<p v-if="guide.summary" class="ge-previewsummary">{{ guide.summary }}</p>
<GuideBlocks :blocks="blocks" :interactive="false" :ticked="{}" />
</div>
</div>
<div v-if="!isNew && revisions.length" class="ge-revisions">
<h3>Version history</h3>
<div v-for="rev in revisions" :key="rev.guiderevisionid" class="ge-rev">
<span class="ge-revnum">v{{ rev.revision }}</span>
<span class="ge-revwhen">{{ formatDate(rev.createdat) }}</span>
<span v-if="rev.ispublished" class="ge-revpub">published</span>
<span class="ge-revnote">{{ rev.note }}</span>
<button type="button" @click="restore(rev)">Restore</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import api from '@/api'
import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth'
import GuideBlocks from '../components/GuideBlocks.vue'
import { templates } from '../utils/templates.js'
import { proof, applyFix } from '../utils/proof.js'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets',
'numbers', 'callout', 'code', 'table']
const guide = reactive({ guideid: null, title: '', summary: '', tags: '',
slug: '', ispublished: false })
const blocks = ref([])
const revisions = ref([])
const saving = ref(false)
const message = ref('')
const error = ref('')
const status = ref('')
const showPreview = ref(true)
const focused = ref('')
const dirty = ref(false)
const showProof = ref(true)
// Recomputed as you type. Cheap - it is a walk over a few dozen blocks - and
// live feedback is the difference between proofing and a report nobody reads.
const issues = computed(() => proof(guide, blocks.value))
const errorCount = computed(() => issues.value.filter(i => i.level === 'error').length)
function jumpTo (blockid) {
focused.value = blockid
const node = textareas[blockid] || itemInputs[blockid]
if (node?.scrollIntoView) {
node.scrollIntoView({ behavior: 'smooth', block: 'center' })
node.focus?.()
}
}
function fix (item) {
const block = blocks.value.find(b => b.id === item.blockid)
if (block && applyFix(block, item.fix)) dirty.value = true
}
const isNew = computed(() => !guide.guideid)
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
const textareas = {}
const itemInputs = {}
function registerArea (id, el) { if (el) textareas[id] = el }
function registerItem (id, el) { if (el) itemInputs[id] = el }
function newid () {
if (window.crypto?.randomUUID) return window.crypto.randomUUID().slice(0, 8)
return Math.random().toString(36).slice(2, 10)
}
function hasText (block) {
return ['heading', 'paragraph', 'step', 'callout', 'code'].includes(block.type)
}
function hasItems (block) {
return ['checklist', 'bullets', 'numbers'].includes(block.type)
}
function placeholderFor (block) {
if (block.type === 'code') return 'The command, exactly as it should be run'
if (block.type === 'callout') return 'What somebody needs to know before carrying on'
if (block.type === 'heading') return 'Section heading'
return 'Text'
}
function useTemplate (tpl) {
blocks.value = tpl.blocks()
dirty.value = true
}
// New blocks land AFTER the block being worked on, not at the bottom. Appending
// to the end means scrolling back every time, which is the difference between
// writing a guide and fighting an editor.
function insertIndex () {
const index = blocks.value.findIndex(b => b.id === focused.value)
return index === -1 ? blocks.value.length : index + 1
}
function add (type) {
const block = { id: newid(), type }
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
if (type === 'callout') block.variant = 'info'
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
const at = insertIndex()
blocks.value.splice(at, 0, block)
focused.value = block.id
nextTick(() => textareas[block.id]?.focus())
}
function duplicate (index) {
const source = blocks.value[index]
const copy = JSON.parse(JSON.stringify(source))
copy.id = newid()
if (copy.items) copy.items = copy.items.map(item => ({ ...item, id: newid() }))
blocks.value.splice(index + 1, 0, copy)
}
function remove (index) { blocks.value.splice(index, 1) }
function move (index, delta) {
const target = index + delta
if (target < 0 || target >= blocks.value.length) return
const [block] = blocks.value.splice(index, 1)
blocks.value.splice(target, 0, block)
}
// Enter makes the next item and focuses it - the way every list in every other
// tool behaves. Typing a ten-item checklist should not need the mouse.
function addItemAfter (block, index) {
const item = { id: newid(), text: '' }
block.items.splice(index + 1, 0, item)
nextTick(() => itemInputs[item.id]?.focus())
}
// Backspace on an ALREADY EMPTY item removes it and moves up, which is how a
// list stops without reaching for the mouse. Never on a non-empty one.
function maybeRemoveItem (block, index, event) {
if (event.target.value !== '' || block.items.length <= 1) return
event.preventDefault()
const previous = block.items[index - 1]
block.items.splice(index, 1)
if (previous) nextTick(() => itemInputs[previous.id]?.focus())
}
function removeItem (block, index) {
if (block.items.length > 1) block.items.splice(index, 1)
else block.items[0].text = ''
}
function parseTable (block) {
const lines = (block.raw || '').split('\n').filter(l => l.trim())
const rows = lines.map(line => line.split('|').map(cell => cell.trim()))
block.header = rows.length ? rows[0] : []
block.rows = rows.slice(1)
}
// Formatting wraps the SELECTION, so the markup stays an implementation detail.
function wrap (block, marker) {
const area = textareas[block.id]
if (!area) return
const start = area.selectionStart
const end = area.selectionEnd
const text = block.text || ''
const selected = text.slice(start, end) || 'text'
block.text = text.slice(0, start) + marker + selected + marker + text.slice(end)
nextTick(() => {
area.focus()
area.setSelectionRange(start + marker.length, start + marker.length + selected.length)
})
}
function makeLink (block) {
const area = textareas[block.id]
if (!area) return
const start = area.selectionStart
const end = area.selectionEnd
const text = block.text || ''
const selected = text.slice(start, end) || 'link text'
const url = window.prompt('Link to:', 'https://')
if (!url) return
block.text = text.slice(0, start) + '[' + selected + '](' + url + ')' + text.slice(end)
nextTick(() => area.focus())
}
async function uploadImage (file) {
const form = new FormData()
form.append('file', file)
if (guide.guideid) form.append('guideid', guide.guideid)
const response = await api.post('/guides/images', form,
{ headers: { 'Content-Type': 'multipart/form-data' } })
return response.data?.data || response.data
}
async function insertImage (file) {
error.value = ''
status.value = 'Uploading image...'
try {
const image = await uploadImage(file)
const block = { id: newid(), type: 'image', url: image.url, caption: '', alt: '' }
blocks.value.splice(insertIndex(), 0, block)
focused.value = block.id
status.value = ''
} catch (err) {
status.value = ''
error.value = apiError(err, 'Could not upload that image')
}
}
function onPaste (event) {
const items = Array.from(event.clipboardData?.items || [])
const imageItem = items.find(item => item.type.startsWith('image/'))
if (!imageItem) return
event.preventDefault()
const file = imageItem.getAsFile()
if (file) insertImage(file)
}
function onDrop (event) {
const file = Array.from(event.dataTransfer?.files || [])
.find(f => f.type.startsWith('image/'))
if (file) insertImage(file)
}
function onFilePick (event) {
const file = event.target.files?.[0]
if (file) insertImage(file)
event.target.value = ''
}
function payload () {
return { title: guide.title, summary: guide.summary || '',
tags: guide.tags || '', blocks: blocks.value }
}
async function save (quiet) {
if (!guide.title.trim()) {
if (!quiet) error.value = 'A guide needs a title before it can be saved'
return
}
saving.value = true
error.value = ''
try {
if (isNew.value) {
const response = await api.post('/guides', payload())
const created = response.data?.data || response.data
Object.assign(guide, { guideid: created.guideid, slug: created.slug })
router.replace({ name: 'guide-edit', params: { guideid: created.guideid } })
} else {
const response = await api.put('/guides/' + guide.guideid, payload())
const saved = response.data?.data || response.data
guide.slug = saved.slug
}
dirty.value = false
status.value = 'Saved ' + new Date().toLocaleTimeString()
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not save this guide')
status.value = 'Not saved'
} finally {
saving.value = false
}
}
// Autosave, debounced. The server dedups on content, so an autosave that
// changed nothing writes no version - which is what makes this safe to run on a
// timer rather than a deliberate act.
let autosaveTimer = null
watch([blocks, () => guide.title, () => guide.summary, () => guide.tags],
() => {
dirty.value = true
status.value = 'Unsaved changes'
if (isNew.value) return // a new guide is saved deliberately, once
clearTimeout(autosaveTimer)
autosaveTimer = setTimeout(() => save(true), 3000)
},
{ deep: true })
onBeforeUnmount(() => clearTimeout(autosaveTimer))
// Leaving with unsaved work should require saying so out loud.
onBeforeRouteLeave(() => {
if (!dirty.value) return true
return window.confirm('This guide has unsaved changes. Leave anyway?')
})
async function publish () {
if (errorCount.value) {
const ok = window.confirm(
errorCount.value + ' problem(s) would go out with this guide, including '
+ 'anything flagged in a command. Publish anyway?')
if (!ok) return
}
await save(true)
try {
const response = await api.post('/guides/' + guide.guideid + '/publish')
guide.ispublished = true
message.value = response.data?.message || 'Published'
setTimeout(() => { message.value = '' }, 3000)
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not publish this guide')
}
}
async function unpublish () {
try {
await api.post('/guides/' + guide.guideid + '/unpublish')
guide.ispublished = false
message.value = 'Withdrawn from readers'
setTimeout(() => { message.value = '' }, 3000)
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not withdraw this guide')
}
}
async function restore (rev) {
if (!window.confirm('Bring version ' + rev.revision + ' back as a new draft version?')) return
try {
const response = await api.post(
'/guides/' + guide.guideid + '/restore/' + rev.guiderevisionid)
const restored = response.data?.data || response.data
blocks.value = restored.blocks || []
Object.assign(guide, { title: restored.title, summary: restored.summary || '',
tags: (restored.tags || []).join(', ') })
dirty.value = false
message.value = response.data?.message || 'Restored'
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not restore that version')
}
}
async function loadRevisions () {
if (!guide.guideid) return
try {
const response = await api.get('/guides/' + guide.guideid + '/revisions')
revisions.value = response.data?.data || response.data || []
} catch (err) {
revisions.value = []
}
}
function formatDate (value) {
return value ? new Date(value).toLocaleString() : ''
}
onMounted(async () => {
const guideid = route.params.guideid
if (!guideid) return
try {
const response = await api.get('/guides/' + guideid + '/working')
const data = response.data?.data || response.data
Object.assign(guide, {
guideid: data.guideid, title: data.title, summary: data.summary || '',
tags: (data.tags || []).join(', '), slug: data.slug,
ispublished: data.ispublished,
})
blocks.value = (data.blocks || []).map(block => {
if (block.type === 'table' && !block.raw) {
const rows = [block.header || [], ...(block.rows || [])]
block.raw = rows.filter(r => r.length).map(r => r.join(' | ')).join('\n')
}
return block
})
await nextTick()
dirty.value = false
status.value = ''
await loadRevisions()
} catch (err) {
error.value = apiError(err, 'Could not load this guide')
}
})
</script>
<style scoped>
.page-header { display: flex; align-items: center; justify-content: space-between; }
.ge-actions { display: flex; gap: 0.4rem; align-items: center; }
.ge-status { font-size: 0.75rem; color: var(--text-light); margin-right: 0.3rem; }
.ge-btn {
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
color: var(--primary); background: transparent; border-radius: 4px;
cursor: pointer; text-decoration: none;
}
.ge-primary { background: var(--primary); color: #fff; }
.ge-btn:disabled { opacity: 0.5; cursor: default; }
.ge-fields { display: grid; gap: 0.4rem; margin: 0.8rem 0 1rem; max-width: 45rem; }
.ge-fields input {
padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 4px;
background: var(--surface, transparent); color: var(--text);
}
.ge-title { font-size: 1.1rem; font-weight: 600; }
.ge-templates { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
margin-bottom: 1rem; }
.ge-templates > span { font-size: 0.8rem; color: var(--text-light); }
.ge-tpl {
display: grid; text-align: left; gap: 0.1rem; padding: 0.45rem 0.7rem;
border: 1px solid var(--border); border-radius: 5px; background: transparent;
color: var(--text); cursor: pointer; max-width: 15rem;
}
.ge-tpl:hover { border-color: var(--primary); }
.ge-tpl small { color: var(--text-light); font-size: 0.72rem; }
.ge-split { display: grid; gap: 1.2rem; }
.ge-haspreview { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
@media (max-width: 70rem) { .ge-haspreview { grid-template-columns: 1fr; } }
.ge-block {
border: 1px solid var(--border); border-radius: 5px; padding: 0.5rem;
margin-bottom: 0.5rem;
}
.ge-focused { border-color: var(--primary); }
.ge-blockbar { display: flex; gap: 0.35rem; align-items: center; margin-bottom: 0.35rem;
flex-wrap: wrap; }
.ge-type { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--text-light); }
.ge-mini {
font-size: 0.72rem; padding: 0.12rem 0.3rem; border: 1px solid var(--border);
border-radius: 3px; background: var(--surface, transparent); color: var(--text);
max-width: 8rem;
}
.ge-format { display: flex; gap: 0.15rem; }
.ge-format button, .ge-blockbtns button {
font-size: 0.7rem; padding: 0.1rem 0.35rem; border: 1px solid var(--border);
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
min-width: 1.5rem;
}
.ge-format button:hover, .ge-blockbtns button:hover { color: var(--primary); border-color: var(--primary); }
.ge-blockbtns { margin-left: auto; display: flex; gap: 0.15rem; }
.ge-del:hover { border-color: var(--danger, #c0392b) !important; color: var(--danger, #c0392b) !important; }
.ge-text, .ge-steptitle, .ge-tableedit textarea {
width: 100%; padding: 0.4rem 0.5rem; border: 1px solid var(--border);
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
font-family: inherit; font-size: 0.88rem; resize: vertical;
}
.ge-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem; }
.ge-steptitle { margin-bottom: 0.35rem; }
.ge-items { display: grid; gap: 0.25rem; }
.ge-item { display: flex; gap: 0.3rem; align-items: center; }
.ge-bullet { font-size: 0.8rem; color: var(--text-light); width: 1.2rem; }
.ge-item input {
flex: 1; padding: 0.3rem 0.45rem; border: 1px solid var(--border);
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
}
.ge-item button, .ge-additem, .ge-add button, .ge-upload {
font-size: 0.72rem; padding: 0.15rem 0.45rem; border: 1px solid var(--border);
background: transparent; color: var(--text-light); border-radius: 3px;
cursor: pointer; justify-self: start;
}
.ge-image img { max-width: 18rem; display: block; margin-bottom: 0.35rem;
border: 1px solid var(--border); border-radius: 4px; }
.ge-image input { width: 100%; margin-bottom: 0.25rem; padding: 0.3rem 0.45rem;
border: 1px solid var(--border); border-radius: 4px;
background: var(--surface, transparent); color: var(--text); }
.ge-add { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin: 0.8rem 0 0.4rem; }
.ge-add > span { font-size: 0.75rem; color: var(--text-light); }
.ge-hint { font-size: 0.75rem; color: var(--text-light); }
.ge-preview {
border-left: 1px solid var(--border); padding-left: 1.2rem;
position: sticky; top: 1rem; align-self: start; max-height: calc(100vh - 3rem);
overflow-y: auto;
}
.ge-previewhead {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-light); margin-bottom: 0.5rem;
}
.ge-previewtitle { margin: 0 0 0.2rem; font-size: 1.4rem; }
.ge-previewsummary { color: var(--text-light); margin: 0 0 1rem; }
.ge-proof {
border: 1px solid var(--border); border-left: 3px solid var(--warning, #d9822b);
border-radius: 4px; padding: 0.5rem 0.7rem; margin-bottom: 1rem;
font-size: 0.82rem;
}
.ge-proofhead { display: flex; align-items: center; gap: 0.5rem; }
.ge-proofhead button {
margin-left: auto; font-size: 0.72rem; background: none; border: none;
color: var(--text-light); cursor: pointer; text-decoration: underline;
}
.ge-proof ul { margin: 0.4rem 0 0; padding: 0; list-style: none; }
.ge-proof li { display: flex; gap: 0.4rem; align-items: baseline; padding: 0.15rem 0; }
.ge-error span { color: var(--danger, #c0392b); }
.ge-warn span { color: var(--text-light); }
.ge-jump, .ge-fix {
font-size: 0.68rem; padding: 0.05rem 0.35rem; border: 1px solid var(--border);
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
}
.ge-fix { margin-left: auto; border-color: var(--primary); color: var(--primary); }
.ge-revisions { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
.ge-rev { display: flex; gap: 0.6rem; align-items: center; font-size: 0.8rem; padding: 0.25rem 0; }
.ge-revnum { font-weight: 600; }
.ge-revwhen, .ge-revnote { color: var(--text-light); }
.ge-revpub { font-size: 0.68rem; padding: 0.05rem 0.35rem; border-radius: 3px;
border: 1px solid var(--primary); color: var(--primary); }
.ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); border-radius: 3px; cursor: pointer; }
</style>

View File

@@ -0,0 +1,271 @@
<template>
<div class="gv">
<div v-if="loading" class="gv-loading">Loading...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<template v-else-if="guide">
<div class="gv-head">
<div class="gv-headmain">
<h1>{{ guide.title }}</h1>
<p v-if="guide.summary" class="gv-summary">{{ guide.summary }}</p>
<div class="gv-meta">
<span v-if="guide.isdraftpreview" class="gv-badge gv-draft">Draft preview</span>
<span v-else class="gv-badge">Version {{ guide.revision }}</span>
<span v-for="tag in guide.tags" :key="tag" class="gv-tag"
@click="$router.push({ name: 'guides', query: { tag } })">{{ tag }}</span>
</div>
</div>
<div class="gv-actions">
<button type="button" class="gv-btn" @click="print">Print</button>
<router-link v-if="guide.canedit" class="gv-btn"
:to="{ name: 'guide-edit', params: { guideid: guide.guideid } }">
Edit
</router-link>
</div>
</div>
<!-- An editor needs to know there is newer work than what readers see,
without leaving the page to find out. -->
<div v-if="guide.hasunpublishedchanges && guide.canedit" class="gv-notice">
There are unpublished changes. Readers are seeing version {{ guide.revision }}.
</div>
<div v-if="totalChecks" class="gv-progress">
<div class="gv-progressbar">
<div class="gv-progressfill" :style="{ width: progressPercent + '%' }"></div>
</div>
<span class="gv-progresstext">{{ tickedCount }} of {{ totalChecks }} done</span>
<button v-if="tickedCount" type="button" class="gv-reset" @click="resetProgress">
Reset
</button>
</div>
<GuideBlocks :blocks="guide.blocks" :interactive="true"
:ticked="ticked" @toggle="toggle" />
<div class="gv-foot">
<span>Version {{ guide.revision }}</span>
<span v-if="guide.publishedat">Published {{ formatDate(guide.publishedat) }}</span>
<span class="gv-printedon">Printed {{ today }}</span>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import api from '@/api'
import { apiError } from '@/utils/apiError'
import GuideBlocks from '../components/GuideBlocks.vue'
const route = useRoute()
const guide = ref(null)
const loading = ref(true)
const error = ref('')
const ticked = ref({})
const today = new Date().toLocaleDateString()
/**
* Progress lives in the BROWSER, not the database.
*
* Shopfloor PCs share a login. Storing progress per user would mean one
* technician's ticks appearing for the next person on that bay, which is worse
* than useless on a procedure - it reads as "already done". Per browser, each
* station keeps its own place, and the Reset button is right there.
*
* Keyed by guide id and version: a new version invalidates old ticks, because
* step 4 of version 3 is not step 4 of version 2.
*/
function storageKey () {
return guide.value
? 'guides.progress.' + guide.value.guideid + '.v' + guide.value.revision
: null
}
function loadProgress () {
const key = storageKey()
if (!key) return
try {
ticked.value = JSON.parse(window.localStorage.getItem(key) || '{}')
} catch (err) {
ticked.value = {}
}
}
function saveProgress () {
const key = storageKey()
if (!key) return
try {
window.localStorage.setItem(key, JSON.stringify(ticked.value))
} catch (err) {
// Private window, or storage disabled. The checkboxes still work for this
// visit; they just will not be remembered. Not worth an error message.
}
}
function toggle (itemid, checked) {
if (checked) ticked.value = { ...ticked.value, [itemid]: true }
else {
const next = { ...ticked.value }
delete next[itemid]
ticked.value = next
}
saveProgress()
}
function resetProgress () {
ticked.value = {}
saveProgress()
}
const totalChecks = computed(() => {
let total = 0
for (const block of guide.value?.blocks || []) {
if (block.type === 'checklist') total += (block.items || []).length
}
return total
})
const tickedCount = computed(() => Object.keys(ticked.value).length)
const progressPercent = computed(() =>
totalChecks.value ? Math.round((tickedCount.value / totalChecks.value) * 100) : 0)
function formatDate (value) {
return value ? new Date(value).toLocaleDateString() : ''
}
function print () {
window.print()
}
async function load () {
loading.value = true
error.value = ''
try {
const response = await api.get('/guides/' + route.params.slug)
guide.value = response.data?.data || response.data
loadProgress()
} catch (err) {
error.value = apiError(err, 'Could not load this guide')
} finally {
loading.value = false
}
}
onMounted(load)
watch(() => route.params.slug, load)
</script>
<style scoped>
.gv { max-width: 60rem; }
.gv-head { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 0.5rem; }
.gv-headmain { flex: 1; }
.gv-head h1 { margin: 0 0 0.3rem; }
.gv-summary { color: var(--text-light); margin: 0 0 0.5rem; }
.gv-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.gv-badge {
font-size: 0.72rem; padding: 0.1rem 0.45rem; border-radius: 3px;
border: 1px solid var(--border); color: var(--text-light);
}
.gv-draft { border-color: var(--warning, #d9822b); color: var(--warning, #d9822b); }
.gv-tag {
font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px;
background: var(--surface-2, rgba(127,127,127,0.1)); cursor: pointer;
}
.gv-tag:hover { color: var(--primary); }
.gv-actions { display: flex; gap: 0.4rem; }
.gv-btn {
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
color: var(--primary); background: transparent; border-radius: 4px;
cursor: pointer; text-decoration: none;
}
.gv-btn:hover { background: var(--primary); color: #fff; }
.gv-notice {
border-left: 4px solid var(--warning, #d9822b);
background: var(--surface-2, rgba(127,127,127,0.08));
padding: 0.5rem 0.8rem; margin: 0.5rem 0 1rem; font-size: 0.85rem;
}
.gv-progress { display: flex; align-items: center; gap: 0.7rem; margin: 1rem 0 1.4rem; }
.gv-progressbar {
flex: 1; height: 6px; border-radius: 3px; overflow: hidden;
background: var(--surface-2, rgba(127,127,127,0.15));
}
.gv-progressfill { height: 100%; background: var(--primary); transition: width 0.2s; }
.gv-progresstext { font-size: 0.78rem; color: var(--text-light); white-space: nowrap; }
.gv-reset {
font-size: 0.72rem; background: none; border: none; color: var(--text-light);
cursor: pointer; text-decoration: underline;
}
.gv-foot {
margin-top: 2rem; padding-top: 0.6rem; border-top: 1px solid var(--border);
display: flex; gap: 1rem; font-size: 0.75rem; color: var(--text-light);
}
.gv-printedon { display: none; }
@media print {
/* A printed guide is a worksheet: no controls, everything visible, and the
footer says which version it was so a stale printout on a noticeboard can
be spotted. */
.gv-actions, .gv-progress, .gv-notice, .gv-reset { display: none !important; }
.gv-printedon { display: inline; }
.gv { max-width: none; }
.gv-head h1 { font-size: 1.6rem; }
.gv-foot { border-top: 1px solid #999; }
}
</style>
<style>
/*
* PRINT IS ALWAYS LIGHT, whatever the screen theme is.
*
* Printing a dark-mode page lays down a full page of toner for the background
* and then knocks the text out of it in white. On a shopfloor printer that is
* a cartridge for a handful of guides, and the result is harder to read than
* the plain version.
*
* So the theme tokens are redefined for print only. Every guide element already
* renders through these variables rather than literal colours, which is what
* makes this a dozen lines instead of a rewrite - and the same discipline that
* made dark mode work at all.
*
* Deliberately NOT scoped: a scoped block cannot reach :root, and these have to
* override tokens set on the document element.
*/
@media print {
:root,
:root[data-theme="dark"],
:root[data-theme="light"] {
--text: #000;
--text-light: #333;
--border: #999;
--surface: #fff;
--surface-2: #fff;
--primary: #000;
--info: #666;
--warning: #666;
--success: #666;
}
html, body, .app-layout, .main-content {
background: #fff !important;
color: #000 !important;
}
/* Backgrounds are dropped rather than printed grey: a callout is identified
by its rule and its heading, which cost nothing to print. */
.gb-callout, .gb-code, .gb-codehead, .gb-table th {
background: #fff !important;
}
.gb-callout { border-left: 3px solid #000 !important; }
.gb-code, .gb-table th, .gb-table td { border-color: #999 !important; }
/* Links: the text is what matters on paper, and a blue underline on a mono
printer is just grey. */
a { color: #000 !important; text-decoration: underline; }
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<div class="gl">
<div class="page-header">
<h2>Guides</h2>
<router-link v-if="cancreate" class="gl-new" :to="{ name: 'guide-new' }">
New guide
</router-link>
</div>
<div class="gl-filters">
<input v-model="search" type="search" class="gl-search"
placeholder="Search titles, summaries, tags and content..."
@input="debouncedLoad">
<div v-if="tags.length" class="gl-tags">
<button type="button" class="gl-tag" :class="{ 'gl-tagon': !activeTag }"
@click="setTag('')">All</button>
<button v-for="tag in tags" :key="tag.tag" type="button"
class="gl-tag" :class="{ 'gl-tagon': activeTag === tag.tag }"
@click="setTag(tag.tag)">
{{ tag.tag }} <span class="gl-count">{{ tag.count }}</span>
</button>
</div>
</div>
<div v-if="loading" class="gl-loading">Loading...</div>
<div v-else-if="error" class="error-message">{{ error }}</div>
<div v-else-if="!guides.length" class="gl-empty">
<p v-if="search || activeTag">Nothing matches that.</p>
<p v-else>No guides yet.</p>
</div>
<div v-else class="gl-grid">
<router-link v-for="guide in guides" :key="guide.guideid" class="gl-card"
:to="{ name: 'guide-view', params: { slug: guide.slug } }">
<div class="gl-cardhead">
<h3>{{ guide.title }}</h3>
<!-- Drafts are visible here only to people who can edit them, so the
badge is a working state rather than a warning. -->
<span v-if="!guide.ispublished" class="gl-draft">Draft</span>
</div>
<p v-if="guide.summary" class="gl-summary">{{ guide.summary }}</p>
<div class="gl-cardfoot">
<span v-for="tag in guide.tags" :key="tag" class="gl-minitag">{{ tag }}</span>
<span v-if="guide.checklistitems" class="gl-checks">
{{ guide.checklistitems }} steps to tick
</span>
</div>
</router-link>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import api from '@/api'
import { apiError } from '@/utils/apiError'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const guides = ref([])
const tags = ref([])
const search = ref('')
const activeTag = ref('')
const loading = ref(true)
const error = ref('')
// computed, not a one-off read: permissions may not be loaded yet at setup.
const cancreate = computed(() => auth.hasPermission?.('guides.create') ?? false)
let timer = null
function debouncedLoad () {
clearTimeout(timer)
timer = setTimeout(load, 250)
}
function setTag (tag) {
activeTag.value = tag
router.replace({ query: tag ? { tag } : {} })
load()
}
async function load () {
loading.value = true
error.value = ''
try {
const params = {}
if (search.value.trim()) params.q = search.value.trim()
if (activeTag.value) params.tag = activeTag.value
const response = await api.get('/guides', { params })
guides.value = response.data?.data || response.data || []
} catch (err) {
error.value = apiError(err, 'Could not load guides')
} finally {
loading.value = false
}
}
async function loadTags () {
try {
const response = await api.get('/guides/tags')
tags.value = response.data?.data || response.data || []
} catch (err) {
tags.value = []
}
}
onMounted(() => {
activeTag.value = route.query.tag || ''
load()
loadTags()
})
</script>
<style scoped>
.page-header { display: flex; align-items: center; justify-content: space-between; }
.gl-new {
font-size: 0.82rem; padding: 0.35rem 0.8rem; border-radius: 4px;
background: var(--primary); color: #fff; text-decoration: none;
}
.gl-filters { margin: 0.8rem 0 1.2rem; }
.gl-search {
width: 100%; max-width: 34rem; padding: 0.45rem 0.6rem;
border: 1px solid var(--border); border-radius: 4px;
background: var(--surface, transparent); color: var(--text);
}
.gl-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
.gl-tag {
font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px;
border: 1px solid var(--border); background: transparent;
color: var(--text-light); cursor: pointer;
}
.gl-tagon { border-color: var(--primary); color: var(--primary); }
.gl-count { opacity: 0.6; }
.gl-grid {
display: grid; gap: 0.8rem;
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
}
.gl-card {
display: block; padding: 0.9rem 1rem; border: 1px solid var(--border);
border-radius: 6px; text-decoration: none; color: inherit;
background: var(--surface, transparent);
}
.gl-card:hover { border-color: var(--primary); }
.gl-cardhead { display: flex; align-items: baseline; gap: 0.5rem; }
.gl-cardhead h3 { margin: 0; font-size: 1rem; flex: 1; }
.gl-draft {
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 3px;
border: 1px solid var(--warning, #d9822b); color: var(--warning, #d9822b);
}
.gl-summary { margin: 0.4rem 0 0.6rem; font-size: 0.85rem; color: var(--text-light); }
.gl-cardfoot { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
.gl-minitag {
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 999px;
background: var(--surface-2, rgba(127,127,127,0.1)); color: var(--text-light);
}
.gl-checks { font-size: 0.7rem; color: var(--text-light); margin-left: auto; }
.gl-empty, .gl-loading { color: var(--text-light); padding: 1.5rem 0; }
</style>