/** * Track G — UI/UX polish: shortcuts modal, go-chords, quick filter, * last-refreshed stamps, scroll-to-top, view enter animations. */ import { el, escapeHtml } from './components.js' const SETTINGS_KEY = 'peardock.settings.v1' /** @type {Record} */ const GO_MAP = { d: 'dashboard', c: 'containers', i: 'images', r: 'registry', n: 'networks', v: 'volumes', s: 'stacks', w: 'swarm', e: 'events', h: 'host', t: 'tunnels', /** Peers moved under Settings — handled specially in go-chord */ p: 'settings:peers', f: 'fleet', a: 'access', ',': 'settings', o: 'deploy', } /** @type {string|null} */ let goPending = null /** @type {ReturnType|null} */ let goTimer = null function loadSettingsLocal() { try { if (typeof window !== 'undefined' && window.peardockOps?.loadSettings) { return window.peardockOps.loadSettings() } return { ...JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') } } catch { return {} } } function saveSettingsLocal(partial) { if (typeof window !== 'undefined' && window.peardockOps?.saveSettings) { return window.peardockOps.saveSettings(partial) } try { const next = { ...loadSettingsLocal(), ...partial } localStorage.setItem(SETTINGS_KEY, JSON.stringify(next)) document.body.dataset.density = next.density || 'comfortable' document.body.dataset.reduceMotion = next.reduceMotion ? '1' : '0' document.body.dataset.accent = next.accent || 'teal' return next } catch { return partial } } function isTyping(target) { if (!target) return false const tag = target.tagName return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable } function reduceMotionOn() { const s = loadSettingsLocal() if (s.reduceMotion) return true try { return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) } catch { return false } } /** * Rich shortcuts modal with key table. */ export function openShortcutsModal() { if (document.getElementById('pd-shortcuts-modal')) return const previouslyFocused = document.activeElement const backdrop = el('div', { className: 'pd-modal-backdrop', role: 'presentation', id: 'pd-shortcuts-modal', }) const modal = el('div', { className: 'pd-modal pd-modal--info pd-shortcuts-modal', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'pd-shortcuts-title', }) const sections = [ { title: 'Global', rows: [ ['Ctrl/⌘ + K', 'Open command palette'], ['?', 'Show this help'], ['/', 'Focus search / filter on the current list'], ['Esc', 'Close dialogs and panels'], ], }, { title: 'Go to (press g, then key)', rows: [ ['g d', 'Dashboard'], ['g c', 'Containers'], ['g i', 'Images'], ['g n / g v', 'Networks / Volumes'], ['g s / g w', 'Stacks / Swarm'], ['g o', 'Deploy'], ['g e / g h', 'Events / Host'], ['g t / g p / g f', 'Tunnels / Peers (Settings) / Fleet'], ['g r', 'Registry'], ['g a / g ,', 'Access / Settings'], ], }, ] modal.innerHTML = `

Keyboard shortcuts

Power-user navigation for the desktop console.

${sections .map( (sec) => `

${escapeHtml(sec.title)}

${sec.rows .map( ([k, v]) => `` ) .join('')}
${escapeHtml(k)}${escapeHtml(v)}
` ) .join('')}
` backdrop.appendChild(modal) document.body.appendChild(backdrop) requestAnimationFrame(() => backdrop.classList.add('pd-modal-backdrop--visible')) const close = () => { backdrop.classList.remove('pd-modal-backdrop--visible') backdrop.classList.add('pd-modal-backdrop--leaving') setTimeout(() => { backdrop.remove() document.removeEventListener('keydown', onKey) if (previouslyFocused && typeof previouslyFocused.focus === 'function') { try { previouslyFocused.focus() } catch { // ignore } } }, 160) } const onKey = (e) => { if (e.key === 'Escape' || e.key === 'Enter') { e.preventDefault() close() } } document.addEventListener('keydown', onKey) modal.querySelector('.pd-ok')?.addEventListener('click', close) backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close() }) setTimeout(() => modal.querySelector('.pd-ok')?.focus?.(), 30) } /** * Focus the primary list filter for the active view. */ export function focusListSearch() { const view = typeof window !== 'undefined' ? window.currentView : null const map = { containers: 'container-search', images: 'image-search', networks: 'network-search', volumes: 'volume-search', stacks: 'stack-search', events: 'events-filter', deploy: 'deploy-template-search-input', } const id = map[view] || null /** @type {HTMLElement|null} */ let elSearch = id ? document.getElementById(id) : null if (!elSearch) { const activeView = document.querySelector('.view:not(.hidden)') elSearch = activeView?.querySelector?.( 'input[type="search"], input.list-search-input, .view-toolbar input[type="text"], .view-toolbar input' ) || null } if (elSearch && typeof elSearch.focus === 'function') { elSearch.focus() if (typeof elSearch.select === 'function') elSearch.select() return true } return false } /** * Stamp “Updated …” on the active view toolbar / header. * @param {string} [view] */ export function markListRefreshed(view) { const s = loadSettingsLocal() if (s.showRefreshStamp === false) return const name = view || (typeof window !== 'undefined' ? window.currentView : null) if (!name) return const root = document.getElementById(`${name}-view`) || document.querySelector('.view:not(.hidden)') if (!root) return let stamp = root.querySelector('.pd-refresh-stamp') if (!stamp) { const header = root.querySelector('.page-header') const toolbar = root.querySelector('.view-toolbar') const host = header || toolbar || root.querySelector('.container-fluid') || root stamp = document.createElement('div') stamp.className = 'pd-refresh-stamp' stamp.setAttribute('aria-live', 'polite') if (header) { // Prefer right side of header when flex header.classList.add('pd-page-header--with-stamp') header.appendChild(stamp) } else if (toolbar) { toolbar.appendChild(stamp) } else { host.insertBefore(stamp, host.firstChild) } } const now = new Date() const time = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) stamp.innerHTML = `Updated ` stamp.dataset.at = String(Date.now()) } /** * Subtle enter animation when switching views. * @param {string} viewName */ export function animateViewEnter(viewName) { if (reduceMotionOn()) return const root = document.getElementById(`${viewName}-view`) if (!root) return root.classList.remove('pd-view-enter') void root.offsetWidth root.classList.add('pd-view-enter') } /** * Floating scroll-to-top control for long lists. */ function ensureScrollTopBtn() { let btn = document.getElementById('pd-scroll-top') if (btn) return btn btn = document.createElement('button') btn.id = 'pd-scroll-top' btn.type = 'button' btn.className = 'pd-scroll-top' btn.title = 'Back to top' btn.setAttribute('aria-label', 'Back to top') btn.innerHTML = '' btn.addEventListener('click', () => { const content = document.getElementById('content') const behavior = reduceMotionOn() ? 'auto' : 'smooth' if (content && content.scrollHeight > content.clientHeight) { content.scrollTo({ top: 0, behavior }) } else { window.scrollTo({ top: 0, behavior }) } }) document.body.appendChild(btn) return btn } function updateScrollTopVisibility() { const btn = ensureScrollTopBtn() const content = document.getElementById('content') const y = content ? content.scrollTop : window.scrollY || document.documentElement.scrollTop btn.classList.toggle('pd-scroll-top--visible', y > 320) } /** * Wire global Track G keyboard + chrome. * @param {{ navigateToView?: (v: string) => void }} [ctx] */ export function initTrackGUx(ctx = {}) { const navigateToView = ctx.navigateToView || ((v) => { if (typeof window !== 'undefined' && typeof window.navigateToView === 'function') { window.navigateToView(v) } }) ensureScrollTopBtn() const content = document.getElementById('content') const onScroll = () => updateScrollTopVisibility() content?.addEventListener('scroll', onScroll, { passive: true }) window.addEventListener('scroll', onScroll, { passive: true }) updateScrollTopVisibility() const go = (dest) => { if (!dest) return // Compound targets: "settings:peers" → settings view + peers subtab if (String(dest).includes(':')) { const [view, tab] = String(dest).split(':') navigateToView?.(view, tab ? { settingsTab: tab } : {}) if (tab && typeof window.peardockOps?.showSettingsTab === 'function') { // ensure subtab after view paints requestAnimationFrame(() => window.peardockOps.showSettingsTab(tab)) } requestAnimationFrame(() => { animateViewEnter(view) markListRefreshed(view) }) return } navigateToView?.(dest) requestAnimationFrame(() => { animateViewEnter(dest) markListRefreshed(dest) }) } document.addEventListener('keydown', (e) => { if (e.defaultPrevented) return if (document.querySelector('.pd-palette-backdrop, #pd-shortcuts-modal, .modal.show')) { return } if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey && !isTyping(e.target)) { e.preventDefault() openShortcutsModal() return } if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !isTyping(e.target)) { e.preventDefault() focusListSearch() return } if (!e.ctrlKey && !e.metaKey && !e.altKey && !isTyping(e.target)) { const key = e.key.length === 1 ? e.key.toLowerCase() : e.key if (goPending) { e.preventDefault() const dest = GO_MAP[key] if (goTimer) clearTimeout(goTimer) goPending = null goTimer = null document.body.classList.remove('pd-go-pending') if (dest) go(dest) return } if (key === 'g') { e.preventDefault() goPending = 'g' document.body.classList.add('pd-go-pending') if (goTimer) clearTimeout(goTimer) goTimer = setTimeout(() => { goPending = null document.body.classList.remove('pd-go-pending') }, 900) } } }) if (typeof window !== 'undefined') { window.peardockUx = { openShortcutsModal, focusListSearch, markListRefreshed, animateViewEnter, stampOnPoll: () => { const view = window.currentView if (view) markListRefreshed(view) }, GO_MAP, } } document.getElementById('settings-open-shortcuts')?.addEventListener('click', () => { openShortcutsModal() }) try { const mq = window.matchMedia('(prefers-reduced-motion: reduce)') const apply = () => { document.body.dataset.systemReducedMotion = mq.matches ? '1' : '0' } mq.addEventListener?.('change', apply) apply() } catch { // ignore } return { go } } /** * Extra palette actions for Track G. * @param {(v: string) => void} navigateToView * @param {Function} [sendCommand] */ export function trackGPaletteItems(navigateToView, sendCommand) { return [ { label: 'Keyboard shortcuts', icon: 'fa-keyboard', keywords: 'help keys hotkeys', action: () => openShortcutsModal(), }, { label: 'Focus list filter', icon: 'fa-filter', keywords: 'search /', action: () => focusListSearch(), }, { label: 'Toggle sidebar', icon: 'fa-bars', keywords: 'collapse rail', action: () => { const sidebar = document.getElementById('sidebar') if (!sidebar) return const next = !sidebar.classList.contains('collapsed') sidebar.classList.toggle('collapsed', next) saveSettingsLocal({ sidebarCollapsed: next }) // keep legacy key in sync try { localStorage.setItem('peardock.sidebarCollapsed', next ? '1' : '0') } catch { // ignore } }, }, { label: 'Cycle table density', icon: 'fa-table-cells', keywords: 'comfortable compact spacious', action: () => { const s = loadSettingsLocal() const order = ['comfortable', 'compact', 'spacious'] const i = Math.max(0, order.indexOf(s.density || 'comfortable')) const density = order[(i + 1) % order.length] saveSettingsLocal({ density }) document.body.dataset.density = density }, }, { label: 'Refresh current view', icon: 'fa-sync', keywords: 'reload poll', action: () => { const view = window.currentView const send = sendCommand || window.sendCommand if (!send || !view) return const quiet = { silent: true } if (view === 'containers' || view === 'dashboard') send('listContainers', {}, quiet) else if (view === 'images') send('listImages', {}, quiet) else if (view === 'networks') send('listNetworks', {}, quiet) else if (view === 'volumes') send('listVolumes', {}, quiet) else if (view === 'stacks') send('listStacks', {}, quiet) markListRefreshed(view) }, }, ] } export default { initTrackGUx, openShortcutsModal, focusListSearch, markListRefreshed, trackGPaletteItems, }