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.
428 lines
16 KiB
Vue
428 lines
16 KiB
Vue
<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">
|
|
<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">
|
|
<label class="ge-field">
|
|
<span>Title</span>
|
|
<input v-model="guide.title" type="text" placeholder="What is this guide for?">
|
|
</label>
|
|
<label class="ge-field">
|
|
<span>Summary</span>
|
|
<input v-model="guide.summary" type="text"
|
|
placeholder="One line, shown in the list and in search results">
|
|
</label>
|
|
<label class="ge-field">
|
|
<span>Tags</span>
|
|
<input v-model="guide.tags" type="text"
|
|
placeholder="Comma separated, e.g. pxe, share, backups">
|
|
</label>
|
|
</div>
|
|
|
|
<div class="ge-blocks">
|
|
<div v-for="(block, index) in blocks" :key="block.id" class="ge-block">
|
|
<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 (optional)">
|
|
<div class="ge-blockbtns">
|
|
<button type="button" @click="move(index, -1)" :disabled="index === 0">Up</button>
|
|
<button type="button" @click="move(index, 1)"
|
|
:disabled="index === blocks.length - 1">Down</button>
|
|
<button type="button" class="ge-del" @click="remove(index)">Remove</button>
|
|
</div>
|
|
</div>
|
|
|
|
<input v-if="block.type === 'step'" v-model="block.title" class="ge-steptitle"
|
|
placeholder="Step title">
|
|
|
|
<textarea v-if="hasText(block)" v-model="block.text" class="ge-text"
|
|
:rows="block.type === 'code' ? 6 : 3"
|
|
: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">
|
|
<input v-model="item.text" :placeholder="'Item ' + (i + 1)">
|
|
<button type="button" @click="removeItem(block, i)">x</button>
|
|
</div>
|
|
<button type="button" class="ge-additem" @click="addItem(block)">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)"
|
|
placeholder="One row per line, cells separated by | Header | Header Cell | Cell"></textarea>
|
|
</div>
|
|
</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 file
|
|
<input type="file" accept="image/*" hidden @change="onFilePick">
|
|
</label>
|
|
</div>
|
|
|
|
<p class="ge-hint">
|
|
Paste or drag an image straight onto this page to add it. Text supports
|
|
**bold**, *italic*, `code` and [links](https://example.com).
|
|
</p>
|
|
|
|
<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, 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 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 isNew = computed(() => !guide.guideid)
|
|
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
|
|
|
|
// Stable ids are what make a tick rememberable between visits, so every block
|
|
// and every item gets one at creation and keeps it for life.
|
|
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 'The thing somebody needs to know before they carry on'
|
|
return 'Text'
|
|
}
|
|
|
|
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 = [] }
|
|
blocks.value.push(block)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
function addItem (block) { block.items.push({ id: newid(), text: '' }) }
|
|
function removeItem (block, index) { block.items.splice(index, 1) }
|
|
|
|
// Tables are edited as pipe-separated lines: quick to type, quick to paste out
|
|
// of a spreadsheet, and no grid widget to maintain.
|
|
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)
|
|
}
|
|
|
|
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 = ''
|
|
try {
|
|
const image = await uploadImage(file)
|
|
blocks.value.push({ id: newid(), type: 'image', url: image.url,
|
|
caption: '', alt: '' })
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Could not upload that image')
|
|
}
|
|
}
|
|
|
|
// Paste and drop are the two ways people actually add a screenshot. Both end up
|
|
// in the same place, and neither requires finding a file on disk first.
|
|
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) {
|
|
saving.value = true
|
|
error.value = ''
|
|
message.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,
|
|
ispublished: false })
|
|
router.replace({ name: 'guide-edit', params: { guideid: created.guideid } })
|
|
message.value = 'Guide created'
|
|
} else {
|
|
const response = await api.put('/guides/' + guide.guideid, payload())
|
|
const saved = response.data?.data || response.data
|
|
guide.slug = saved.slug
|
|
// 'No changes to save' is a real outcome, not a failure: an unchanged
|
|
// save must not manufacture a version.
|
|
message.value = response.data?.message || 'Saved'
|
|
}
|
|
await loadRevisions()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Could not save this guide')
|
|
} finally {
|
|
saving.value = false
|
|
if (!quiet) setTimeout(() => { message.value = '' }, 2500)
|
|
}
|
|
}
|
|
|
|
async function publish () {
|
|
await save(true)
|
|
try {
|
|
const response = await api.post('/guides/' + guide.guideid + '/publish')
|
|
guide.ispublished = true
|
|
message.value = response.data?.message || 'Published'
|
|
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'
|
|
await loadRevisions()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Could not withdraw this guide')
|
|
}
|
|
}
|
|
|
|
async function restore (rev) {
|
|
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(', ') })
|
|
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 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; }
|
|
.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.6rem; margin: 0.8rem 0 1.2rem; max-width: 45rem; }
|
|
.ge-field { display: grid; gap: 0.2rem; }
|
|
.ge-field span { font-size: 0.78rem; color: var(--text-light); }
|
|
.ge-field input {
|
|
padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 4px;
|
|
background: var(--surface, transparent); color: var(--text);
|
|
}
|
|
|
|
.ge-block {
|
|
border: 1px solid var(--border); border-radius: 5px; padding: 0.6rem;
|
|
margin-bottom: 0.6rem;
|
|
}
|
|
.ge-blockbar { display: flex; gap: 0.4rem; align-items: center; margin-bottom: 0.4rem; }
|
|
.ge-type {
|
|
font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|
color: var(--text-light);
|
|
}
|
|
.ge-mini {
|
|
font-size: 0.75rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
|
border-radius: 3px; background: var(--surface, transparent); color: var(--text);
|
|
max-width: 10rem;
|
|
}
|
|
.ge-blockbtns { margin-left: auto; display: flex; gap: 0.25rem; }
|
|
.ge-blockbtns button {
|
|
font-size: 0.72rem; padding: 0.12rem 0.4rem; border: 1px solid var(--border);
|
|
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
|
|
}
|
|
.ge-del:hover { border-color: var(--danger, #c0392b); color: var(--danger, #c0392b); }
|
|
|
|
.ge-text, .ge-steptitle, .ge-tableedit textarea {
|
|
width: 100%; padding: 0.45rem 0.55rem; border: 1px solid var(--border);
|
|
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
|
font-family: inherit; font-size: 0.9rem;
|
|
}
|
|
.ge-steptitle { margin-bottom: 0.4rem; }
|
|
.ge-items { display: grid; gap: 0.3rem; }
|
|
.ge-item { display: flex; gap: 0.3rem; }
|
|
.ge-item input {
|
|
flex: 1; padding: 0.35rem 0.5rem; border: 1px solid var(--border);
|
|
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
|
}
|
|
.ge-additem, .ge-add button, .ge-upload {
|
|
font-size: 0.75rem; padding: 0.2rem 0.55rem; border: 1px solid var(--border);
|
|
background: transparent; color: var(--text-light); border-radius: 3px;
|
|
cursor: pointer; justify-self: start;
|
|
}
|
|
.ge-image img { max-width: 20rem; display: block; margin-bottom: 0.4rem;
|
|
border: 1px solid var(--border); border-radius: 4px; }
|
|
.ge-image input { width: 100%; margin-bottom: 0.3rem; padding: 0.3rem 0.5rem;
|
|
border: 1px solid var(--border); border-radius: 4px;
|
|
background: var(--surface, transparent); color: var(--text); }
|
|
|
|
.ge-add { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; margin: 1rem 0 0.4rem; }
|
|
.ge-add > span { font-size: 0.78rem; color: var(--text-light); }
|
|
.ge-hint { font-size: 0.78rem; color: var(--text-light); }
|
|
|
|
.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>
|