forked from snxraven/peardock
Store theme, density, and other prefs in a versioned JSON cache file (matching peers.json) instead of localStorage-only, with migration and a localStorage mirror for FOUC and legacy readers.
495 lines
14 KiB
JavaScript
495 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'
|
|
import {
|
|
loadSettingsBlob,
|
|
saveSettingsBlob,
|
|
} from '../client/settingsCache.js'
|
|
|
|
/** @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 { ...loadSettingsBlob() }
|
|
} catch {
|
|
return {}
|
|
}
|
|
}
|
|
|
|
function saveSettingsLocal(partial) {
|
|
if (typeof window !== 'undefined' && window.peardockOps?.saveSettings) {
|
|
return window.peardockOps.saveSettings(partial)
|
|
}
|
|
try {
|
|
const next = { ...loadSettingsLocal(), ...partial }
|
|
saveSettingsBlob(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,
|
|
}
|