Files
shopdb-flask/plugins/guides/frontend/views/GuideView.vue
cproudlock ed5d64fa3e Name the record in the browser tab, not just the section
Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two
different machines were indistinguishable, a bookmark said nothing about what
it pointed at, and a link shared into Teams previewed as the section rather
than the thing.

The router cannot fix this alone: afterEach runs before the view has fetched
anything, so it only ever knows the KIND of page. So a detail view names its
record once the record arrives, and both writers go through
composables/pageTitle.js.

THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build
the "<facility> ShopDB" prefix, so on a cold load it finishes AFTER a view that
fetched quickly and would overwrite the good title with the generic one. The
route's fullPath is claimed before that await, and the generic write stands
down when a record title already belongs to the route being landed on. The
reverse case is handled too: a view that names its record BEFORE the facility
name arrives composed against the placeholder prefix, so the late resolve
rewrites it rather than leaving "ShopDB - CSF16" on a site called West
Jefferson. Both are pinned by tests, because both fail only under timing.

Covers machines, PCs, printers, network devices, subnets, measuring tools, USB
devices, printed parts, applications, employees, knowledge base articles and
guides. Printers reuse the page's own displayTitle, which already discards the
literal name "NONE" a discovered queue can carry, so the tab and the heading
agree.

A record with no usable name, or one that failed to load, keeps the section
name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs.
Long names are cut at a word boundary - knowledge base articles are named with
a whole sentence of description, which a browser otherwise truncates mid-word.

Reports gained meta.title, which the router already supported and nothing used.
That also fixes "Pc Relationships", which the name-derived spelling produced.
2026-08-26 13:13:30 -04:00

274 lines
9.0 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()
}
const totalChecks = computed(() => {
let total = 0
for (const block of guide.value?.blocks || []) {
if (block.type === 'checklist') total += (block.items || []).length
}
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>