Files
shopdb-flask/plugins/guides/frontend/views/GuideEditor.vue
cproudlock 933740aae4 Guide editor: real arrows, drag to reorder, add a section where it goes
Four things people kept tripping over.

The move controls were a literal ^ and v character. They are arrows now.

Sections can be dragged. The handle is a grip in the block bar, not the block
itself: making the whole container draggable swallows text selection inside its
own textarea, so you could no longer select a word to bold it.

The type palette lived once, at the bottom of the entire guide. Adding a step in
the middle meant adding it at the end and walking it up one press at a time.
There is now an insert point in every gap - and at the end, so there is one way
to add a section rather than two.

Linking to another guide or a KB article meant knowing its slug or id by heart.
A picker searches both by title and writes the markdown link at the cursor. It
offers published guides only - never send a reader to a draft - and it stores an
in-app path rather than an absolute URL, so the link survives the site being
reached by another hostname or mounted under a subpath. The knowledge base is a
separate plugin that a site may not have installed; its absence costs nothing
but its results.
2026-08-27 09:25:45 -04:00

975 lines
40 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">
<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>
<!-- Reference picker -->
<div v-if="refOpen" class="ge-refmask" @click.self="refOpen = false">
<div class="ge-ref">
<div class="ge-refhead">
<strong>Link to a guide or article</strong>
<button type="button" @click="refOpen = false"><X :size="16" /></button>
</div>
<input ref="refInput" v-model="refQuery" class="ge-refsearch"
placeholder="Search by title..." @input="searchReferences">
<p v-if="refLoading" class="ge-hint">Searching...</p>
<p v-else-if="!refResults.length" class="ge-hint">
{{ refQuery ? 'Nothing matches that.' : 'Type to search.' }}
</p>
<ul v-else class="ge-reflist">
<li v-for="item in refResults" :key="item.kind + item.id">
<button type="button" @click="insertReference(item)">
<span class="ge-refkind">{{ item.kind }}</span>
<span class="ge-reftitle">{{ item.title }}</span>
<small v-if="item.summary">{{ item.summary }}</small>
</button>
</li>
</ul>
</div>
</div>
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
<div class="ge-editor">
<!-- Insert point ABOVE each section.
The type palette used to live only at the bottom of the whole
guide, so adding a step in the middle meant adding it at the end
and walking it up one press at a time. The insert bar puts the
palette where the section is actually going. -->
<div v-for="(block, index) in blocks" :key="block.id">
<div class="ge-insert" :class="{ 'ge-insertopen': insertAt === index }">
<button type="button" class="ge-insertbtn"
:title="'Add a section here'"
@click="insertAt = insertAt === index ? null : index">
<Plus :size="14" /> Add section
</button>
</div>
<div v-if="insertAt === index" class="ge-palette">
<button v-for="type in blocktypes" :key="type" type="button"
@click="add(type, index)">{{ type }}</button>
<label class="ge-upload">
image
<input type="file" accept="image/*" hidden
@change="event => onFilePick(event, index)">
</label>
</div>
<div class="ge-block"
:class="{ 'ge-focused': focused === block.id,
'ge-dragging': dragIndex === index,
'ge-dragover': dragOver === index }"
@focusin="focused = block.id"
@dragover.prevent="dragOver = index"
@dragleave="dragOver === index && (dragOver = null)"
@drop.prevent="dropOn(index)">
<div class="ge-blockbar">
<!-- Dragging is on the HANDLE, not the whole block: a draggable
container swallows text selection inside its own textarea. -->
<span class="ge-grip" draggable="true" title="Drag to move"
@dragstart="startDrag(index, $event)" @dragend="endDrag">
<GripVertical :size="14" />
</span>
<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>
<!-- Linking to another guide or a KB article by hand means
knowing its slug or id. This finds it by name and writes the
link, so a procedure can point at the one before it. -->
<button type="button" title="Reference a guide or KB article"
@click="openReference(block)"><Link2 :size="14" /></button>
</div>
<div class="ge-blockbtns">
<button type="button" title="Move up" @click="move(index, -1)"
:disabled="index === 0"><ArrowUp :size="14" /></button>
<button type="button" title="Move down" @click="move(index, 1)"
:disabled="index === blocks.length - 1"><ArrowDown :size="14" /></button>
<button type="button" title="Duplicate" @click="duplicate(index)">
<Copy :size="14" />
</button>
<button type="button" class="ge-del" title="Remove"
@click="remove(index)"><X :size="14" /></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">
<!-- Same authenticated fetch as the reader's view: an author must
see the screenshot they just pasted. -->
<img v-if="imageState(block).src" :src="imageState(block).src" alt="">
<p v-else-if="imageState(block).failed" class="ge-imagefailed">
Image unavailable - it may have been removed
</p>
<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>
<!-- The end of the guide gets the same control as every gap between
sections, so there is one way to add a section, not two. -->
<div class="ge-insert ge-insertend"
:class="{ 'ge-insertopen': insertAt === blocks.length }">
<button type="button" class="ge-insertbtn"
@click="insertAt = insertAt === blocks.length ? null : blocks.length">
<Plus :size="14" /> Add section
</button>
</div>
<div v-if="insertAt === blocks.length" class="ge-palette">
<button v-for="type in blocktypes" :key="type" type="button"
@click="add(type, blocks.length)">{{ type }}</button>
<label class="ge-upload">
image
<input type="file" accept="image/*" hidden
@change="event => onFilePick(event, blocks.length)">
</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'
import { useGuideImages } from '../utils/images.js'
import { ArrowUp, ArrowDown, GripVertical, Copy, X, Plus, Link2 } from 'lucide-vue-next'
import { knowledgebaseApi } from '@/api'
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([])
// Which gap the type palette is open in. null = closed; blocks.length = the end.
const insertAt = ref(null)
const dragIndex = ref(null)
const dragOver = ref(null)
const refOpen = ref(false)
const refQuery = ref('')
const refResults = ref([])
const refLoading = ref(false)
const refInput = ref(null)
let refBlock = null
let refTimer = null
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
}
// `at` is an explicit gap from an insert bar. Without one, fall back to
// "after whatever is focused", which is what the old bottom palette did.
function add (type, at = null) {
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 index = at === null ? insertIndex() : at
blocks.value.splice(index, 0, block)
insertAt.value = null
focused.value = block.id
nextTick(() => textareas[block.id]?.focus())
}
// --------------------------------------------------------------- dragging ---
function startDrag (index, event) {
dragIndex.value = index
// Firefox will not start a drag without payload, and 'move' is what the
// cursor should say.
event.dataTransfer.effectAllowed = 'move'
try { event.dataTransfer.setData('text/plain', String(index)) } catch (err) { /* older browsers */ }
}
function endDrag () {
dragIndex.value = null
dragOver.value = null
}
function dropOn (index) {
const from = dragIndex.value
endDrag()
if (from === null || from === index) return
const [moved] = blocks.value.splice(from, 1)
blocks.value.splice(index, 0, moved)
focused.value = moved.id
}
// ------------------------------------------------------------- references ---
function openReference (block) {
refBlock = block
refOpen.value = true
refQuery.value = ''
refResults.value = []
nextTick(() => refInput.value?.focus())
}
function searchReferences () {
clearTimeout(refTimer)
const term = refQuery.value.trim()
if (!term) { refResults.value = []; return }
refTimer = setTimeout(async () => {
refLoading.value = true
try {
const results = []
// Guides first: a procedure most often points at another procedure.
try {
const response = await api.get('/guides', { params: { q: term } })
for (const guide of (response.data?.data || [])) {
if (!guide.ispublished) continue // never link readers at a draft
results.push({ kind: 'guide', id: guide.guideid, title: guide.title,
summary: guide.summary, url: `/guides/${guide.slug}` })
}
} catch (err) { /* guides always exist here, but never break the picker */ }
// The knowledge base is a separate plugin and may not be installed at
// this site (ADR-009). Its absence must cost nothing but its results.
try {
const response = await knowledgebaseApi.list({ search: term, perpage: 20 })
const rows = response.data?.data || []
for (const article of (Array.isArray(rows) ? rows : rows.items || [])) {
results.push({ kind: 'article', id: article.articleid || article.id,
title: article.title,
summary: article.summary || article.description,
url: `/knowledgebase/${article.articleid || article.id}` })
}
} catch (err) { /* KB plugin absent or disabled */ }
refResults.value = results.slice(0, 25)
} finally {
refLoading.value = false
}
}, 250)
}
function insertReference (item) {
// An in-app path, not an absolute URL: the link has to keep working when the
// site is reached by another hostname or mounted under a subpath.
const markdown = `[${item.title}](${item.url})`
const area = refBlock ? textareas[refBlock.id] : null
if (refBlock && area) {
const start = area.selectionStart ?? refBlock.text?.length ?? 0
const end = area.selectionEnd ?? start
const text = refBlock.text || ''
refBlock.text = text.slice(0, start) + markdown + text.slice(end)
nextTick(() => {
area.focus()
const caret = start + markdown.length
area.setSelectionRange(caret, caret)
})
} else if (refBlock) {
refBlock.text = (refBlock.text || '') + markdown
}
refOpen.value = false
}
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, at = null) {
error.value = ''
status.value = 'Uploading image...'
try {
const image = await uploadImage(file)
// imageid is what the renderer resolves; url stays for older readers and
// for anything that still treats a block as a plain document.
const block = { id: newid(), type: 'image', imageid: image.guideimageid,
url: image.url, caption: '', alt: '' }
blocks.value.splice(at === null ? insertIndex() : at, 0, block)
insertAt.value = null
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, at = null) {
const file = event.target.files?.[0]
if (file) insertImage(file, at)
event.target.value = ''
}
// Editor thumbnails resolve the same way the reader's do; see utils/images.js.
const { state: imageState } = useGuideImages(blocks)
function payload () {
return { title: guide.title, summary: guide.summary || '',
tags: guide.tags || '', blocks: blocks.value }
}
// Returns whether the save actually reached the server. publish() depends on
// that answer: a publish that runs after a failed save promotes the LAST
// revision the server accepted, which is not what is on the author's screen.
async function save (quiet) {
if (!guide.title.trim()) {
if (!quiet) error.value = 'A guide needs a title before it can be saved'
return false
}
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()
return true
} catch (err) {
error.value = apiError(err, 'Could not save this guide')
status.value = 'Not saved'
return false
} 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
}
// Publishing promotes the newest SAVED revision, so an unsaved edit is not
// in what readers get. Reporting "Published version 4" over a failed save
// tells the author their words went out when the old ones did.
if (!await save(true)) {
error.value = error.value
|| 'Could not save this guide, so it was not published'
return
}
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-imagefailed { margin: 0 0 0.35rem; padding: 0.5rem 0.7rem; font-size: 0.85rem;
color: var(--danger, #b3261e); border: 1px dashed var(--border); border-radius: 4px; }
.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; }
/* --- insert bars, drag handle, reference picker -------------------------- */
/* A quiet line that becomes a button on hover: present when wanted, not
competing with the guide's own content the rest of the time. */
.ge-insert {
display: flex; align-items: center; justify-content: center;
height: 1.5rem; opacity: 0; transition: opacity 0.12s;
}
.ge-insert:hover, .ge-insertopen, .ge-insertend { opacity: 1; }
.ge-insert::before, .ge-insert::after {
content: ''; flex: 1; height: 1px; background: var(--border);
}
.ge-insertbtn {
display: inline-flex; align-items: center; gap: 0.3rem;
margin: 0 0.5rem; padding: 0.15rem 0.6rem;
font-size: 0.75rem; color: var(--text-light);
border: 1px solid var(--border); border-radius: 999px;
background: var(--bg); cursor: pointer; white-space: nowrap;
}
.ge-insertbtn:hover { color: var(--primary); border-color: var(--primary); }
.ge-palette {
display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center;
margin: 0 0 0.6rem; padding: 0.5rem;
border: 1px solid var(--border); border-radius: 5px;
}
.ge-palette button, .ge-palette .ge-upload {
font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid var(--border);
background: transparent; color: var(--text); border-radius: 3px; cursor: pointer;
}
.ge-palette button:hover, .ge-palette .ge-upload:hover {
color: var(--primary); border-color: var(--primary);
}
.ge-grip { display: inline-flex; align-items: center; color: var(--text-light);
cursor: grab; padding-right: 0.2rem; }
.ge-grip:active { cursor: grabbing; }
.ge-dragging { opacity: 0.45; }
.ge-dragover { border-top: 2px solid var(--primary); }
.ge-refmask {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45);
display: flex; align-items: flex-start; justify-content: center;
padding-top: 8vh; z-index: 50;
}
.ge-ref {
width: min(34rem, 92vw); max-height: 70vh; overflow: auto;
background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 6px; padding: 1rem;
}
.ge-refhead { display: flex; align-items: center; justify-content: space-between;
margin-bottom: 0.6rem; }
.ge-refhead button { background: transparent; border: none; color: var(--text-light);
cursor: pointer; }
.ge-refsearch {
width: 100%; padding: 0.5rem 0.7rem; font-size: 1rem;
border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--text); margin-bottom: 0.6rem;
}
.ge-reflist { list-style: none; margin: 0; padding: 0; }
.ge-reflist button {
width: 100%; display: flex; flex-direction: column; gap: 0.15rem;
padding: 0.5rem 0.6rem; margin-bottom: 0.3rem; text-align: left;
border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--text); cursor: pointer;
}
.ge-reflist button:hover { border-color: var(--primary); }
.ge-refkind {
font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--text-light);
}
.ge-reftitle { font-weight: 600; }
.ge-reflist small { color: var(--text-light); }
</style>