diff --git a/src/index.html b/src/index.html index 8be83b4..f953560 100644 --- a/src/index.html +++ b/src/index.html @@ -811,6 +811,23 @@ + + @@ -823,6 +840,7 @@ + diff --git a/src/renderer-command-palette.ts b/src/renderer-command-palette.ts new file mode 100644 index 0000000..75f1826 --- /dev/null +++ b/src/renderer-command-palette.ts @@ -0,0 +1,202 @@ +// Command Palette — Pillar 5 UI Power. +// Ctrl+K oeffnet ein Suchfeld + Liste schnell ausfuehrbarer Aktionen. +// MVP: 6 statische Tab-Wechsel-Befehle, prefix-match auf Label. + +interface PaletteCommand { + id: string; + label: string; + hint: string; + keywords: string; // fuer Match — Label kleingeschrieben + Synonyme + action: () => void; +} + +(function initCommandPalette() { + const STORE: { commands: PaletteCommand[]; activeIndex: number; filtered: PaletteCommand[] } = { + commands: [], + activeIndex: 0, + filtered: [], + }; + + function buildCommands(): PaletteCommand[] { + const showTab = (window as unknown as { showTab?: (tab: string) => void }).showTab; + if (typeof showTab !== 'function') { + return []; + } + + const tabs: Array<{ id: string; labels: string[]; hint: string }> = [ + { id: 'vods', labels: ['VODs', 'videos', 'streams'], hint: 'Go' }, + { id: 'queue', labels: ['Queue', 'downloads', 'warteschlange'], hint: 'Go' }, + { id: 'streamers', labels: ['Streamers', 'channels'], hint: 'Go' }, + { id: 'stats', labels: ['Stats', 'statistiken', 'dashboard'], hint: 'Go' }, + { id: 'archive', labels: ['Archive', 'archiv'], hint: 'Go' }, + { id: 'settings', labels: ['Settings', 'einstellungen', 'config'], hint: 'Go' }, + ]; + + return tabs.map(t => ({ + id: 'tab:' + t.id, + label: t.labels[0], + hint: t.hint, + keywords: t.labels.join(' ').toLowerCase(), + action: () => showTab(t.id), + })); + } + + function getModal(): HTMLElement | null { + return document.getElementById('commandPaletteModal'); + } + + function getInput(): HTMLInputElement | null { + return document.getElementById('commandPaletteInput') as HTMLInputElement | null; + } + + function getList(): HTMLUListElement | null { + return document.getElementById('commandPaletteList') as HTMLUListElement | null; + } + + function isOpen(): boolean { + return Boolean(getModal()?.classList.contains('show')); + } + + function clearList(list: HTMLUListElement) { + while (list.firstChild) list.removeChild(list.firstChild); + } + + function render() { + const list = getList(); + if (!list) return; + clearList(list); + STORE.filtered.forEach((cmd, idx) => { + const li = document.createElement('li'); + li.className = 'cp-item' + (idx === STORE.activeIndex ? ' cp-active' : ''); + li.dataset.cmdId = cmd.id; + li.setAttribute('role', 'option'); + li.setAttribute('aria-selected', idx === STORE.activeIndex ? 'true' : 'false'); + + const label = document.createElement('span'); + label.className = 'cp-item-label'; + label.textContent = cmd.label; + li.appendChild(label); + + const hint = document.createElement('span'); + hint.className = 'cp-item-hint'; + hint.textContent = cmd.hint; + li.appendChild(hint); + + li.addEventListener('mouseenter', () => { + STORE.activeIndex = idx; + render(); + }); + li.addEventListener('click', () => { + executeAt(idx); + }); + + list.appendChild(li); + }); + } + + function applyFilter(query: string) { + const q = query.trim().toLowerCase(); + if (!q) { + STORE.filtered = STORE.commands.slice(); + } else { + STORE.filtered = STORE.commands.filter(c => c.keywords.includes(q)); + } + if (STORE.activeIndex >= STORE.filtered.length) { + STORE.activeIndex = STORE.filtered.length > 0 ? STORE.filtered.length - 1 : 0; + } + render(); + } + + function executeAt(idx: number) { + const cmd = STORE.filtered[idx]; + if (!cmd) return; + close(); + try { + cmd.action(); + } catch (e) { + console.error('command-palette: action failed', cmd.id, e); + } + } + + function open() { + const modal = getModal(); + const input = getInput(); + if (!modal || !input) return; + STORE.commands = buildCommands(); + STORE.filtered = STORE.commands.slice(); + STORE.activeIndex = 0; + input.value = ''; + modal.classList.add('show'); + requestAnimationFrame(() => input.focus()); + render(); + } + + function close() { + const modal = getModal(); + if (!modal) return; + modal.classList.remove('show'); + } + + function onKeydown(e: KeyboardEvent) { + // Toggle: Ctrl+K (Linux/Windows) or Cmd+K (Mac) + if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + if (isOpen()) { + close(); + } else { + open(); + } + return; + } + + if (!isOpen()) return; + + if (e.key === 'Escape') { + e.preventDefault(); + close(); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (STORE.filtered.length === 0) return; + STORE.activeIndex = (STORE.activeIndex + 1) % STORE.filtered.length; + render(); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (STORE.filtered.length === 0) return; + STORE.activeIndex = (STORE.activeIndex - 1 + STORE.filtered.length) % STORE.filtered.length; + render(); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + executeAt(STORE.activeIndex); + return; + } + } + + function attach() { + const input = getInput(); + if (input) { + input.addEventListener('input', () => applyFilter(input.value)); + } + const modal = getModal(); + if (modal) { + modal.addEventListener('click', e => { + if (e.target === modal) close(); + }); + } + document.addEventListener('keydown', onKeydown, { capture: true }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', attach); + } else { + attach(); + } + + // Expose for renderer.ts closeTopmostOpenModal integration. + (window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette = close; +})(); diff --git a/src/renderer.ts b/src/renderer.ts index 70f5306..f9d1a28 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -536,6 +536,15 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void { function closeTopmostOpenModal(): boolean { // Try each known modal in priority order + const commandPaletteModal = document.getElementById('commandPaletteModal'); + if (commandPaletteModal?.classList.contains('show')) { + const closeCp = (window as unknown as { closeCommandPalette?: () => void }).closeCommandPalette; + if (typeof closeCp === 'function') { + closeCp(); + return true; + } + } + const eventsViewerModal = document.getElementById('eventsViewerModal'); if (eventsViewerModal?.classList.contains('show')) { closeEventsViewer(); diff --git a/src/styles.css b/src/styles.css index 0f2d33f..a94bafd 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4617,3 +4617,95 @@ input[type="number"]::-webkit-outer-spin-button { font-size: 11px; word-break: break-all; } + +/* Command Palette (Pillar 5 — added in 5.1.0-alpha.1) */ +.command-palette { + max-width: 540px; + width: 90%; + padding: 16px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.cp-title { + font-size: 13px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.08em; + margin: 0; +} + +.cp-input { + width: 100%; + padding: 10px 12px; + font-size: 16px; + background: var(--bg-main); + color: var(--text); + border: 1px solid var(--border-soft); + border-radius: 4px; + outline: none; +} + +.cp-input:focus { + border-color: var(--accent); +} + +.cp-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 320px; + overflow-y: auto; + border: 1px solid var(--border-soft); + border-radius: 4px; +} + +.cp-list:empty { + display: none; +} + +.cp-item { + padding: 8px 12px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + color: var(--text); + border-bottom: 1px solid var(--border-soft); +} + +.cp-item:last-child { + border-bottom: none; +} + +.cp-item:hover, +.cp-item.cp-active { + background: var(--accent); + color: #fff; +} + +.cp-item-label { + flex: 1; + font-size: 14px; +} + +.cp-item-hint { + font-size: 11px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.cp-item:hover .cp-item-hint, +.cp-item.cp-active .cp-item-hint { + color: rgba(255, 255, 255, 0.85); +} + +.cp-hint { + margin: 0; + font-size: 11px; + color: var(--text-secondary); + text-align: right; +}