Images were a broken icon for every user. The stored URL was bound straight to <img src>, and a plain image request carries no Authorization header while this app keeps its JWT in one, so every fetch was a 401. They are now fetched through the API client, which also fixes the stored '/api/...' path under a subpath mount where the API is not at /api at all. A failed image says so in words rather than rendering as a broken icon, and the editor's own thumbnails go through the same path - an author needs to see the screenshot they pasted. Revision and image routes gated on their parent guide. Both were gated on guides.view alone, and revision ids are sequential, so being able to read one guide was enough to walk the ids and read every unpublished draft on the site - the opposite of what docs/GUIDES.md promises. An image in a PUBLISHED guide still serves to any reader; one that only appears in a draft does not. The guide list, the tag chips and search now match the PUBLISHED text rather than the working copy. Matching the working copy handed readers a hit on wording that is not in the page when they open it. The slug no longer follows the title. It was regenerated on every save that carried a title and the editor autosaves every three seconds, so fixing a typo in a heading silently moved the URL and broke exactly the pasted links the stored slug exists to protect. publish() now aborts when the pre-publish save failed. It ran regardless, so the author saw "Published version N" while readers got the previous revision. Link rendering: text is escaped before formatting, so a captured URL arrives carrying entities and was escaped a second time - every multi-parameter ServiceNow and SharePoint link opened with &amp; in it. Emphasis could also reach inside an href, and '//host' passed as an in-app link when the browser reads it as off-site. Tests are now HTTP-level. The draft-invisibility rule lived entirely in routes that no test executed, which is how three of them shipped serving drafts.
718 lines
29 KiB
Vue
718 lines
29 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>
|
|
|
|
<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, '`')"><></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">
|
|
<!-- 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 | Header | Header 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'
|
|
import { useGuideImages } from '../utils/images.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)
|
|
// 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(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 = ''
|
|
}
|
|
|
|
// 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; }
|
|
</style>
|