Only checklist items could be ticked, which answers "what have I done" but not the question a long procedure actually raises: where was I. Somebody looks away to do the thing the guide asked for and looks back at a wall of text. Every step and every heading now carries a checkbox while reading. Ticking one strikes it through and fades it rather than hiding it, because somebody scanning back up the page has to still be able to read what they did. The number is struck through with it, but the step's own "Step 3" label is not - that is the address of the thing, not part of what was done. Headings count, not just steps. A guide converted from an HTML article often has fifty headings and no steps at all, and those are exactly the guides long enough to lose your place in. That also means the card's "N steps to tick" stops reading as a broken feature on a guide that is entirely tickable. Reading only, as the checklists already were: 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. Progress stays per browser and per version - shopfloor PCs share a login, so one technician's ticks must not greet the next person on that bay, and step 4 of version 3 is not step 4 of version 2.
278 lines
9.3 KiB
Vue
278 lines
9.3 KiB
Vue
<template>
|
|
<div class="gv">
|
|
<div v-if="loading" class="gv-loading">Loading...</div>
|
|
<div v-else-if="error" class="error-message">{{ error }}</div>
|
|
|
|
<template v-else-if="guide">
|
|
<div class="gv-head">
|
|
<div class="gv-headmain">
|
|
<h1>{{ guide.title }}</h1>
|
|
<p v-if="guide.summary" class="gv-summary">{{ guide.summary }}</p>
|
|
<div class="gv-meta">
|
|
<span v-if="guide.isdraftpreview" class="gv-badge gv-draft">Draft preview</span>
|
|
<span v-else class="gv-badge">Version {{ guide.revision }}</span>
|
|
<span v-for="tag in guide.tags" :key="tag" class="gv-tag"
|
|
@click="$router.push({ name: 'guides', query: { tag } })">{{ tag }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="gv-actions">
|
|
<button type="button" class="gv-btn" @click="print">Print</button>
|
|
<router-link v-if="guide.canedit" class="gv-btn"
|
|
:to="{ name: 'guide-edit', params: { guideid: guide.guideid } }">
|
|
Edit
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- An editor needs to know there is newer work than what readers see,
|
|
without leaving the page to find out. -->
|
|
<div v-if="guide.hasunpublishedchanges && guide.canedit" class="gv-notice">
|
|
There are unpublished changes. Readers are seeing version {{ guide.revision }}.
|
|
</div>
|
|
|
|
<div v-if="totalChecks" class="gv-progress">
|
|
<div class="gv-progressbar">
|
|
<div class="gv-progressfill" :style="{ width: progressPercent + '%' }"></div>
|
|
</div>
|
|
<span class="gv-progresstext">{{ tickedCount }} of {{ totalChecks }} done</span>
|
|
<button v-if="tickedCount" type="button" class="gv-reset" @click="resetProgress">
|
|
Reset
|
|
</button>
|
|
</div>
|
|
|
|
<GuideBlocks :blocks="guide.blocks" :interactive="true"
|
|
:ticked="ticked" @toggle="toggle" />
|
|
|
|
<div class="gv-foot">
|
|
<span>Version {{ guide.revision }}</span>
|
|
<span v-if="guide.publishedat">Published {{ formatDate(guide.publishedat) }}</span>
|
|
<span class="gv-printedon">Printed {{ today }}</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import api from '@/api'
|
|
import { apiError } from '@/utils/apiError'
|
|
import GuideBlocks from '../components/GuideBlocks.vue'
|
|
import { setRecordTitle } from '@/composables/pageTitle'
|
|
|
|
const route = useRoute()
|
|
const guide = ref(null)
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const ticked = ref({})
|
|
|
|
const today = new Date().toLocaleDateString()
|
|
|
|
/**
|
|
* Progress lives in the BROWSER, not the database.
|
|
*
|
|
* Shopfloor PCs share a login. Storing progress per user would mean one
|
|
* technician's ticks appearing for the next person on that bay, which is worse
|
|
* than useless on a procedure - it reads as "already done". Per browser, each
|
|
* station keeps its own place, and the Reset button is right there.
|
|
*
|
|
* Keyed by guide id and version: a new version invalidates old ticks, because
|
|
* step 4 of version 3 is not step 4 of version 2.
|
|
*/
|
|
function storageKey () {
|
|
return guide.value
|
|
? 'guides.progress.' + guide.value.guideid + '.v' + guide.value.revision
|
|
: null
|
|
}
|
|
|
|
function loadProgress () {
|
|
const key = storageKey()
|
|
if (!key) return
|
|
try {
|
|
ticked.value = JSON.parse(window.localStorage.getItem(key) || '{}')
|
|
} catch (err) {
|
|
ticked.value = {}
|
|
}
|
|
}
|
|
|
|
function saveProgress () {
|
|
const key = storageKey()
|
|
if (!key) return
|
|
try {
|
|
window.localStorage.setItem(key, JSON.stringify(ticked.value))
|
|
} catch (err) {
|
|
// Private window, or storage disabled. The checkboxes still work for this
|
|
// visit; they just will not be remembered. Not worth an error message.
|
|
}
|
|
}
|
|
|
|
function toggle (itemid, checked) {
|
|
if (checked) ticked.value = { ...ticked.value, [itemid]: true }
|
|
else {
|
|
const next = { ...ticked.value }
|
|
delete next[itemid]
|
|
ticked.value = next
|
|
}
|
|
saveProgress()
|
|
}
|
|
|
|
function resetProgress () {
|
|
ticked.value = {}
|
|
saveProgress()
|
|
}
|
|
|
|
// Everything that can be ticked: checklist items, and the sections a reader
|
|
// crosses off to keep their place - a step, or a heading on a guide that has
|
|
// no steps at all (a converted article often has fifty headings and none).
|
|
const totalChecks = computed(() => {
|
|
let total = 0
|
|
for (const block of guide.value?.blocks || []) {
|
|
if (block.type === 'checklist') total += (block.items || []).length
|
|
else if (block.type === 'step' || block.type === 'heading') total += 1
|
|
}
|
|
return total
|
|
})
|
|
const tickedCount = computed(() => Object.keys(ticked.value).length)
|
|
const progressPercent = computed(() =>
|
|
totalChecks.value ? Math.round((tickedCount.value / totalChecks.value) * 100) : 0)
|
|
|
|
function formatDate (value) {
|
|
return value ? new Date(value).toLocaleDateString() : ''
|
|
}
|
|
|
|
function print () {
|
|
window.print()
|
|
}
|
|
|
|
async function load () {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const response = await api.get('/guides/' + route.params.slug)
|
|
guide.value = response.data?.data || response.data
|
|
setRecordTitle(guide.value?.title)
|
|
loadProgress()
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Could not load this guide')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(load)
|
|
watch(() => route.params.slug, load)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.gv { max-width: 60rem; }
|
|
.gv-head { display: flex; align-items: flex-start; gap: 1rem; margin-bottom: 0.5rem; }
|
|
.gv-headmain { flex: 1; }
|
|
.gv-head h1 { margin: 0 0 0.3rem; }
|
|
.gv-summary { color: var(--text-light); margin: 0 0 0.5rem; }
|
|
.gv-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
|
|
.gv-badge {
|
|
font-size: 0.72rem; padding: 0.1rem 0.45rem; border-radius: 3px;
|
|
border: 1px solid var(--border); color: var(--text-light);
|
|
}
|
|
.gv-draft { border-color: var(--warning, #d9822b); color: var(--warning, #d9822b); }
|
|
.gv-tag {
|
|
font-size: 0.72rem; padding: 0.1rem 0.5rem; border-radius: 999px;
|
|
background: var(--surface-2, rgba(127,127,127,0.1)); cursor: pointer;
|
|
}
|
|
.gv-tag:hover { color: var(--primary); }
|
|
.gv-actions { display: flex; gap: 0.4rem; }
|
|
.gv-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;
|
|
}
|
|
.gv-btn:hover { background: var(--primary); color: #fff; }
|
|
|
|
.gv-notice {
|
|
border-left: 4px solid var(--warning, #d9822b);
|
|
background: var(--surface-2, rgba(127,127,127,0.08));
|
|
padding: 0.5rem 0.8rem; margin: 0.5rem 0 1rem; font-size: 0.85rem;
|
|
}
|
|
|
|
.gv-progress { display: flex; align-items: center; gap: 0.7rem; margin: 1rem 0 1.4rem; }
|
|
.gv-progressbar {
|
|
flex: 1; height: 6px; border-radius: 3px; overflow: hidden;
|
|
background: var(--surface-2, rgba(127,127,127,0.15));
|
|
}
|
|
.gv-progressfill { height: 100%; background: var(--primary); transition: width 0.2s; }
|
|
.gv-progresstext { font-size: 0.78rem; color: var(--text-light); white-space: nowrap; }
|
|
.gv-reset {
|
|
font-size: 0.72rem; background: none; border: none; color: var(--text-light);
|
|
cursor: pointer; text-decoration: underline;
|
|
}
|
|
|
|
.gv-foot {
|
|
margin-top: 2rem; padding-top: 0.6rem; border-top: 1px solid var(--border);
|
|
display: flex; gap: 1rem; font-size: 0.75rem; color: var(--text-light);
|
|
}
|
|
.gv-printedon { display: none; }
|
|
|
|
@media print {
|
|
/* A printed guide is a worksheet: no controls, everything visible, and the
|
|
footer says which version it was so a stale printout on a noticeboard can
|
|
be spotted. */
|
|
.gv-actions, .gv-progress, .gv-notice, .gv-reset { display: none !important; }
|
|
.gv-printedon { display: inline; }
|
|
.gv { max-width: none; }
|
|
.gv-head h1 { font-size: 1.6rem; }
|
|
.gv-foot { border-top: 1px solid #999; }
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/*
|
|
* PRINT IS ALWAYS LIGHT, whatever the screen theme is.
|
|
*
|
|
* Printing a dark-mode page lays down a full page of toner for the background
|
|
* and then knocks the text out of it in white. On a shopfloor printer that is
|
|
* a cartridge for a handful of guides, and the result is harder to read than
|
|
* the plain version.
|
|
*
|
|
* So the theme tokens are redefined for print only. Every guide element already
|
|
* renders through these variables rather than literal colours, which is what
|
|
* makes this a dozen lines instead of a rewrite - and the same discipline that
|
|
* made dark mode work at all.
|
|
*
|
|
* Deliberately NOT scoped: a scoped block cannot reach :root, and these have to
|
|
* override tokens set on the document element.
|
|
*/
|
|
@media print {
|
|
:root,
|
|
:root[data-theme="dark"],
|
|
:root[data-theme="light"] {
|
|
--text: #000;
|
|
--text-light: #333;
|
|
--border: #999;
|
|
--surface: #fff;
|
|
--surface-2: #fff;
|
|
--primary: #000;
|
|
--info: #666;
|
|
--warning: #666;
|
|
--success: #666;
|
|
}
|
|
|
|
html, body, .app-layout, .main-content {
|
|
background: #fff !important;
|
|
color: #000 !important;
|
|
}
|
|
|
|
/* Backgrounds are dropped rather than printed grey: a callout is identified
|
|
by its rule and its heading, which cost nothing to print. */
|
|
.gb-callout, .gb-code, .gb-codehead, .gb-table th {
|
|
background: #fff !important;
|
|
}
|
|
.gb-callout { border-left: 3px solid #000 !important; }
|
|
.gb-code, .gb-table th, .gb-table td { border-color: #999 !important; }
|
|
|
|
/* Links: the text is what matters on paper, and a blue underline on a mono
|
|
printer is just grey. */
|
|
a { color: #000 !important; text-decoration: underline; }
|
|
}
|
|
</style>
|