Files
nas-tools/dashboard/frontend/src/components/CommandPalette.svelte
T
Gan, Jimmy bfa11b5f26
Deploy Dashboard (Dev) / Backend Tests (push) Successful in 1m15s
Deploy Dashboard (Dev) / Frontend Tests (push) Successful in 55s
Deploy Dashboard (Dev) / Build Dev Image (push) Failing after 13m49s
Deploy Dashboard (Dev) / Deploy to Dev (push) Has been skipped
feat(dashboard): service health widget, Cmd+K palette, CI runs, pinned shortcuts
2026-07-09 03:36:31 +08:00

139 lines
5.5 KiB
Svelte

<script>
let { items = [], onNavigate = () => {}, onOpenExternal = () => {} } = $props();
let open = $state(false);
let query = $state("");
let selectedIndex = $state(0);
let inputEl = $state(null);
function fuzzyMatch(text, q) {
if (!q) return true;
const lower = text.toLowerCase();
const ql = q.toLowerCase();
let qi = 0;
for (let i = 0; i < lower.length && qi < ql.length; i++) {
if (lower[i] === ql[qi]) qi++;
}
return qi === ql.length;
}
let filtered = $derived(
items.filter(i => fuzzyMatch(i.label + " " + (i.section || ""), query))
);
function handleKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
open = !open;
if (open) {
query = "";
selectedIndex = 0;
}
}
if (!open) return;
if (e.key === "ArrowDown") {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filtered.length - 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
} else if (e.key === "Enter" && filtered[selectedIndex]) {
e.preventDefault();
select(filtered[selectedIndex]);
} else if (e.key === "Escape") {
e.preventDefault();
open = false;
}
}
function select(item) {
open = false;
if (item.external || item.remoteHref) {
window.open(item.remoteHref || item.href, "_blank", "noopener");
} else if (item.id) {
onNavigate(item.id);
}
}
function sectionClass(section) {
if (section === "main") return "text-sky-500";
if (section === "media") return "text-violet-500";
if (section === "tools") return "text-amber-500";
return "text-surface-400";
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if open}
<!-- Overlay -->
<button class="fixed inset-0 bg-black/40 z-[100] backdrop-blur-sm" onclick={() => open = false}></button>
<!-- Palette -->
<div class="fixed top-[15%] left-1/2 -translate-x-1/2 z-[101] w-[520px] max-w-[90vw]" role="dialog" aria-modal="true">
<div class="bg-white dark:bg-surface-800 rounded-2xl border border-surface-200 dark:border-surface-700 shadow-2xl overflow-hidden">
<!-- Search -->
<div class="flex items-center px-4 border-b border-surface-200 dark:border-surface-700">
<svg class="w-4 h-4 text-surface-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg>
<input
bind:this={inputEl}
type="text"
placeholder="Search services, pages, actions..."
bind:value={query}
class="w-full bg-transparent border-none outline-none px-3 py-4 text-sm text-surface-800 dark:text-surface-200 placeholder-surface-400"
autofocus
/>
<kbd class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700 text-surface-400">esc</kbd>
</div>
<!-- Results -->
<div class="max-h-[360px] overflow-y-auto p-2">
{#if filtered.length === 0}
<div class="py-8 text-center text-sm text-surface-400">No results found</div>
{:else}
{#each filtered as item, i}
<button
class="w-full text-left px-3 py-2.5 rounded-xl text-sm flex items-center gap-3 transition-all duration-100
{i === selectedIndex
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 shadow-sm'
: 'text-surface-600 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700'}
"
onmouseenter={() => selectedIndex = i}
onclick={() => select(item)}
>
<span class="w-6 h-6 rounded-lg flex items-center justify-center text-xs font-bold uppercase {sectionClass(item.section)}">
{item.section ? item.section[0] : "?"}
</span>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{item.label}</div>
{#if item.description}
<div class="text-[11px] text-surface-400 truncate">{item.description}</div>
{/if}
</div>
{#if item.external || item.remoteHref}
<svg class="w-3 h-3 text-surface-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
{/if}
</button>
{/each}
{/if}
</div>
<!-- Footer hint -->
<div class="flex items-center gap-4 px-4 py-2.5 border-t border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800/50">
<div class="flex items-center gap-1.5 text-[10px] text-surface-400">
<kbd class="font-mono px-1 py-0.5 rounded bg-surface-200 dark:bg-surface-600 text-surface-500">↑↓</kbd>
<span>Navigate</span>
</div>
<div class="flex items-center gap-1.5 text-[10px] text-surface-400">
<kbd class="font-mono px-1 py-0.5 rounded bg-surface-200 dark:bg-surface-600 text-surface-500"></kbd>
<span>Open</span>
</div>
<div class="flex items-center gap-1.5 text-[10px] text-surface-400">
<kbd class="font-mono px-1 py-0.5 rounded bg-surface-200 dark:bg-surface-600 text-surface-500">esc</kbd>
<span>Close</span>
</div>
</div>
</div>
</div>
{/if}