Make writing a guide less like operating an editor
Seven changes, all aimed at the same thing: think about the procedure, not about blocks. Live preview beside the editor, rendered with the SAME component readers get, so what an author sees is what will be published - a separate preview implementation would drift within a month. Formatting buttons wrap the selection, so nobody needs to know the markup is Markdown. New blocks and pasted images land AFTER the block being worked on rather than at the bottom of the document, which removes the scroll-back that made adding a screenshot mid-guide annoying. Enter in a list makes the next item and focuses it, the way every other list in every other tool behaves; backspace on an already-empty item removes it and steps back up. Typing a ten-item checklist no longer needs the mouse. Templates, because the blank page is the real barrier to somebody writing a procedure down at all. The shapes are lifted from the KB articles the team already writes, so guides written here read like the ones written before them. Autosave on a three-second debounce with an honest status line, safe precisely because the server dedups on content - an autosave that changed nothing writes no version. Leaving with unsaved work now asks first. CLIPBOARD: the code block no longer writes to the clipboard. A page that programmatically copies a shell command is the exact shape of a ClickFix attack, where malware plants a command in your clipboard and talks you into pasting it into Run - and uBlock Origin blocked ours, correctly, because it was writing PowerShell on click. The button now SELECTS the code and says to press Ctrl+C: same convenience, decision back with the person, and nothing for security tooling to object to. AUTOCORRECT is on for prose and off for code and table cells. A code block that autocorrects produces a guide telling somebody to run a command that does not work - smart quotes replacing a straight one, an autocapitalised cmdlet - and it still LOOKS right to the author. The person at the bay gets the syntax error.
This commit is contained in:
@@ -50,14 +50,26 @@
|
|||||||
<div class="gb-codehead">
|
<div class="gb-codehead">
|
||||||
<span class="gb-lang">{{ block.language || 'text' }}</span>
|
<span class="gb-lang">{{ block.language || 'text' }}</span>
|
||||||
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
|
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
|
||||||
<!-- Copies the code ONLY: no caption, no prompt. A prompt glued to a
|
<!--
|
||||||
copied command is how somebody ends up pasting "PS C:\>" into a
|
SELECTS the code; it does not write to the clipboard.
|
||||||
terminal. -->
|
|
||||||
<button type="button" class="gb-copy" @click="copy(block)">
|
A page that programmatically copies a shell command is the exact
|
||||||
{{ copied === block.id ? 'Copied' : 'Copy' }}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre><code>{{ block.text }}</code></pre>
|
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
||||||
@@ -96,7 +108,9 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
const emit = defineEmits(['toggle'])
|
const emit = defineEmits(['toggle'])
|
||||||
|
|
||||||
const copied = ref('')
|
const selected = ref('')
|
||||||
|
const codeNodes = {}
|
||||||
|
function registerCode (id, el) { if (el) codeNodes[id] = el }
|
||||||
|
|
||||||
function inline (text) {
|
function inline (text) {
|
||||||
return renderInline(text)
|
return renderInline(text)
|
||||||
@@ -138,23 +152,16 @@ function anchorFor (block) {
|
|||||||
return 'g-' + (block.id || '')
|
return 'g-' + (block.id || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copy (block) {
|
function select (block) {
|
||||||
try {
|
const node = codeNodes[block.id]
|
||||||
await navigator.clipboard.writeText(block.text || '')
|
if (!node || !window.getSelection) return
|
||||||
copied.value = block.id
|
const range = document.createRange()
|
||||||
setTimeout(() => { copied.value = '' }, 2000)
|
range.selectNodeContents(node)
|
||||||
} catch (err) {
|
const selection = window.getSelection()
|
||||||
// Clipboard needs a secure context. Select the text instead of failing
|
selection.removeAllRanges()
|
||||||
// silently, so the tech can copy it by hand.
|
selection.addRange(range)
|
||||||
const node = document.querySelector('.gb-code pre')
|
selected.value = block.id
|
||||||
if (node && window.getSelection) {
|
setTimeout(() => { selected.value = '' }, 4000)
|
||||||
const range = document.createRange()
|
|
||||||
range.selectNodeContents(node)
|
|
||||||
const selection = window.getSelection()
|
|
||||||
selection.removeAllRanges()
|
|
||||||
selection.addRange(range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
74
plugins/guides/frontend/utils/templates.js
Normal file
74
plugins/guides/frontend/utils/templates.js
Normal 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: '' },
|
||||||
|
] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>{{ isNew ? 'New guide' : 'Edit guide' }}</h2>
|
<h2>{{ isNew ? 'New guide' : 'Edit guide' }}</h2>
|
||||||
<div class="ge-actions">
|
<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">
|
<button type="button" class="ge-btn" @click="save()" :disabled="saving">
|
||||||
{{ saving ? 'Saving...' : 'Save' }}
|
{{ saving ? 'Saving...' : 'Save' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -13,9 +17,7 @@
|
|||||||
<button v-if="!isNew && canpublish && guide.ispublished" type="button"
|
<button v-if="!isNew && canpublish && guide.ispublished" type="button"
|
||||||
class="ge-btn" @click="unpublish">Withdraw</button>
|
class="ge-btn" @click="unpublish">Withdraw</button>
|
||||||
<router-link v-if="!isNew && guide.slug" class="ge-btn"
|
<router-link v-if="!isNew && guide.slug" class="ge-btn"
|
||||||
:to="{ name: 'guide-view', params: { slug: guide.slug } }">
|
:to="{ name: 'guide-view', params: { slug: guide.slug } }">View</router-link>
|
||||||
View
|
|
||||||
</router-link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -23,86 +25,138 @@
|
|||||||
<div v-if="error" class="error-message">{{ error }}</div>
|
<div v-if="error" class="error-message">{{ error }}</div>
|
||||||
|
|
||||||
<div class="ge-fields">
|
<div class="ge-fields">
|
||||||
<label class="ge-field">
|
<input v-model="guide.title" type="text" class="ge-title"
|
||||||
<span>Title</span>
|
placeholder="Guide title">
|
||||||
<input v-model="guide.title" type="text" placeholder="What is this guide for?">
|
<input v-model="guide.summary" type="text"
|
||||||
</label>
|
placeholder="One line: what this covers and when somebody needs it">
|
||||||
<label class="ge-field">
|
<input v-model="guide.tags" type="text"
|
||||||
<span>Summary</span>
|
placeholder="Tags, comma separated - e.g. partmarker, backups">
|
||||||
<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>
|
||||||
|
|
||||||
<div class="ge-blocks">
|
<!-- The blank page is the barrier, so offer a shape rather than an empty
|
||||||
<div v-for="(block, index) in blocks" :key="block.id" class="ge-block">
|
canvas. Only on a guide that has nothing in it yet. -->
|
||||||
<div class="ge-blockbar">
|
<div v-if="!blocks.length" class="ge-templates">
|
||||||
<span class="ge-type">{{ block.type }}</span>
|
<span>Start from:</span>
|
||||||
<select v-if="block.type === 'callout'" v-model="block.variant" class="ge-mini">
|
<button v-for="tpl in templates" :key="tpl.key" type="button"
|
||||||
<option value="info">info</option>
|
class="ge-tpl" @click="useTemplate(tpl)">
|
||||||
<option value="warning">warning</option>
|
<strong>{{ tpl.name }}</strong>
|
||||||
<option value="success">success</option>
|
<small>{{ tpl.description }}</small>
|
||||||
</select>
|
</button>
|
||||||
<input v-if="block.type === 'code'" v-model="block.language"
|
</div>
|
||||||
class="ge-mini" placeholder="language">
|
|
||||||
<input v-if="block.type === 'code'" v-model="block.caption"
|
<div class="ge-split" :class="{ 'ge-haspreview': showPreview }">
|
||||||
class="ge-mini" placeholder="caption (optional)">
|
<div class="ge-editor">
|
||||||
<div class="ge-blockbtns">
|
<div v-for="(block, index) in blocks" :key="block.id"
|
||||||
<button type="button" @click="move(index, -1)" :disabled="index === 0">Up</button>
|
class="ge-block" :class="{ 'ge-focused': focused === block.id }"
|
||||||
<button type="button" @click="move(index, 1)"
|
@focusin="focused = block.id">
|
||||||
:disabled="index === blocks.length - 1">Down</button>
|
<div class="ge-blockbar">
|
||||||
<button type="button" class="ge-del" @click="remove(index)">Remove</button>
|
<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">
|
||||||
|
<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 | Header | Header Cell | Cell"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input v-if="block.type === 'step'" v-model="block.title" class="ge-steptitle"
|
<div class="ge-add">
|
||||||
placeholder="Step title">
|
<span>Add:</span>
|
||||||
|
<button v-for="type in blocktypes" :key="type" type="button"
|
||||||
<textarea v-if="hasText(block)" v-model="block.text" class="ge-text"
|
@click="add(type)">{{ type }}</button>
|
||||||
:rows="block.type === 'code' ? 6 : 3"
|
<label class="ge-upload">
|
||||||
:placeholder="placeholderFor(block)"></textarea>
|
image
|
||||||
|
<input type="file" accept="image/*" hidden @change="onFilePick">
|
||||||
<div v-if="hasItems(block)" class="ge-items">
|
</label>
|
||||||
<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>
|
||||||
|
|
||||||
<div v-if="block.type === 'image'" class="ge-image">
|
<p class="ge-hint">
|
||||||
<img v-if="block.url" :src="block.url" alt="">
|
Paste or drag an image anywhere to drop it in below the block you are working on.
|
||||||
<input v-model="block.caption" placeholder="Caption (optional)">
|
Text supports **bold**, *italic*, `code` and [links](https://example.com) -
|
||||||
<input v-model="block.alt" placeholder="Alt text, for anyone who cannot see it">
|
or use the buttons.
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="block.type === 'table'" class="ge-tableedit">
|
<!-- The same renderer readers get, so what you see here is what they see.
|
||||||
<textarea v-model="block.raw" rows="4" @input="parseTable(block)"
|
A separate preview implementation would drift within a month. -->
|
||||||
placeholder="One row per line, cells separated by | Header | Header Cell | Cell"></textarea>
|
<div v-if="showPreview" class="ge-preview">
|
||||||
</div>
|
<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>
|
</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">
|
<div v-if="!isNew && revisions.length" class="ge-revisions">
|
||||||
<h3>Version history</h3>
|
<h3>Version history</h3>
|
||||||
<div v-for="rev in revisions" :key="rev.guiderevisionid" class="ge-rev">
|
<div v-for="rev in revisions" :key="rev.guiderevisionid" class="ge-rev">
|
||||||
@@ -117,11 +171,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { apiError } from '@/utils/apiError'
|
import { apiError } from '@/utils/apiError'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import GuideBlocks from '../components/GuideBlocks.vue'
|
||||||
|
import { templates } from '../utils/templates.js'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -137,12 +193,19 @@ const revisions = ref([])
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const status = ref('')
|
||||||
|
const showPreview = ref(true)
|
||||||
|
const focused = ref('')
|
||||||
|
const dirty = ref(false)
|
||||||
|
|
||||||
const isNew = computed(() => !guide.guideid)
|
const isNew = computed(() => !guide.guideid)
|
||||||
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
|
const canpublish = computed(() => auth.hasPermission?.('guides.publish') ?? false)
|
||||||
|
|
||||||
// Stable ids are what make a tick rememberable between visits, so every block
|
const textareas = {}
|
||||||
// and every item gets one at creation and keeps it for life.
|
const itemInputs = {}
|
||||||
|
function registerArea (id, el) { if (el) textareas[id] = el }
|
||||||
|
function registerItem (id, el) { if (el) itemInputs[id] = el }
|
||||||
|
|
||||||
function newid () {
|
function newid () {
|
||||||
if (window.crypto?.randomUUID) return window.crypto.randomUUID().slice(0, 8)
|
if (window.crypto?.randomUUID) return window.crypto.randomUUID().slice(0, 8)
|
||||||
return Math.random().toString(36).slice(2, 10)
|
return Math.random().toString(36).slice(2, 10)
|
||||||
@@ -156,16 +219,41 @@ function hasItems (block) {
|
|||||||
}
|
}
|
||||||
function placeholderFor (block) {
|
function placeholderFor (block) {
|
||||||
if (block.type === 'code') return 'The command, exactly as it should be run'
|
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'
|
if (block.type === 'callout') return 'What somebody needs to know before carrying on'
|
||||||
|
if (block.type === 'heading') return 'Section heading'
|
||||||
return 'Text'
|
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) {
|
function add (type) {
|
||||||
const block = { id: newid(), type }
|
const block = { id: newid(), type }
|
||||||
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
|
if (hasItems(block)) block.items = [{ id: newid(), text: '' }]
|
||||||
if (type === 'callout') block.variant = 'info'
|
if (type === 'callout') block.variant = 'info'
|
||||||
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
|
if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] }
|
||||||
blocks.value.push(block)
|
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 remove (index) { blocks.value.splice(index, 1) }
|
||||||
@@ -177,11 +265,29 @@ function move (index, delta) {
|
|||||||
blocks.value.splice(target, 0, block)
|
blocks.value.splice(target, 0, block)
|
||||||
}
|
}
|
||||||
|
|
||||||
function addItem (block) { block.items.push({ id: newid(), text: '' }) }
|
// Enter makes the next item and focuses it - the way every list in every other
|
||||||
function removeItem (block, index) { block.items.splice(index, 1) }
|
// 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 = ''
|
||||||
|
}
|
||||||
|
|
||||||
// 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) {
|
function parseTable (block) {
|
||||||
const lines = (block.raw || '').split('\n').filter(l => l.trim())
|
const lines = (block.raw || '').split('\n').filter(l => l.trim())
|
||||||
const rows = lines.map(line => line.split('|').map(cell => cell.trim()))
|
const rows = lines.map(line => line.split('|').map(cell => cell.trim()))
|
||||||
@@ -189,6 +295,34 @@ function parseTable (block) {
|
|||||||
block.rows = rows.slice(1)
|
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) {
|
async function uploadImage (file) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
@@ -200,17 +334,19 @@ async function uploadImage (file) {
|
|||||||
|
|
||||||
async function insertImage (file) {
|
async function insertImage (file) {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
status.value = 'Uploading image...'
|
||||||
try {
|
try {
|
||||||
const image = await uploadImage(file)
|
const image = await uploadImage(file)
|
||||||
blocks.value.push({ id: newid(), type: 'image', url: image.url,
|
const block = { id: newid(), type: 'image', url: image.url, caption: '', alt: '' }
|
||||||
caption: '', alt: '' })
|
blocks.value.splice(insertIndex(), 0, block)
|
||||||
|
focused.value = block.id
|
||||||
|
status.value = ''
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
status.value = ''
|
||||||
error.value = apiError(err, 'Could not upload that image')
|
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) {
|
function onPaste (event) {
|
||||||
const items = Array.from(event.clipboardData?.items || [])
|
const items = Array.from(event.clipboardData?.items || [])
|
||||||
const imageItem = items.find(item => item.type.startsWith('image/'))
|
const imageItem = items.find(item => item.type.startsWith('image/'))
|
||||||
@@ -233,49 +369,68 @@ function onFilePick (event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function payload () {
|
function payload () {
|
||||||
return {
|
return { title: guide.title, summary: guide.summary || '',
|
||||||
title: guide.title,
|
tags: guide.tags || '', blocks: blocks.value }
|
||||||
summary: guide.summary || '',
|
|
||||||
tags: guide.tags || '',
|
|
||||||
blocks: blocks.value,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save (quiet) {
|
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
|
saving.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
message.value = ''
|
|
||||||
try {
|
try {
|
||||||
if (isNew.value) {
|
if (isNew.value) {
|
||||||
const response = await api.post('/guides', payload())
|
const response = await api.post('/guides', payload())
|
||||||
const created = response.data?.data || response.data
|
const created = response.data?.data || response.data
|
||||||
Object.assign(guide, { guideid: created.guideid, slug: created.slug,
|
Object.assign(guide, { guideid: created.guideid, slug: created.slug })
|
||||||
ispublished: false })
|
|
||||||
router.replace({ name: 'guide-edit', params: { guideid: created.guideid } })
|
router.replace({ name: 'guide-edit', params: { guideid: created.guideid } })
|
||||||
message.value = 'Guide created'
|
|
||||||
} else {
|
} else {
|
||||||
const response = await api.put('/guides/' + guide.guideid, payload())
|
const response = await api.put('/guides/' + guide.guideid, payload())
|
||||||
const saved = response.data?.data || response.data
|
const saved = response.data?.data || response.data
|
||||||
guide.slug = saved.slug
|
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'
|
|
||||||
}
|
}
|
||||||
|
dirty.value = false
|
||||||
|
status.value = 'Saved ' + new Date().toLocaleTimeString()
|
||||||
await loadRevisions()
|
await loadRevisions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = apiError(err, 'Could not save this guide')
|
error.value = apiError(err, 'Could not save this guide')
|
||||||
|
status.value = 'Not saved'
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
if (!quiet) setTimeout(() => { message.value = '' }, 2500)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 () {
|
async function publish () {
|
||||||
await save(true)
|
await save(true)
|
||||||
try {
|
try {
|
||||||
const response = await api.post('/guides/' + guide.guideid + '/publish')
|
const response = await api.post('/guides/' + guide.guideid + '/publish')
|
||||||
guide.ispublished = true
|
guide.ispublished = true
|
||||||
message.value = response.data?.message || 'Published'
|
message.value = response.data?.message || 'Published'
|
||||||
|
setTimeout(() => { message.value = '' }, 3000)
|
||||||
await loadRevisions()
|
await loadRevisions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = apiError(err, 'Could not publish this guide')
|
error.value = apiError(err, 'Could not publish this guide')
|
||||||
@@ -287,6 +442,7 @@ async function unpublish () {
|
|||||||
await api.post('/guides/' + guide.guideid + '/unpublish')
|
await api.post('/guides/' + guide.guideid + '/unpublish')
|
||||||
guide.ispublished = false
|
guide.ispublished = false
|
||||||
message.value = 'Withdrawn from readers'
|
message.value = 'Withdrawn from readers'
|
||||||
|
setTimeout(() => { message.value = '' }, 3000)
|
||||||
await loadRevisions()
|
await loadRevisions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = apiError(err, 'Could not withdraw this guide')
|
error.value = apiError(err, 'Could not withdraw this guide')
|
||||||
@@ -294,6 +450,7 @@ async function unpublish () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function restore (rev) {
|
async function restore (rev) {
|
||||||
|
if (!window.confirm('Bring version ' + rev.revision + ' back as a new draft version?')) return
|
||||||
try {
|
try {
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
'/guides/' + guide.guideid + '/restore/' + rev.guiderevisionid)
|
'/guides/' + guide.guideid + '/restore/' + rev.guiderevisionid)
|
||||||
@@ -301,6 +458,7 @@ async function restore (rev) {
|
|||||||
blocks.value = restored.blocks || []
|
blocks.value = restored.blocks || []
|
||||||
Object.assign(guide, { title: restored.title, summary: restored.summary || '',
|
Object.assign(guide, { title: restored.title, summary: restored.summary || '',
|
||||||
tags: (restored.tags || []).join(', ') })
|
tags: (restored.tags || []).join(', ') })
|
||||||
|
dirty.value = false
|
||||||
message.value = response.data?.message || 'Restored'
|
message.value = response.data?.message || 'Restored'
|
||||||
await loadRevisions()
|
await loadRevisions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -340,6 +498,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
return block
|
return block
|
||||||
})
|
})
|
||||||
|
await nextTick()
|
||||||
|
dirty.value = false
|
||||||
|
status.value = ''
|
||||||
await loadRevisions()
|
await loadRevisions()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = apiError(err, 'Could not load this guide')
|
error.value = apiError(err, 'Could not load this guide')
|
||||||
@@ -349,7 +510,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
||||||
.ge-actions { display: flex; gap: 0.4rem; }
|
.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 {
|
.ge-btn {
|
||||||
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
|
font-size: 0.8rem; padding: 0.3rem 0.7rem; border: 1px solid var(--primary);
|
||||||
color: var(--primary); background: transparent; border-radius: 4px;
|
color: var(--primary); background: transparent; border-radius: 4px;
|
||||||
@@ -358,65 +520,96 @@ onMounted(async () => {
|
|||||||
.ge-primary { background: var(--primary); color: #fff; }
|
.ge-primary { background: var(--primary); color: #fff; }
|
||||||
.ge-btn:disabled { opacity: 0.5; cursor: default; }
|
.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-fields { display: grid; gap: 0.4rem; margin: 0.8rem 0 1rem; max-width: 45rem; }
|
||||||
.ge-field { display: grid; gap: 0.2rem; }
|
.ge-fields input {
|
||||||
.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;
|
padding: 0.4rem 0.55rem; border: 1px solid var(--border); border-radius: 4px;
|
||||||
background: var(--surface, transparent); color: var(--text);
|
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 {
|
.ge-block {
|
||||||
border: 1px solid var(--border); border-radius: 5px; padding: 0.6rem;
|
border: 1px solid var(--border); border-radius: 5px; padding: 0.5rem;
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
|
||||||
.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-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 {
|
.ge-mini {
|
||||||
font-size: 0.75rem; padding: 0.15rem 0.35rem; border: 1px solid var(--border);
|
font-size: 0.72rem; padding: 0.12rem 0.3rem; border: 1px solid var(--border);
|
||||||
border-radius: 3px; background: var(--surface, transparent); color: var(--text);
|
border-radius: 3px; background: var(--surface, transparent); color: var(--text);
|
||||||
max-width: 10rem;
|
max-width: 8rem;
|
||||||
}
|
}
|
||||||
.ge-blockbtns { margin-left: auto; display: flex; gap: 0.25rem; }
|
.ge-format { display: flex; gap: 0.15rem; }
|
||||||
.ge-blockbtns button {
|
.ge-format button, .ge-blockbtns button {
|
||||||
font-size: 0.72rem; padding: 0.12rem 0.4rem; border: 1px solid var(--border);
|
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;
|
background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer;
|
||||||
|
min-width: 1.5rem;
|
||||||
}
|
}
|
||||||
.ge-del:hover { border-color: var(--danger, #c0392b); color: var(--danger, #c0392b); }
|
.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 {
|
.ge-text, .ge-steptitle, .ge-tableedit textarea {
|
||||||
width: 100%; padding: 0.45rem 0.55rem; border: 1px solid var(--border);
|
width: 100%; padding: 0.4rem 0.5rem; border: 1px solid var(--border);
|
||||||
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
||||||
font-family: inherit; font-size: 0.9rem;
|
font-family: inherit; font-size: 0.88rem; resize: vertical;
|
||||||
}
|
}
|
||||||
.ge-steptitle { margin-bottom: 0.4rem; }
|
.ge-mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.82rem; }
|
||||||
.ge-items { display: grid; gap: 0.3rem; }
|
.ge-steptitle { margin-bottom: 0.35rem; }
|
||||||
.ge-item { display: flex; gap: 0.3rem; }
|
|
||||||
|
.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 {
|
.ge-item input {
|
||||||
flex: 1; padding: 0.35rem 0.5rem; border: 1px solid var(--border);
|
flex: 1; padding: 0.3rem 0.45rem; border: 1px solid var(--border);
|
||||||
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
border-radius: 4px; background: var(--surface, transparent); color: var(--text);
|
||||||
}
|
}
|
||||||
.ge-additem, .ge-add button, .ge-upload {
|
.ge-item button, .ge-additem, .ge-add button, .ge-upload {
|
||||||
font-size: 0.75rem; padding: 0.2rem 0.55rem; border: 1px solid var(--border);
|
font-size: 0.72rem; padding: 0.15rem 0.45rem; border: 1px solid var(--border);
|
||||||
background: transparent; color: var(--text-light); border-radius: 3px;
|
background: transparent; color: var(--text-light); border-radius: 3px;
|
||||||
cursor: pointer; justify-self: start;
|
cursor: pointer; justify-self: start;
|
||||||
}
|
}
|
||||||
.ge-image img { max-width: 20rem; display: block; margin-bottom: 0.4rem;
|
.ge-image img { max-width: 18rem; display: block; margin-bottom: 0.35rem;
|
||||||
border: 1px solid var(--border); border-radius: 4px; }
|
border: 1px solid var(--border); border-radius: 4px; }
|
||||||
.ge-image input { width: 100%; margin-bottom: 0.3rem; padding: 0.3rem 0.5rem;
|
.ge-image input { width: 100%; margin-bottom: 0.25rem; padding: 0.3rem 0.45rem;
|
||||||
border: 1px solid var(--border); border-radius: 4px;
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
background: var(--surface, transparent); color: var(--text); }
|
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 { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; margin: 0.8rem 0 0.4rem; }
|
||||||
.ge-add > span { font-size: 0.78rem; color: var(--text-light); }
|
.ge-add > span { font-size: 0.75rem; color: var(--text-light); }
|
||||||
.ge-hint { font-size: 0.78rem; 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-revisions { margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 0.8rem; }
|
.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;
|
.ge-rev { display: flex; gap: 0.6rem; align-items: center; font-size: 0.8rem; padding: 0.25rem 0; }
|
||||||
padding: 0.25rem 0; }
|
|
||||||
.ge-revnum { font-weight: 600; }
|
.ge-revnum { font-weight: 600; }
|
||||||
.ge-revwhen, .ge-revnote { color: var(--text-light); }
|
.ge-revwhen, .ge-revnote { color: var(--text-light); }
|
||||||
.ge-revpub { font-size: 0.68rem; padding: 0.05rem 0.35rem; border-radius: 3px;
|
.ge-revpub { font-size: 0.68rem; padding: 0.05rem 0.35rem; border-radius: 3px;
|
||||||
|
|||||||
Reference in New Issue
Block a user