feat(ux): command palette (Strg/⌘-K) + keyboard shortcuts

A global command palette and keyboard shortcuts, mounted once in the persistent
layout with a single global keydown listener.

- Strg/⌘-K opens the palette: fuzzy-filter nav targets (German, from the sidebar) +
  "Server hinzufügen"; ↑/↓ to move, Enter to run, Esc to close. Navigation uses
  window.Livewire.navigate (SPA-fast).
- Leader "g" + key: d=Dashboard, s=Server, i=Dienste, f=Dateien, l=Audit-Log,
  e=Einstellungen, y=System, v=Version.
- "/" focuses the page search ([data-page-search] on services/servers/audit) — but
  only consumes the key when such a field exists, so the browser quick-find still
  works elsewhere.
- "?" shows a shortcut-help overlay.
- Typing guard: shortcuts never fire inside inputs/textarea/select/contentEditable.
- Alpine destroy() removes the navigation listener (no leak across wire:navigate);
  topbar gains a "Strg K" trigger button. New icons: command, corner-down-left.

R5/R3/R9 respected: the palette only navigates or opens the existing (R5) create-server
modal — zero direct destructive actions; tokens-only styling, German, no emoji.

Pint clean; Codex review clean (destroy cleanup + slash-passthrough hardened); R12 —
verified in-browser: Ctrl-K opens, filter works, Esc closes, g+f navigates, ? help,
HTTP 200, 0 console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-13 20:38:22 +02:00
parent e792e9a3af
commit 2c533bf821
8 changed files with 231 additions and 0 deletions

View File

@ -66,4 +66,132 @@ document.addEventListener('alpine:init', () => {
get memLine() { return this._coords(this.mem); },
get memArea() { return `0,100 ${this._coords(this.mem)} 300,100`; },
}));
// ── Command palette + keyboard shortcuts ────────────────────────────
// German nav targets (mirrors the sidebar) with a leader-key hint. Leader
// mnemonics: i=dIenste, l=Log, e=Einstellungen, y=sYstem (d/s already taken).
const CMDK_NAV = [
{ label: 'Dashboard', href: '/', hint: 'g d' },
{ label: 'Server', href: '/servers', hint: 'g s' },
{ label: 'Dienste', href: '/services', hint: 'g i' },
{ label: 'Dateien', href: '/files', hint: 'g f' },
{ label: 'Audit-Log', href: '/audit', hint: 'g l' },
{ label: 'Einstellungen', href: '/settings', hint: 'g e' },
{ label: 'System', href: '/system', hint: 'g y' },
{ label: 'Version', href: '/versions', hint: 'g v' },
];
const CMDK_ACTIONS = [
{ label: 'Server hinzufügen', action: 'create-server', hint: '' },
];
const CMDK_GO = { d: '/', s: '/servers', i: '/services', f: '/files', l: '/audit', e: '/settings', y: '/system', v: '/versions' };
window.Alpine.data('cmdk', () => ({
open: false,
help: false,
query: '',
active: 0,
leader: false,
leaderTimer: null,
nav: CMDK_NAV,
init() {
// Reset overlays after an SPA navigation. The component is recreated on each
// wire:navigate, so we MUST remove this document listener in destroy() — else
// every navigation would accumulate another permanent callback.
this._onNav = () => {
this.open = false;
this.help = false;
this.leader = false;
};
document.addEventListener('livewire:navigated', this._onNav);
},
destroy() {
document.removeEventListener('livewire:navigated', this._onNav);
},
get items() {
const q = this.query.trim().toLowerCase();
const all = [...CMDK_NAV, ...CMDK_ACTIONS];
return q === '' ? all : all.filter((i) => i.label.toLowerCase().includes(q));
},
toggle(force) {
this.open = force === true ? true : ! this.open;
if (this.open) {
this.help = false;
this.query = '';
this.active = 0;
this.$nextTick(() => this.$refs.input?.focus());
}
},
close() {
this.open = false;
this.help = false;
},
move(d) {
const n = this.items.length;
if (n) this.active = (this.active + d + n) % n;
},
run(item) {
item = item || this.items[this.active] || null;
if (! item) return;
if (item.href) {
this.navigate(item.href);
} else if (item.action === 'create-server') {
this.close();
window.Livewire?.dispatch('openModal', { component: 'modals.create-server' });
}
},
navigate(href) {
this.close();
if (window.Livewire?.navigate) window.Livewire.navigate(href);
else window.location.href = href;
},
focusSearch() {
const el = document.querySelector('[data-page-search]');
if (el) {
el.focus();
return true;
}
return false;
},
go(key) {
if (CMDK_GO[key]) this.navigate(CMDK_GO[key]);
},
onKey(e) {
// Cmd/Ctrl-K toggles the palette even from within an input.
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.toggle();
return;
}
if (this.open || this.help) return; // overlay keys handled in the markup
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
// Only consume "/" when a page search exists — otherwise leave the browser's quick-find.
if (e.key === '/') { if (this.focusSearch()) e.preventDefault(); return; }
if (e.key === '?') { e.preventDefault(); this.help = true; return; }
if (this.leader) {
this.leader = false;
clearTimeout(this.leaderTimer);
this.go(e.key.toLowerCase());
return;
}
if (e.key === 'g' || e.key === 'G') {
this.leader = true;
this.leaderTimer = setTimeout(() => { this.leader = false; }, 1200);
}
},
}));
});

View File

@ -0,0 +1,89 @@
{{--
Command palette + keyboard shortcuts. Mounted ONCE in the persistent layout so it
survives wire:navigate and its global keydown listener is registered only once.
Strg/-K opens it; "g" + a key navigates; "/" focuses the page search; "?" shows help.
--}}
@php
$chords = [
['Strg / ⌘ K', 'Befehle öffnen'],
['/', 'Suche auf der Seite fokussieren'],
['?', 'Diese Hilfe anzeigen'],
['g d', 'Dashboard'],
['g s', 'Server'],
['g i', 'Dienste'],
['g f', 'Dateien'],
['g l', 'Audit-Log'],
['g e', 'Einstellungen'],
['g y', 'System'],
['g v', 'Version'],
];
$kbd = 'rounded border border-line bg-inset px-1.5 py-0.5 font-mono text-[10px] text-ink-2';
@endphp
<div x-data="cmdk"
@keydown.window="onKey($event)"
@keydown.escape.window="close()"
@cmdk-open.window="toggle(true)">
{{-- Command palette --}}
<div x-show="open" x-cloak class="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[12vh]" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-void/70 backdrop-blur-sm" @click="close()" aria-hidden="true"></div>
<div x-show="open" x-transition
class="relative w-full max-w-xl overflow-hidden rounded-lg border border-line bg-surface shadow-pop">
<div class="flex items-center gap-2.5 border-b border-line px-4">
<x-icon name="search" class="h-4 w-4 shrink-0 text-ink-3" />
<input x-ref="input" x-model="query" type="text" autocomplete="off" spellcheck="false"
placeholder="Befehl oder Seite…"
@input="active = 0"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="run()"
class="h-12 w-full bg-transparent font-mono text-sm text-ink placeholder:text-ink-4 focus:outline-none" />
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4">Esc</span>
</div>
<ul class="max-h-80 overflow-y-auto py-1.5">
<template x-for="(item, idx) in items" :key="item.label">
<li>
<button type="button"
@click="run(item)"
@mouseenter="active = idx"
:class="active === idx ? 'bg-accent/10 text-accent-text' : 'text-ink-2'"
class="flex w-full items-center justify-between gap-3 px-4 py-2 text-left">
<span class="truncate font-mono text-sm" x-text="item.label"></span>
<span class="shrink-0 font-mono text-[10px] uppercase tracking-wider text-ink-4" x-text="item.hint"></span>
</button>
</li>
</template>
<li x-show="items.length === 0" class="px-4 py-3 font-mono text-[11px] text-ink-4">Kein Treffer.</li>
</ul>
<div class="flex items-center justify-between border-t border-line px-4 py-2 font-mono text-[10px] text-ink-4">
<span class="flex items-center gap-1.5"><x-icon name="corner-down-left" class="h-3 w-3" /> öffnen</span>
<button type="button" @click="open = false; help = true" class="hover:text-ink-2">? Tastenkürzel</button>
</div>
</div>
</div>
{{-- Shortcut help --}}
<div x-show="help" x-cloak class="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[12vh]" role="dialog" aria-modal="true">
<div class="fixed inset-0 bg-void/70 backdrop-blur-sm" @click="help = false" aria-hidden="true"></div>
<div x-show="help" x-transition class="relative w-full max-w-md overflow-hidden rounded-lg border border-line bg-surface shadow-pop">
<div class="flex items-center gap-2.5 border-b border-line px-4 py-3">
<x-icon name="command" class="h-4 w-4 shrink-0 text-accent-text" />
<h2 class="font-display text-sm font-semibold text-ink">Tastenkürzel</h2>
<button type="button" @click="help = false" class="ml-auto text-ink-4 hover:text-ink" aria-label="Schließen">
<x-icon name="x" class="h-4 w-4" />
</button>
</div>
<dl class="divide-y divide-line">
@foreach ($chords as [$chord, $desc])
<div class="flex items-center justify-between gap-3 px-4 py-2">
<dt class="text-sm text-ink-2">{{ $desc }}</dt>
<dd class="shrink-0"><span class="{{ $kbd }}">{{ $chord }}</span></dd>
</div>
@endforeach
</dl>
</div>
</div>
</div>

View File

@ -15,6 +15,8 @@
'folder' => '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
'audit' => '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',
'file' => '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
'command' => '<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3"/>',
'corner-down-left' => '<path d="M20 4v7a4 4 0 0 1-4 4H4"/><path d="m9 10-5 5 5 5"/>',
'activity' => '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
'switcher' => '<path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/>',
'search' => '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',

View File

@ -9,6 +9,12 @@
<h1 class="min-w-0 truncate font-display text-sm font-semibold text-ink sm:text-base">{{ $title }}</h1>
<div class="ml-auto flex min-w-0 items-center gap-2">
<button type="button" @click="$dispatch('cmdk-open')"
class="hidden items-center gap-2 rounded-md border border-line bg-inset px-2.5 py-1.5 text-ink-3 transition-colors hover:border-line-strong hover:text-ink sm:flex"
aria-label="Befehle öffnen (Strg K)" title="Befehle (Strg K)">
<x-icon name="command" class="h-3.5 w-3.5" />
<span class="font-mono text-[10px] uppercase tracking-wider">Strg K</span>
</button>
@php
$fleetTotal = \App\Models\Server::count();
$fleetOnline = \App\Models\Server::where('status', 'online')->count();

View File

@ -48,6 +48,9 @@
</template>
</div>
{{-- Command palette + keyboard shortcuts (persistent; one global keydown listener) --}}
<x-command-palette />
<livewire:wire-elements-modal />
@livewireScripts
</body>

View File

@ -18,6 +18,7 @@
</span>
<input
type="search"
data-page-search
wire:model.live.debounce.300ms="q"
placeholder="Akteur, Aktion, Ziel …"
class="min-h-11 w-44 rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-64"

View File

@ -34,6 +34,7 @@
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
<input
type="search"
data-page-search
wire:model.live.debounce.300ms="search"
placeholder="Name oder IP…"
class="min-h-11 w-full rounded-md border border-line bg-inset py-1.5 pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"

View File

@ -29,6 +29,7 @@
<x-icon name="search" class="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
<input
type="search"
data-page-search
wire:model.live.debounce.250ms="search"
placeholder="Dienst suchen…"
class="h-9 w-40 rounded-md border border-line bg-inset pl-8 pr-3 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none sm:w-56"