The knowledgebase plugin is a link registry - a description, a URL and some keywords pointing at a document held somewhere else. That leaves the documents themselves as HTML files on a share: no search, no history, no way to know whether the copy pinned to a noticeboard is current, and no way for a technician to tick off where they are. A guide is stored as an ordered list of typed blocks rather than a blob of HTML, because three of the four things this feature exists for depend on it. A tickable step needs a stable id to be remembered; dark mode needs semantic markup rather than pasted colours; and print needs to know where a step begins so it does not split one across a page. Free HTML gives none of that and lets pasted styling fight the theme. Inline text is a restricted Markdown subset - bold, italic, inline code, links, line breaks - escaped BEFORE formatting, so nothing an author pasted can reach the DOM as markup. No fonts, sizes or colours: emphasis is semantic and the theme decides how it looks. That is what makes a guide written next year match one written today, and dark mode work without an author thinking about it. DRAFTS AND VERSIONS. The guide row is the working copy; every save that changes something writes an immutable revision; and a separate pointer says which revision READERS get. Without that pointer, editing a live procedure changes it under a technician who is halfway through following it. Publishing promotes an existing revision rather than snapshotting, so a publish with unsaved edits cannot ship a version nobody reviewed. Restoring writes a new revision with old content, so the record of what people were told in between survives. Saves dedup on a content hash covering title, summary, tags and blocks, so an autosave that changed nothing writes no version - otherwise the history fills with identical entries and hides the three edits that mattered. Progress lives in the browser, not the database: shopfloor PCs share a login, so per-user ticks would show one technician's progress to the next person on that bay, which reads as "already done". Keyed by guide and version, because step 4 of version 3 is not step 4 of version 2. Five permissions, and the split between edit and publish is the point: a technician can write a procedure up while somebody else decides it is fit for the floor. create-without-edit works too - an author may edit their own guide until it is published, after which other people are following it.
164 lines
5.5 KiB
Vue
164 lines
5.5 KiB
Vue
<template>
|
|
<div class="gl">
|
|
<div class="page-header">
|
|
<h2>Guides</h2>
|
|
<router-link v-if="cancreate" class="gl-new" :to="{ name: 'guide-new' }">
|
|
New guide
|
|
</router-link>
|
|
</div>
|
|
|
|
<div class="gl-filters">
|
|
<input v-model="search" type="search" class="gl-search"
|
|
placeholder="Search titles, summaries, tags and content..."
|
|
@input="debouncedLoad">
|
|
<div v-if="tags.length" class="gl-tags">
|
|
<button type="button" class="gl-tag" :class="{ 'gl-tagon': !activeTag }"
|
|
@click="setTag('')">All</button>
|
|
<button v-for="tag in tags" :key="tag.tag" type="button"
|
|
class="gl-tag" :class="{ 'gl-tagon': activeTag === tag.tag }"
|
|
@click="setTag(tag.tag)">
|
|
{{ tag.tag }} <span class="gl-count">{{ tag.count }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="gl-loading">Loading...</div>
|
|
<div v-else-if="error" class="error-message">{{ error }}</div>
|
|
<div v-else-if="!guides.length" class="gl-empty">
|
|
<p v-if="search || activeTag">Nothing matches that.</p>
|
|
<p v-else>No guides yet.</p>
|
|
</div>
|
|
|
|
<div v-else class="gl-grid">
|
|
<router-link v-for="guide in guides" :key="guide.guideid" class="gl-card"
|
|
:to="{ name: 'guide-view', params: { slug: guide.slug } }">
|
|
<div class="gl-cardhead">
|
|
<h3>{{ guide.title }}</h3>
|
|
<!-- Drafts are visible here only to people who can edit them, so the
|
|
badge is a working state rather than a warning. -->
|
|
<span v-if="!guide.ispublished" class="gl-draft">Draft</span>
|
|
</div>
|
|
<p v-if="guide.summary" class="gl-summary">{{ guide.summary }}</p>
|
|
<div class="gl-cardfoot">
|
|
<span v-for="tag in guide.tags" :key="tag" class="gl-minitag">{{ tag }}</span>
|
|
<span v-if="guide.checklistitems" class="gl-checks">
|
|
{{ guide.checklistitems }} steps to tick
|
|
</span>
|
|
</div>
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import api from '@/api'
|
|
import { apiError } from '@/utils/apiError'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const auth = useAuthStore()
|
|
|
|
const guides = ref([])
|
|
const tags = ref([])
|
|
const search = ref('')
|
|
const activeTag = ref('')
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
|
|
// computed, not a one-off read: permissions may not be loaded yet at setup.
|
|
const cancreate = computed(() => auth.hasPermission?.('guides.create') ?? false)
|
|
|
|
let timer = null
|
|
function debouncedLoad () {
|
|
clearTimeout(timer)
|
|
timer = setTimeout(load, 250)
|
|
}
|
|
|
|
function setTag (tag) {
|
|
activeTag.value = tag
|
|
router.replace({ query: tag ? { tag } : {} })
|
|
load()
|
|
}
|
|
|
|
async function load () {
|
|
loading.value = true
|
|
error.value = ''
|
|
try {
|
|
const params = {}
|
|
if (search.value.trim()) params.q = search.value.trim()
|
|
if (activeTag.value) params.tag = activeTag.value
|
|
const response = await api.get('/guides', { params })
|
|
guides.value = response.data?.data || response.data || []
|
|
} catch (err) {
|
|
error.value = apiError(err, 'Could not load guides')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadTags () {
|
|
try {
|
|
const response = await api.get('/guides/tags')
|
|
tags.value = response.data?.data || response.data || []
|
|
} catch (err) {
|
|
tags.value = []
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
activeTag.value = route.query.tag || ''
|
|
load()
|
|
loadTags()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-header { display: flex; align-items: center; justify-content: space-between; }
|
|
.gl-new {
|
|
font-size: 0.82rem; padding: 0.35rem 0.8rem; border-radius: 4px;
|
|
background: var(--primary); color: #fff; text-decoration: none;
|
|
}
|
|
.gl-filters { margin: 0.8rem 0 1.2rem; }
|
|
.gl-search {
|
|
width: 100%; max-width: 34rem; padding: 0.45rem 0.6rem;
|
|
border: 1px solid var(--border); border-radius: 4px;
|
|
background: var(--surface, transparent); color: var(--text);
|
|
}
|
|
.gl-tags { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.6rem; }
|
|
.gl-tag {
|
|
font-size: 0.75rem; padding: 0.2rem 0.6rem; border-radius: 999px;
|
|
border: 1px solid var(--border); background: transparent;
|
|
color: var(--text-light); cursor: pointer;
|
|
}
|
|
.gl-tagon { border-color: var(--primary); color: var(--primary); }
|
|
.gl-count { opacity: 0.6; }
|
|
|
|
.gl-grid {
|
|
display: grid; gap: 0.8rem;
|
|
grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
|
|
}
|
|
.gl-card {
|
|
display: block; padding: 0.9rem 1rem; border: 1px solid var(--border);
|
|
border-radius: 6px; text-decoration: none; color: inherit;
|
|
background: var(--surface, transparent);
|
|
}
|
|
.gl-card:hover { border-color: var(--primary); }
|
|
.gl-cardhead { display: flex; align-items: baseline; gap: 0.5rem; }
|
|
.gl-cardhead h3 { margin: 0; font-size: 1rem; flex: 1; }
|
|
.gl-draft {
|
|
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 3px;
|
|
border: 1px solid var(--warning, #d9822b); color: var(--warning, #d9822b);
|
|
}
|
|
.gl-summary { margin: 0.4rem 0 0.6rem; font-size: 0.85rem; color: var(--text-light); }
|
|
.gl-cardfoot { display: flex; flex-wrap: wrap; gap: 0.3rem; align-items: center; }
|
|
.gl-minitag {
|
|
font-size: 0.68rem; padding: 0.05rem 0.4rem; border-radius: 999px;
|
|
background: var(--surface-2, rgba(127,127,127,0.1)); color: var(--text-light);
|
|
}
|
|
.gl-checks { font-size: 0.7rem; color: var(--text-light); margin-left: auto; }
|
|
.gl-empty, .gl-loading { color: var(--text-light); padding: 1.5rem 0; }
|
|
</style>
|