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
parent
e792e9a3af
commit
2c533bf821
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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"/>',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@
|
|||
</template>
|
||||
</div>
|
||||
|
||||
{{-- Command palette + keyboard shortcuts (persistent; one global keydown listener) --}}
|
||||
<x-command-palette />
|
||||
|
||||
<livewire:wire-elements-modal />
|
||||
@livewireScripts
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue