diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 2f0a96dd..9d8faaf5 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -75,13 +75,25 @@ export const useAuthStore = defineStore('auth', { } }, + // Re-read the signed-in user from the server, permissions included. + // + // The login response is the ONLY thing that used to populate this, and it + // is cached in localStorage - so granting somebody a permission did nothing + // visible until they happened to log out and back in. They were told they + // had rights, the button stayed hidden, and nothing explained why. Called + // on app boot so a permission change lands on the next page load. + // + // Only a 401 logs out. A transient failure - API restarting, network blip - + // must not throw somebody out of a session they still hold; the cached user + // is stale, not invalid. async fetchUser() { try { const response = await authApi.me() this.user = response.data.data await this.enrichFromDirectory() + localStorage.setItem('user', JSON.stringify(this.user)) } catch (error) { - this.logout() + if (error.response?.status === 401) this.logout() } }, diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index ebf4a747..2d37253a 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -189,14 +189,33 @@ const defaultNav = [ { name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 }, { name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 }, { name: 'Map', icon: 'map', route: '/map', position: 4 }, - { name: 'Machines', icon: 'cog', route: '/machines', position: 10 }, - { name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 }, - { name: 'Network', icon: 'network-wired', route: '/network', position: 18 }, - { name: 'Printers', icon: 'printer', route: '/printers', position: 20 }, - { name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 }, + { name: 'Machines', icon: 'cog', route: '/machines', position: 10, section: 'assets' }, + { name: 'PCs', icon: 'desktop', route: '/pcs', position: 15, section: 'assets' }, + { name: 'Network', icon: 'network-wired', route: '/network', position: 18, section: 'assets' }, + { name: 'Printers', icon: 'printer', route: '/printers', position: 20, section: 'assets' }, + { name: 'USB Devices', icon: 'usb', route: '/usb', position: 25, section: 'assets' }, { name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' }, - { name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35 }, - { name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40 }, + { name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35, section: 'information' }, + { name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40, section: 'information' }, +] + +// The canonical sidebar sections, in the order they render. A nav item DECLARES +// which one it belongs to; nothing is inferred. +// +// Sections used to be derived from the position number - 10-29 became Assets, +// 30-49 became Information - so a plugin landed in whichever bucket its number +// happened to fall in. That is how USB Devices, an asset, came to sit under +// Information next to GE-Enforce and Tech Tools, and why two plugins picking +// the same number silently reordered each other. A number is for ORDER; it was +// never a statement about meaning. +// +// An item with no section (or an unknown one) stays in the unlabelled group at +// the top, which is the safe default: it appears, it is just not filed. +const NAV_SECTIONS = [ + { key: null, label: null }, + { key: 'assets', label: 'Assets' }, + { key: 'information', label: 'Information' }, + { key: 'fleet', label: 'Fleet' }, ] function buildNavItems(items) { @@ -206,29 +225,26 @@ function buildNavItems(items) { const visible = items.filter(item => !item.permission || authStore.hasPermission(item.permission)) - // Sort by position - const sorted = [...visible].sort((a, b) => (a.position || 99) - (b.position || 99)) + const known = new Set(NAV_SECTIONS.map(s => s.key).filter(Boolean)) + const bucket = item => { + const declared = (item.section || '').toLowerCase() + return known.has(declared) ? declared : null + } - // Assign section headers based on position ranges const result = [] - let currentSection = null - - for (const item of sorted) { - let section = null - if (item.position >= 10 && item.position < 30 && currentSection !== 'Assets') { - section = 'Assets' - currentSection = 'Assets' - } else if (item.section === 'information' || (item.position >= 30 && item.position < 50 && currentSection !== 'Information')) { - if (currentSection !== 'Information') { - section = 'Information' - currentSection = 'Information' - } - } - - result.push({ - ...item, - section, - iconComponent: iconMap[item.icon] || null + for (const { key, label } of NAV_SECTIONS) { + const inSection = visible + .filter(item => bucket(item) === key) + // ?? not ||: position 0 is a real position, and `0 || 99` is 99, which + // sent Dashboard - the only item that uses 0 - to the end of its group. + .sort((a, b) => (a.position ?? 99) - (b.position ?? 99)) + inSection.forEach((item, index) => { + result.push({ + ...item, + // Only the first item in a section carries its heading. + section: index === 0 ? label : null, + iconComponent: iconMap[item.icon] || null, + }) }) } @@ -236,6 +252,11 @@ function buildNavItems(items) { } onMounted(async () => { + // Refresh the signed-in user (and their permissions) on every app load. The + // login payload is cached in localStorage, so without this a permission + // granted mid-session stays invisible until the person logs out and back in. + if (authStore.isAuthenticated) authStore.fetchUser() + // Half-hourly: cheap, and it crosses midnight into the new week without a // reload on a screen nobody touches. fiscalWeekTimer = setInterval(() => { currentFiscalWeek.value = fiscalWeek() }, 30 * 60 * 1000) diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 01bea5cd..babf4b9a 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -1653,6 +1653,7 @@ class ComputersPlugin(BasePlugin): 'icon': 'desktop', 'route': '/pcs', 'position': 15, + 'section': 'assets', }, ] diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py index 1768805b..5000e248 100644 --- a/plugins/geenforce/plugin.py +++ b/plugins/geenforce/plugin.py @@ -125,6 +125,7 @@ class GeEnforcePlugin(BasePlugin): 'icon': 'shield', 'route': '/geenforce/manifests', 'position': 46, + 'section': 'fleet', }, ] diff --git a/plugins/guides/frontend/views/GuideEditor.vue b/plugins/guides/frontend/views/GuideEditor.vue index 5d5dd462..3afbbdab 100644 --- a/plugins/guides/frontend/views/GuideEditor.vue +++ b/plugins/guides/frontend/views/GuideEditor.vue @@ -68,12 +68,71 @@ + +
+
+
+ Link to a guide or article + +
+ +

Searching...

+

+ {{ refQuery ? 'Nothing matches that.' : 'Type to search.' }} +

+ +
+
+
-
+ +
+
+ +
+
+ + +
+ +
+ + + + {{ block.type }} +
@@ -209,6 +285,8 @@ import GuideBlocks from '../components/GuideBlocks.vue' import { templates } from '../utils/templates.js' import { proof, applyFix } from '../utils/proof.js' import { useGuideImages } from '../utils/images.js' +import { ArrowUp, ArrowDown, GripVertical, Copy, X, Plus, Link2 } from 'lucide-vue-next' +import { knowledgebaseApi } from '@/api' const route = useRoute() const router = useRouter() @@ -220,6 +298,17 @@ const blocktypes = ['heading', 'paragraph', 'step', 'checklist', 'bullets', const guide = reactive({ guideid: null, title: '', summary: '', tags: '', slug: '', ispublished: false }) const blocks = ref([]) +// Which gap the type palette is open in. null = closed; blocks.length = the end. +const insertAt = ref(null) +const dragIndex = ref(null) +const dragOver = ref(null) +const refOpen = ref(false) +const refQuery = ref('') +const refResults = ref([]) +const refLoading = ref(false) +const refInput = ref(null) +let refBlock = null +let refTimer = null const revisions = ref([]) const saving = ref(false) const message = ref('') @@ -288,17 +377,111 @@ function insertIndex () { return index === -1 ? blocks.value.length : index + 1 } -function add (type) { +// `at` is an explicit gap from an insert bar. Without one, fall back to +// "after whatever is focused", which is what the old bottom palette did. +function add (type, at = null) { const block = { id: newid(), type } if (hasItems(block)) block.items = [{ id: newid(), text: '' }] if (type === 'callout') block.variant = 'info' if (type === 'table') { block.raw = ''; block.header = []; block.rows = [] } - const at = insertIndex() - blocks.value.splice(at, 0, block) + const index = at === null ? insertIndex() : at + blocks.value.splice(index, 0, block) + insertAt.value = null focused.value = block.id nextTick(() => textareas[block.id]?.focus()) } +// --------------------------------------------------------------- dragging --- +function startDrag (index, event) { + dragIndex.value = index + // Firefox will not start a drag without payload, and 'move' is what the + // cursor should say. + event.dataTransfer.effectAllowed = 'move' + try { event.dataTransfer.setData('text/plain', String(index)) } catch (err) { /* older browsers */ } +} + +function endDrag () { + dragIndex.value = null + dragOver.value = null +} + +function dropOn (index) { + const from = dragIndex.value + endDrag() + if (from === null || from === index) return + const [moved] = blocks.value.splice(from, 1) + blocks.value.splice(index, 0, moved) + focused.value = moved.id +} + +// ------------------------------------------------------------- references --- +function openReference (block) { + refBlock = block + refOpen.value = true + refQuery.value = '' + refResults.value = [] + nextTick(() => refInput.value?.focus()) +} + +function searchReferences () { + clearTimeout(refTimer) + const term = refQuery.value.trim() + if (!term) { refResults.value = []; return } + refTimer = setTimeout(async () => { + refLoading.value = true + try { + const results = [] + // Guides first: a procedure most often points at another procedure. + try { + const response = await api.get('/guides', { params: { q: term } }) + for (const guide of (response.data?.data || [])) { + if (!guide.ispublished) continue // never link readers at a draft + results.push({ kind: 'guide', id: guide.guideid, title: guide.title, + summary: guide.summary, url: `/guides/${guide.slug}` }) + } + } catch (err) { /* guides always exist here, but never break the picker */ } + + // The knowledge base is a separate plugin and may not be installed at + // this site (ADR-009). Its absence must cost nothing but its results. + try { + const response = await knowledgebaseApi.list({ search: term, perpage: 20 }) + const rows = response.data?.data || [] + for (const article of (Array.isArray(rows) ? rows : rows.items || [])) { + results.push({ kind: 'article', id: article.articleid || article.id, + title: article.title, + summary: article.summary || article.description, + url: `/knowledgebase/${article.articleid || article.id}` }) + } + } catch (err) { /* KB plugin absent or disabled */ } + + refResults.value = results.slice(0, 25) + } finally { + refLoading.value = false + } + }, 250) +} + +function insertReference (item) { + // An in-app path, not an absolute URL: the link has to keep working when the + // site is reached by another hostname or mounted under a subpath. + const markdown = `[${item.title}](${item.url})` + const area = refBlock ? textareas[refBlock.id] : null + if (refBlock && area) { + const start = area.selectionStart ?? refBlock.text?.length ?? 0 + const end = area.selectionEnd ?? start + const text = refBlock.text || '' + refBlock.text = text.slice(0, start) + markdown + text.slice(end) + nextTick(() => { + area.focus() + const caret = start + markdown.length + area.setSelectionRange(caret, caret) + }) + } else if (refBlock) { + refBlock.text = (refBlock.text || '') + markdown + } + refOpen.value = false +} + function duplicate (index) { const source = blocks.value[index] const copy = JSON.parse(JSON.stringify(source)) @@ -383,7 +566,7 @@ async function uploadImage (file) { return response.data?.data || response.data } -async function insertImage (file) { +async function insertImage (file, at = null) { error.value = '' status.value = 'Uploading image...' try { @@ -392,7 +575,8 @@ async function insertImage (file) { // for anything that still treats a block as a plain document. const block = { id: newid(), type: 'image', imageid: image.guideimageid, url: image.url, caption: '', alt: '' } - blocks.value.splice(insertIndex(), 0, block) + blocks.value.splice(at === null ? insertIndex() : at, 0, block) + insertAt.value = null focused.value = block.id status.value = '' } catch (err) { @@ -416,9 +600,9 @@ function onDrop (event) { if (file) insertImage(file) } -function onFilePick (event) { +function onFilePick (event, at = null) { const file = event.target.files?.[0] - if (file) insertImage(file) + if (file) insertImage(file, at) event.target.value = '' } @@ -714,4 +898,77 @@ onMounted(async () => { .ge-rev button { margin-left: auto; font-size: 0.72rem; padding: 0.12rem 0.45rem; border: 1px solid var(--border); background: transparent; color: var(--text-light); border-radius: 3px; cursor: pointer; } + +/* --- insert bars, drag handle, reference picker -------------------------- */ + +/* A quiet line that becomes a button on hover: present when wanted, not + competing with the guide's own content the rest of the time. */ +.ge-insert { + display: flex; align-items: center; justify-content: center; + height: 1.5rem; opacity: 0; transition: opacity 0.12s; +} +.ge-insert:hover, .ge-insertopen, .ge-insertend { opacity: 1; } +.ge-insert::before, .ge-insert::after { + content: ''; flex: 1; height: 1px; background: var(--border); +} +.ge-insertbtn { + display: inline-flex; align-items: center; gap: 0.3rem; + margin: 0 0.5rem; padding: 0.15rem 0.6rem; + font-size: 0.75rem; color: var(--text-light); + border: 1px solid var(--border); border-radius: 999px; + background: var(--bg); cursor: pointer; white-space: nowrap; +} +.ge-insertbtn:hover { color: var(--primary); border-color: var(--primary); } +.ge-palette { + display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; + margin: 0 0 0.6rem; padding: 0.5rem; + border: 1px solid var(--border); border-radius: 5px; +} +.ge-palette button, .ge-palette .ge-upload { + font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid var(--border); + background: transparent; color: var(--text); border-radius: 3px; cursor: pointer; +} +.ge-palette button:hover, .ge-palette .ge-upload:hover { + color: var(--primary); border-color: var(--primary); +} + +.ge-grip { display: inline-flex; align-items: center; color: var(--text-light); + cursor: grab; padding-right: 0.2rem; } +.ge-grip:active { cursor: grabbing; } +.ge-dragging { opacity: 0.45; } +.ge-dragover { border-top: 2px solid var(--primary); } + +.ge-refmask { + position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); + display: flex; align-items: flex-start; justify-content: center; + padding-top: 8vh; z-index: 50; +} +.ge-ref { + width: min(34rem, 92vw); max-height: 70vh; overflow: auto; + background: var(--bg); color: var(--text); + border: 1px solid var(--border); border-radius: 6px; padding: 1rem; +} +.ge-refhead { display: flex; align-items: center; justify-content: space-between; + margin-bottom: 0.6rem; } +.ge-refhead button { background: transparent; border: none; color: var(--text-light); + cursor: pointer; } +.ge-refsearch { + width: 100%; padding: 0.5rem 0.7rem; font-size: 1rem; + border: 1px solid var(--border); border-radius: 4px; + background: transparent; color: var(--text); margin-bottom: 0.6rem; +} +.ge-reflist { list-style: none; margin: 0; padding: 0; } +.ge-reflist button { + width: 100%; display: flex; flex-direction: column; gap: 0.15rem; + padding: 0.5rem 0.6rem; margin-bottom: 0.3rem; text-align: left; + border: 1px solid var(--border); border-radius: 4px; + background: transparent; color: var(--text); cursor: pointer; +} +.ge-reflist button:hover { border-color: var(--primary); } +.ge-refkind { + font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--text-light); +} +.ge-reftitle { font-weight: 600; } +.ge-reflist small { color: var(--text-light); } diff --git a/plugins/guides/plugin.py b/plugins/guides/plugin.py index c8b0e387..13f30323 100644 --- a/plugins/guides/plugin.py +++ b/plugins/guides/plugin.py @@ -75,10 +75,15 @@ class GuidesPlugin(BasePlugin): ] def get_navigation_items(self) -> List[Dict]: - # Position 8, beside Slides (7), rather than down in the Information - # section with the reference lists. A guide is something you go and DO - # something with, like a slide deck you put on a screen - not a - # catalogue you look things up in. + # Position 8, directly under Slides (7), rather than down in the + # Information section with the reference lists. A guide is something you + # go and DO something with, like a slide deck you put on a screen - not + # a catalogue you look things up in. + # + # Warranties held 8 as well, and a TIE is broken by plugin load order, + # so Guides landed underneath it and read as "not under Slides at all". + # Warranties moved to 9. Two nav items sharing a position is not a + # preference, it is a coin toss. return [ { 'name': 'Guides', diff --git a/plugins/machines/plugin.py b/plugins/machines/plugin.py index 77258bf9..192469d0 100644 --- a/plugins/machines/plugin.py +++ b/plugins/machines/plugin.py @@ -232,6 +232,7 @@ class MachinesPlugin(BasePlugin): 'icon': 'cog', 'route': '/machines', 'position': 10, + 'section': 'assets', }, ] diff --git a/plugins/measuringtools/plugin.py b/plugins/measuringtools/plugin.py index 5a08e3fb..b563971f 100644 --- a/plugins/measuringtools/plugin.py +++ b/plugins/measuringtools/plugin.py @@ -74,6 +74,7 @@ class MeasuringToolsPlugin(BasePlugin): 'icon': 'ruler', 'route': '/measuringtools', 'position': 22, + 'section': 'assets', }, ] diff --git a/plugins/network/frontend/views/NetworkDeviceDetail.vue b/plugins/network/frontend/views/NetworkDeviceDetail.vue index f947549d..365654d7 100644 --- a/plugins/network/frontend/views/NetworkDeviceDetail.vue +++ b/plugins/network/frontend/views/NetworkDeviceDetail.vue @@ -1,5 +1,25 @@