287 lines
11 KiB
Vue
287 lines
11 KiB
Vue
<template>
|
|
<div class="gb-blocks" :class="{ 'gb-interactive': interactive }">
|
|
<template v-for="(block, index) in blocks" :key="block.id || index">
|
|
|
|
<h2 v-if="block.type === 'heading'" :id="anchorFor(block)" class="gb-heading"
|
|
v-html="inline(block.text)"></h2>
|
|
|
|
<p v-else-if="block.type === 'paragraph'" class="gb-para"
|
|
v-html="inline(block.text)"></p>
|
|
|
|
<div v-else-if="block.type === 'step'" class="gb-step">
|
|
<span class="gb-stepnum">Step {{ stepNumber(index) }}</span>
|
|
<h3 v-if="block.title" v-html="inline(block.title)"></h3>
|
|
<div v-if="block.text" v-html="inline(block.text)"></div>
|
|
</div>
|
|
|
|
<!--
|
|
The reason this feature exists. Interactive ONLY when viewing: in the
|
|
editor a tick would be an edit, and an author clicking around their own
|
|
draft would quietly change what the next reader sees as "done".
|
|
-->
|
|
<ul v-else-if="block.type === 'checklist'" class="gb-checklist">
|
|
<li v-for="item in itemsOf(block)" :key="item.id" class="gb-check">
|
|
<label>
|
|
<input type="checkbox"
|
|
:checked="isTicked(item.id)"
|
|
:disabled="!interactive"
|
|
@change="toggle(item.id, $event.target.checked)">
|
|
<span :class="{ 'gb-done': interactive && isTicked(item.id) }"
|
|
v-html="inline(item.text)"></span>
|
|
</label>
|
|
</li>
|
|
</ul>
|
|
|
|
<ul v-else-if="block.type === 'bullets'" class="gb-list">
|
|
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
|
</ul>
|
|
|
|
<ol v-else-if="block.type === 'numbers'" class="gb-list">
|
|
<li v-for="item in itemsOf(block)" :key="item.id" v-html="inline(item.text)"></li>
|
|
</ol>
|
|
|
|
<div v-else-if="block.type === 'callout'"
|
|
class="gb-callout" :class="'gb-' + (block.variant || 'info')">
|
|
<strong v-if="block.title">{{ block.title }}</strong>
|
|
<div v-html="inline(block.text)"></div>
|
|
</div>
|
|
|
|
<div v-else-if="block.type === 'code'" class="gb-code">
|
|
<div class="gb-codehead">
|
|
<span class="gb-lang">{{ block.language || 'text' }}</span>
|
|
<span v-if="block.caption" class="gb-caption">{{ block.caption }}</span>
|
|
<!--
|
|
SELECTS the code; it does not write to the clipboard.
|
|
|
|
A page that programmatically copies a shell command is the exact
|
|
shape of a ClickFix attack - malware that plants a command in your
|
|
clipboard and talks you into pasting it into Run. uBlock Origin
|
|
blocks it, and it was right to: our button wrote PowerShell to the
|
|
clipboard on click.
|
|
|
|
Selecting the text and letting the person press Ctrl+C keeps the
|
|
convenience, moves the decision back to them, and gives security
|
|
tooling nothing to object to. It also selects the code ONLY - no
|
|
caption, no prompt, so nobody ends up pasting "PS C:\>" into a
|
|
terminal.
|
|
-->
|
|
<button type="button" class="gb-copy" @click="select(block)">
|
|
{{ selected === block.id ? 'Selected - press Ctrl+C' : 'Select' }}
|
|
</button>
|
|
</div>
|
|
<pre :ref="el => registerCode(block.id, el)"><code>{{ block.text }}</code></pre>
|
|
</div>
|
|
|
|
<!--
|
|
The image is fetched by script, not by the browser.
|
|
|
|
An <img src> is a plain browser request: it carries no Authorization
|
|
header, and this app keeps its JWT in one. So a direct src against the
|
|
authenticated image route is a 401 and a broken-image icon for every
|
|
reader - which is exactly how this shipped. Fetching through the api
|
|
client sends the token, and it resolves against the client's baseURL, so
|
|
it also survives a subpath mount that a stored '/api/...' string does
|
|
not.
|
|
|
|
A failure says so in words. A guide whose screenshot is missing must not
|
|
look like a guide that never had one.
|
|
-->
|
|
<figure v-else-if="block.type === 'image'" class="gb-figure">
|
|
<img v-if="imageState(block).src" :src="imageState(block).src"
|
|
:alt="block.alt || ''">
|
|
<p v-else-if="imageState(block).failed" class="gb-imagefailed">
|
|
Image unavailable{{ block.alt ? ': ' + block.alt : '' }}
|
|
</p>
|
|
<p v-else class="gb-imageloading">Loading image...</p>
|
|
<figcaption v-if="block.caption">{{ block.caption }}</figcaption>
|
|
</figure>
|
|
|
|
<div v-else-if="block.type === 'table'" class="gb-tablewrap">
|
|
<table class="gb-table">
|
|
<thead v-if="block.header && block.header.length">
|
|
<tr><th v-for="(cell, c) in block.header" :key="c" v-html="inline(cell)"></th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(row, r) in (block.rows || [])" :key="r">
|
|
<td v-for="(cell, c) in row" :key="c" v-html="inline(cell)"></td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- An unknown type renders as nothing rather than as an error. A guide
|
|
written against a newer version must stay readable on an older one. -->
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, toRef } from 'vue'
|
|
import { renderInline } from '../utils/inline.js'
|
|
import { useGuideImages } from '../utils/images.js'
|
|
|
|
const props = defineProps({
|
|
blocks: { type: Array, default: () => [] },
|
|
// Live checkboxes belong to the viewer only.
|
|
interactive: { type: Boolean, default: false },
|
|
ticked: { type: Object, default: () => ({}) },
|
|
})
|
|
const emit = defineEmits(['toggle'])
|
|
|
|
const selected = ref('')
|
|
const codeNodes = {}
|
|
function registerCode (id, el) { if (el) codeNodes[id] = el }
|
|
|
|
// Images are fetched by script, not by <img src>; see utils/images.js.
|
|
const { state: imageState } = useGuideImages(toRef(props, 'blocks'))
|
|
|
|
function inline (text) {
|
|
return renderInline(text)
|
|
}
|
|
|
|
// Items may be plain strings (bullets) or objects with ids (checklists). One
|
|
// shape here so the template does not branch on it.
|
|
function itemsOf (block) {
|
|
return (block.items || []).map((item, index) =>
|
|
typeof item === 'string'
|
|
? { id: (block.id || 'b') + '-' + index, text: item }
|
|
: item)
|
|
}
|
|
|
|
function isTicked (itemid) {
|
|
return Boolean(props.ticked[itemid])
|
|
}
|
|
|
|
function toggle (itemid, checked) {
|
|
if (!props.interactive) return
|
|
emit('toggle', itemid, checked)
|
|
}
|
|
|
|
// Steps number themselves by position among steps, so inserting a paragraph
|
|
// between two of them does not renumber anything by hand.
|
|
const stepIndex = computed(() => {
|
|
const map = {}
|
|
let n = 0
|
|
props.blocks.forEach((block, index) => {
|
|
if (block.type === 'step') { n += 1; map[index] = n }
|
|
})
|
|
return map
|
|
})
|
|
function stepNumber (index) {
|
|
return stepIndex.value[index] || 1
|
|
}
|
|
|
|
function anchorFor (block) {
|
|
return 'g-' + (block.id || '')
|
|
}
|
|
|
|
function select (block) {
|
|
const node = codeNodes[block.id]
|
|
if (!node || !window.getSelection) return
|
|
const range = document.createRange()
|
|
range.selectNodeContents(node)
|
|
const selection = window.getSelection()
|
|
selection.removeAllRanges()
|
|
selection.addRange(range)
|
|
selected.value = block.id
|
|
setTimeout(() => { selected.value = '' }, 4000)
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Every colour is a theme token. Nothing here is a literal, which is what makes
|
|
dark mode work with no dark-mode code. */
|
|
.gb-blocks { line-height: 1.65; }
|
|
.gb-heading { margin: 1.6rem 0 0.6rem; font-size: 1.35rem; color: var(--text); }
|
|
.gb-para { margin: 0 0 0.9rem; color: var(--text); }
|
|
|
|
.gb-step {
|
|
border-left: 3px solid var(--primary);
|
|
padding: 0.6rem 0 0.6rem 1rem;
|
|
margin: 0 0 1rem;
|
|
}
|
|
.gb-stepnum {
|
|
display: inline-block; font-size: 0.72rem; text-transform: uppercase;
|
|
letter-spacing: 0.06em; color: var(--primary); font-weight: 600;
|
|
}
|
|
.gb-step h3 { margin: 0.2rem 0 0.4rem; font-size: 1.05rem; }
|
|
|
|
.gb-checklist { list-style: none; padding: 0; margin: 0 0 1rem; }
|
|
.gb-check { margin: 0.35rem 0; }
|
|
.gb-check label { display: flex; gap: 0.6rem; align-items: flex-start; cursor: pointer; }
|
|
.gb-interactive .gb-check label:hover { color: var(--primary); }
|
|
.gb-check input { margin-top: 0.28rem; flex: none; width: 1rem; height: 1rem; }
|
|
.gb-done { opacity: 0.55; text-decoration: line-through; }
|
|
|
|
.gb-list { margin: 0 0 1rem 1.2rem; }
|
|
.gb-list li { margin: 0.25rem 0; }
|
|
|
|
.gb-callout {
|
|
border-left: 4px solid var(--info, var(--primary));
|
|
background: var(--surface-2, rgba(127, 127, 127, 0.08));
|
|
padding: 0.75rem 1rem; margin: 0 0 1rem; border-radius: 4px;
|
|
}
|
|
.gb-callout strong { display: block; margin-bottom: 0.25rem; }
|
|
.gb-warning { border-left-color: var(--warning, #d9822b); }
|
|
.gb-success { border-left-color: var(--success, #2e9e5b); }
|
|
|
|
.gb-code { margin: 0 0 1rem; border: 1px solid var(--border); border-radius: 5px; overflow: hidden; }
|
|
.gb-codehead {
|
|
display: flex; align-items: center; gap: 0.6rem;
|
|
padding: 0.3rem 0.6rem; background: var(--surface-2, rgba(127,127,127,0.08));
|
|
border-bottom: 1px solid var(--border); font-size: 0.75rem;
|
|
}
|
|
.gb-lang { text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-light); }
|
|
.gb-caption { color: var(--text-light); }
|
|
.gb-copy {
|
|
margin-left: auto; font-size: 0.72rem; padding: 0.15rem 0.5rem;
|
|
border: 1px solid var(--border); background: transparent;
|
|
color: var(--text-light); border-radius: 3px; cursor: pointer;
|
|
}
|
|
.gb-copy:hover { border-color: var(--primary); color: var(--primary); }
|
|
.gb-code pre {
|
|
margin: 0; padding: 0.7rem 0.8rem; overflow-x: auto;
|
|
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 0.84rem;
|
|
/* WRAP rather than clip: these guides are full of long UNC paths, and a
|
|
line that scrolls out of sight is a command somebody copies half of. */
|
|
white-space: pre-wrap; word-break: break-word;
|
|
}
|
|
|
|
.gb-figure { margin: 0 0 1.2rem; }
|
|
.gb-figure img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: 4px; }
|
|
.gb-imageloading, .gb-imagefailed {
|
|
margin: 0; padding: 0.75rem 1rem; border: 1px dashed var(--border);
|
|
border-radius: 4px; font-size: 0.9rem;
|
|
}
|
|
.gb-imageloading { color: var(--textmuted); }
|
|
.gb-imagefailed { color: var(--danger, #b3261e); }
|
|
.gb-figure figcaption { font-size: 0.8rem; color: var(--text-light); margin-top: 0.35rem; }
|
|
|
|
.gb-tablewrap { overflow-x: auto; margin: 0 0 1.2rem; }
|
|
.gb-table { border-collapse: collapse; width: 100%; }
|
|
.gb-table th, .gb-table td {
|
|
border: 1px solid var(--border); padding: 0.45rem 0.6rem; text-align: left;
|
|
vertical-align: top; font-size: 0.9rem;
|
|
}
|
|
.gb-table th { background: var(--surface-2, rgba(127,127,127,0.08)); }
|
|
|
|
@media print {
|
|
/* Paper has no hover, no clipboard and no theme. The light-mode token
|
|
overrides live in GuideView.vue, which can reach :root; this block is only
|
|
about what belongs on paper. */
|
|
.gb-copy { display: none; }
|
|
.gb-step, .gb-callout, .gb-code, .gb-figure, .gb-check { break-inside: avoid; }
|
|
|
|
/* Checkboxes print EMPTY on purpose, even where they are ticked on screen:
|
|
a printed guide is a worksheet somebody carries to the bay and ticks by
|
|
hand, and one person's progress is not part of the procedure. */
|
|
.gb-done { opacity: 1; text-decoration: none; }
|
|
.gb-check input { appearance: none; -webkit-appearance: none;
|
|
width: 0.85rem; height: 0.85rem; border: 1px solid #000;
|
|
border-radius: 2px; background: #fff; }
|
|
|
|
.gb-code pre { white-space: pre-wrap; }
|
|
.gb-stepnum { color: #000; }
|
|
}
|
|
</style>
|