diff --git a/CHANGELOG.md b/CHANGELOG.md index 38e3001..b4909a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.05.08 (2026-05-31) + +### Admin dashboard — ⌘K command palette + +- A keyboard-first **command palette**: press ⌘K / Ctrl-K + (or the new navbar **Search** button) to fuzzy-filter every view plus quick + actions (toggle theme, wallboard mode) and jump on Enter. +- Full keyboard navigation (↑/↓ with clamping, Enter to run, Esc to close), + click-to-run, and a blurred modal backdrop. Brand-green active state, Maven Pro. +- Reuses the sidebar's navigation definition (now exported) so the palette + always stays in sync with the nav. The navbar Search trigger collapses to an + icon on mobile. + +--- + ## v26.05.07 (2026-05-31) ### Admin dashboard — brand refresh & UI foundation diff --git a/pyproject.toml b/pyproject.toml index c56c60a..0c2783e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "pyfly" # CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4); # git tag, GitHub release and human-readable display use leading-zero form # (v26.05.04) to match the Java/.NET/Go siblings. -version = "26.5.7" +version = "26.5.8" description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more." readme = "README.md" license = "Apache-2.0" diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py index 86ccddd..e40a670 100644 --- a/src/pyfly/__init__.py +++ b/src/pyfly/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """PyFly — Enterprise Python Framework.""" -__version__ = "26.05.07" +__version__ = "26.05.08" diff --git a/src/pyfly/admin/static/css/admin.css b/src/pyfly/admin/static/css/admin.css index b595130..7755484 100644 --- a/src/pyfly/admin/static/css/admin.css +++ b/src/pyfly/admin/static/css/admin.css @@ -1709,3 +1709,173 @@ body.wallboard-mode .admin-content { opacity: 0.6; pointer-events: none; } + +/* ── Command Palette (⌘K) ───────────────────────────────────────── */ +.cmd-palette-trigger { + display: flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 10px; + background: var(--admin-bg-subtle); + border: 1px solid var(--admin-border); + border-radius: var(--admin-radius); + color: var(--admin-text-muted); + font-family: var(--admin-font-sans); + font-size: 0.8rem; + cursor: pointer; + transition: border-color var(--admin-transition), color var(--admin-transition); +} + +.cmd-palette-trigger:hover { + border-color: var(--admin-primary-dim); + color: var(--admin-text-secondary); +} + +.cmd-palette-trigger svg { + width: 15px; + height: 15px; +} + +.cmd-palette-trigger kbd { + font-family: var(--admin-font-mono); + font-size: 0.68rem; + padding: 1px 6px; + border: 1px solid var(--admin-border); + border-radius: 4px; + background: var(--admin-surface); + color: var(--admin-text-muted); +} + +.cmd-palette-overlay { + position: fixed; + inset: 0; + z-index: 500; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 12vh; + background: rgba(4, 8, 5, 0.55); + backdrop-filter: blur(3px); + opacity: 0; + transition: opacity 120ms ease; +} + +.cmd-palette-overlay.open { + opacity: 1; +} + +.cmd-palette { + width: min(92vw, 600px); + max-height: 60vh; + display: flex; + flex-direction: column; + background: var(--admin-card-bg); + border: 1px solid var(--admin-border); + border-radius: var(--admin-radius-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55); + overflow: hidden; + transform: translateY(-8px) scale(0.98); + transition: transform 140ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.cmd-palette-overlay.open .cmd-palette { + transform: translateY(0) scale(1); +} + +.cmd-palette-input-wrap { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 16px; + border-bottom: 1px solid var(--admin-border); +} + +.cmd-palette-input-wrap svg { + width: 18px; + height: 18px; + color: var(--admin-text-muted); + flex-shrink: 0; +} + +.cmd-palette-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--admin-text); + font-family: var(--admin-font-sans); + font-size: 1rem; +} + +.cmd-palette-input::placeholder { + color: var(--admin-text-muted); +} + +.cmd-palette-esc { + font-family: var(--admin-font-mono); + font-size: 0.62rem; + padding: 2px 6px; + border: 1px solid var(--admin-border); + border-radius: 4px; + color: var(--admin-text-muted); +} + +.cmd-palette-list { + overflow-y: auto; + padding: 6px; +} + +.cmd-palette-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-radius: var(--admin-radius); + cursor: pointer; + color: var(--admin-text-secondary); +} + +.cmd-palette-item.active { + background: var(--admin-primary-dim); + color: var(--admin-text); +} + +.cmd-palette-item-icon { + width: 17px; + height: 17px; + color: var(--admin-text-muted); + flex-shrink: 0; +} + +.cmd-palette-item.active .cmd-palette-item-icon { + color: var(--admin-primary); +} + +.cmd-palette-item-label { + flex: 1; + font-family: var(--admin-font-sans); + font-size: 0.9rem; +} + +.cmd-palette-item-hint { + font-family: var(--admin-font-mono); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--admin-text-muted); +} + +.cmd-palette-empty { + padding: 28px; + text-align: center; + color: var(--admin-text-muted); + font-size: 0.9rem; +} + +@media (max-width: 768px) { + .cmd-palette-trigger-label, + .cmd-palette-trigger kbd { + display: none; + } +} diff --git a/src/pyfly/admin/static/js/app.js b/src/pyfly/admin/static/js/app.js index 78e771c..49bafae 100644 --- a/src/pyfly/admin/static/js/app.js +++ b/src/pyfly/admin/static/js/app.js @@ -8,7 +8,8 @@ import { api } from './api.js'; import { sse } from './sse.js'; -import { renderSidebar, updateSidebarActive } from './components/sidebar.js'; +import { installCommandPalette } from './components/command-palette.js'; +import { createSvgIcon, renderSidebar, updateSidebarActive } from './components/sidebar.js'; import { showToast } from './components/toast.js'; /* ── Route Registry ───────────────────────────────────────────── */ @@ -45,6 +46,7 @@ let settings = { let currentRoute = ''; let currentCleanup = null; // Cleanup function from current view +let commandPalette = null; // ⌘K launcher (installed in init) /* ── DOM References ───────────────────────────────────────────── */ @@ -118,6 +120,23 @@ function renderNavbar() { refreshLabel.textContent = `refresh: ${settings.refreshInterval / 1000}s`; right.appendChild(refreshLabel); + // Command palette trigger (⌘K) + const searchBtn = document.createElement('button'); + searchBtn.className = 'cmd-palette-trigger'; + searchBtn.setAttribute('aria-label', 'Open command palette'); + searchBtn.title = 'Search & commands (⌘K / Ctrl-K)'; + const searchIcon = createSvgIcon('M21 21l-4.35-4.35 M11 19a8 8 0 100-16 8 8 0 000 16z'); + searchBtn.appendChild(searchIcon); + const searchText = document.createElement('span'); + searchText.className = 'cmd-palette-trigger-label'; + searchText.textContent = 'Search'; + searchBtn.appendChild(searchText); + const searchKbd = document.createElement('kbd'); + searchKbd.textContent = '⌘K'; + searchBtn.appendChild(searchKbd); + searchBtn.addEventListener('click', () => commandPalette && commandPalette.open()); + right.appendChild(searchBtn); + // Theme toggle button const themeBtn = document.createElement('button'); themeBtn.className = 'theme-toggle'; @@ -308,6 +327,13 @@ async function init() { // Render navbar renderNavbar(); + // Install the ⌘K command palette + commandPalette = installCommandPalette({ + onNavigate: (route) => navigateTo(route), + serverMode: settings.serverMode, + onToggleTheme: toggleTheme, + }); + // Listen for hash changes window.addEventListener('hashchange', () => { const route = getRouteFromHash(); diff --git a/src/pyfly/admin/static/js/components/command-palette.js b/src/pyfly/admin/static/js/components/command-palette.js new file mode 100644 index 0000000..c132094 --- /dev/null +++ b/src/pyfly/admin/static/js/components/command-palette.js @@ -0,0 +1,187 @@ +/** + * PyFly Admin — Command Palette (⌘K / Ctrl-K). + * + * A keyboard-first launcher that fuzzy-filters every view + quick action and + * navigates on Enter. Built with safe DOM construction (no innerHTML with data). + */ + +import { createSvgIcon, ICONS, NAV_ITEMS, SERVER_ITEMS } from './sidebar.js'; + +/** + * Install the command palette and its global ⌘K / Ctrl-K shortcut. + * + * @param {object} opts + * @param {function} opts.onNavigate Called with a route id to navigate. + * @param {boolean} [opts.serverMode] Include server-mode views. + * @param {function} [opts.onToggleTheme] Toggle dark/light. + * @returns {{ open: function, close: function }} + */ +export function installCommandPalette({ onNavigate, serverMode = false, onToggleTheme = null }) { + const navItems = serverMode ? [...NAV_ITEMS, ...SERVER_ITEMS] : NAV_ITEMS; + + const commands = [ + ...navItems.map((it) => ({ + label: it.label, + hint: 'Go to', + icon: it.icon, + keywords: `${it.label} ${it.id} ${it.section || ''}`.toLowerCase(), + run: () => onNavigate(it.id), + })), + { + label: 'Toggle theme', + hint: 'Action', + icon: 'cog', + keywords: 'theme dark light mode toggle', + run: () => onToggleTheme && onToggleTheme(), + }, + { + label: 'Wallboard mode', + hint: 'Action', + icon: 'chart', + keywords: 'wallboard fullscreen kiosk tv display', + run: () => onNavigate('wallboard'), + }, + ]; + + let active = 0; + let filtered = commands; + + // ── DOM ────────────────────────────────────────────────────── + const overlay = document.createElement('div'); + overlay.className = 'cmd-palette-overlay'; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Command palette'); + + const panel = document.createElement('div'); + panel.className = 'cmd-palette'; + + const inputWrap = document.createElement('div'); + inputWrap.className = 'cmd-palette-input-wrap'; + inputWrap.appendChild(createSvgIcon('M21 21l-4.35-4.35 M11 19a8 8 0 100-16 8 8 0 000 16z')); + const input = document.createElement('input'); + input.className = 'cmd-palette-input'; + input.type = 'text'; + input.placeholder = 'Search views and actions…'; + input.setAttribute('aria-label', 'Search'); + inputWrap.appendChild(input); + const kbd = document.createElement('kbd'); + kbd.className = 'cmd-palette-esc'; + kbd.textContent = 'ESC'; + inputWrap.appendChild(kbd); + panel.appendChild(inputWrap); + + const list = document.createElement('div'); + list.className = 'cmd-palette-list'; + panel.appendChild(list); + + overlay.appendChild(panel); + + function renderList() { + list.textContent = ''; + if (filtered.length === 0) { + const empty = document.createElement('div'); + empty.className = 'cmd-palette-empty'; + empty.textContent = 'No matching commands'; + list.appendChild(empty); + return; + } + filtered.forEach((cmd, i) => { + const item = document.createElement('div'); + item.className = 'cmd-palette-item' + (i === active ? ' active' : ''); + item.setAttribute('role', 'option'); + + const iconData = ICONS[cmd.icon]; + if (iconData) { + const ic = createSvgIcon(iconData); + ic.classList.add('cmd-palette-item-icon'); + item.appendChild(ic); + } + const label = document.createElement('span'); + label.className = 'cmd-palette-item-label'; + label.textContent = cmd.label; + item.appendChild(label); + + const hint = document.createElement('span'); + hint.className = 'cmd-palette-item-hint'; + hint.textContent = cmd.hint; + item.appendChild(hint); + + item.addEventListener('mousemove', () => { + if (active !== i) { active = i; highlight(); } + }); + item.addEventListener('click', () => execute(i)); + list.appendChild(item); + }); + } + + function highlight() { + [...list.children].forEach((el, i) => el.classList.toggle('active', i === active)); + const el = list.children[active]; + if (el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' }); + } + + function filter(query) { + const q = query.trim().toLowerCase(); + filtered = q ? commands.filter((c) => q.split(/\s+/).every((t) => c.keywords.includes(t))) : commands; + active = 0; + renderList(); + } + + function execute(i) { + const cmd = filtered[i]; + close(); + if (cmd) cmd.run(); + } + + function open() { + if (overlay.classList.contains('open')) return; + input.value = ''; + filter(''); + document.body.appendChild(overlay); + // next frame for the transition + requestAnimationFrame(() => overlay.classList.add('open')); + input.focus(); + } + + function close() { + overlay.classList.remove('open'); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + } + + // ── Events ─────────────────────────────────────────────────── + input.addEventListener('input', () => filter(input.value)); + + overlay.addEventListener('mousedown', (e) => { + if (e.target === overlay) close(); + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + active = Math.min(active + 1, filtered.length - 1); + highlight(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + active = Math.max(active - 1, 0); + highlight(); + } else if (e.key === 'Enter') { + e.preventDefault(); + execute(active); + } else if (e.key === 'Escape') { + e.preventDefault(); + close(); + } + }); + + // Global shortcut: ⌘K / Ctrl-K (and Ctrl-/ as an alias) + document.addEventListener('keydown', (e) => { + const meta = e.metaKey || e.ctrlKey; + if (meta && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + overlay.classList.contains('open') ? close() : open(); + } + }); + + return { open, close }; +} diff --git a/src/pyfly/admin/static/js/components/sidebar.js b/src/pyfly/admin/static/js/components/sidebar.js index 12a2bf6..1a04aac 100644 --- a/src/pyfly/admin/static/js/components/sidebar.js +++ b/src/pyfly/admin/static/js/components/sidebar.js @@ -6,7 +6,7 @@ */ /* ── SVG Icon Paths ───────────────────────────────────────────── */ -const ICONS = { +export const ICONS = { home: 'M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z M9 22V12h6v10', cube: 'M21 16V8a2 2 0 00-1-1.73l-7-4a2 2 0 00-2 0l-7 4A2 2 0 003 8v8a2 2 0 001 1.73l7 4a2 2 0 002 0l7-4A2 2 0 0021 16z M3.27 6.96L12 12.01l8.73-5.05 M12 22.08V12', heart: 'M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z', @@ -29,7 +29,7 @@ const ICONS = { * Each item: { id, label, icon, section? } * section creates a section header before the item. */ -const NAV_ITEMS = [ +export const NAV_ITEMS = [ { id: '', label: 'Overview', icon: 'home', section: 'Dashboard' }, { id: 'health', label: 'Health', icon: 'heart' }, { id: 'beans', label: 'Beans', icon: 'cube', section: 'Application' }, @@ -49,7 +49,7 @@ const NAV_ITEMS = [ ]; /** Navigation item only shown in server mode. */ -const SERVER_ITEMS = [ +export const SERVER_ITEMS = [ { id: 'instances', label: 'Instances', icon: 'server', section: 'Fleet' }, ]; @@ -58,7 +58,7 @@ const SERVER_ITEMS = [ * @param {string} pathData Space-separated SVG path "d" strings. * @returns {SVGElement} */ -function createSvgIcon(pathData) { +export function createSvgIcon(pathData) { const svgNS = 'http://www.w3.org/2000/svg'; const svg = document.createElementNS(svgNS, 'svg'); svg.setAttribute('width', '18');