Files
peardock/ui/track-g-ux.js
T
Raven Scott 9775fe5305
Release rolling / release (push) Successful in 9m39s
Move Peers into Settings and add a full Registry browser tab.
Peers live under Settings; Images is local-only. Registry is a top-level view with vault credentials, Hub search, and Registry API V2 catalog/tags/manifest/delete over the wire.
2026-07-15 16:04:27 -04:00

493 lines
14 KiB
JavaScript

/**
* 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<string, string>} */
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<typeof setTimeout>|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 = `
<div class="pd-modal-icon pd-modal-icon--info" aria-hidden="true">
<i class="fas fa-keyboard"></i>
</div>
<h3 class="pd-modal-title" id="pd-shortcuts-title">Keyboard shortcuts</h3>
<p class="pd-modal-body">Power-user navigation for the desktop console.</p>
<div class="pd-shortcuts-body">
${sections
.map(
(sec) => `
<h4 class="pd-shortcuts-heading">${escapeHtml(sec.title)}</h4>
<table class="pd-shortcuts-table">
<tbody>
${sec.rows
.map(
([k, v]) =>
`<tr><th scope="row"><kbd class="pd-kbd">${escapeHtml(k)}</kbd></th><td>${escapeHtml(v)}</td></tr>`
)
.join('')}
</tbody>
</table>`
)
.join('')}
</div>
<div class="pd-modal-actions">
<button type="button" class="btn btn-primary pd-ok">Got it</button>
</div>`
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 = `<i class="fas fa-clock me-1" aria-hidden="true"></i>Updated <time datetime="${now.toISOString()}">${escapeHtml(time)}</time>`
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 = '<i class="fas fa-arrow-up" aria-hidden="true"></i>'
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,
}