diff options
| author | Fuwn <[email protected]> | 2025-05-06 06:04:52 -0700 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2025-05-06 06:04:52 -0700 |
| commit | 7771e2618d3bffbcaebc23a21d2e34fe4012d89a (patch) | |
| tree | 376e4ceec4e50bc8dfb2b0990e0e619366c87b36 /src/lib/CommandPalette | |
| parent | fix(anime): Exclude AiOmoDarkElf from subtitle matching (diff) | |
| download | due.moe-7771e2618d3bffbcaebc23a21d2e34fe4012d89a.tar.xz due.moe-7771e2618d3bffbcaebc23a21d2e34fe4012d89a.zip | |
feat: Add command palette
Diffstat (limited to 'src/lib/CommandPalette')
| -rw-r--r-- | src/lib/CommandPalette/CommandPalette.svelte | 161 | ||||
| -rw-r--r-- | src/lib/CommandPalette/actions.ts | 34 |
2 files changed, 195 insertions, 0 deletions
diff --git a/src/lib/CommandPalette/CommandPalette.svelte b/src/lib/CommandPalette/CommandPalette.svelte new file mode 100644 index 00000000..36c97a91 --- /dev/null +++ b/src/lib/CommandPalette/CommandPalette.svelte @@ -0,0 +1,161 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + + interface CommandPaletteItem { + name: string; + url: string; + onClick?: () => void; + preventDefault?: boolean; + } + + export let items: CommandPaletteItem[] = []; + export let open = false; + + let search = ''; + let filtered: CommandPaletteItem[] = []; + let selectedIndex = -1; + let inputRef: HTMLInputElement; + + $: filtered = items + .filter((item) => item.name.toLowerCase().includes(search.toLowerCase())) + .slice(0, 10); + + $: if (selectedIndex >= filtered.length) selectedIndex = filtered.length - 1; + $: if (selectedIndex < 0 && filtered.length > 0) selectedIndex = 0; + + const executeItem = (item: CommandPaletteItem) => { + if (item.onClick) item.onClick(); + if (!item.preventDefault) window.location.href = item.url; + + open = false; + }; + + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + + selectedIndex = (selectedIndex + 1) % filtered.length; + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + + selectedIndex = (selectedIndex - 1 + filtered.length) % filtered.length; + } else if (e.key === 'Enter') { + if (filtered.length === 1) { + executeItem(filtered[0]); + } else if (filtered.length > 1 && selectedIndex >= 0) { + executeItem(filtered[selectedIndex]); + } + } else if (e.key === 'Escape') { + open = false; + } + }; + + onMount(() => { + window.addEventListener('keydown', handleGlobalKey); + + return () => window.removeEventListener('keydown', handleGlobalKey); + }); + + const handleClickOutside = (event: MouseEvent) => { + if (!event.target.closest('.dropdown')) open = false; + }; + + const handleGlobalKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + + open = !open; + + if (open) requestAnimationFrame(() => inputRef?.focus()); + } + }; +</script> + +<svelte:window on:click={handleClickOutside} /> + +{#if open} + <div class="dropdown"> + <div class="dropdown-content card card-small"> + <input + bind:this={inputRef} + bind:value={search} + class="command-input" + placeholder="Search" + on:keydown={handleKey} + /> + + {#each filtered as item, i} + <a + href={item.url} + class="header-item {selectedIndex === i ? 'selected' : ''}" + on:click={(e) => { + if (item.preventDefault) e.preventDefault(); + if (item.onClick) item.onClick(); + + open = false; + }} + > + {item.name} + </a> + {/each} + </div> + </div> +{/if} + +<style lang="scss"> + a { + color: var(--base06); + } + + .header-item { + margin: 0 0.5rem; + } + + .header-item:hover { + text-decoration: none; + } + + .header-item:active { + outline: none; + } + + .dropdown { + position: fixed; + top: 15%; + left: 50%; + transform: translateX(-50%); + z-index: 1; + width: min(600px, 90%); + font-size: 1.05em; + } + + .command-input { + width: calc(100% - 2rem); + padding: 0.5em 0.75em; + margin: 0.5rem 1rem; + margin-bottom: 0.75rem; + outline: none; + } + + .dropdown-content { + display: block; + position: relative; + min-width: max-content; + padding: 0.5em 0; + } + + .dropdown-content a { + padding: 0.5em 0.75em; + text-decoration: none; + display: block; + border-radius: 8px; + margin: 0.5em 0.75em; + backdrop-filter: blur(0px); + font-weight: 450; + } + + .dropdown-content a:hover, + .dropdown-content a.selected { + border: 2px solid var(--base06); + } +</style> diff --git a/src/lib/CommandPalette/actions.ts b/src/lib/CommandPalette/actions.ts new file mode 100644 index 00000000..73128a2c --- /dev/null +++ b/src/lib/CommandPalette/actions.ts @@ -0,0 +1,34 @@ +export const defaultActions = [ + { + name: 'Home', + url: '/' + }, + { + name: 'Completed', + url: '/completed' + }, + { + name: 'Subtitle Schedule', + url: '/schedule' + }, + { + name: 'hololive Schedule', + url: '/hololive' + }, + { + name: 'Character Birthdays', + url: '/birthdays' + }, + { + name: 'New Releases', + url: '/releases' + }, + { + name: 'Settings', + url: '/settings' + }, + { + name: 'My Profile', + url: '/user' + } +]; |