139 lines
5.5 KiB
Svelte
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}
|