aboutsummaryrefslogtreecommitdiff
path: root/src/lib/CommandPalette
diff options
context:
space:
mode:
authorFuwn <[email protected]>2025-05-06 06:04:52 -0700
committerFuwn <[email protected]>2025-05-06 06:04:52 -0700
commit7771e2618d3bffbcaebc23a21d2e34fe4012d89a (patch)
tree376e4ceec4e50bc8dfb2b0990e0e619366c87b36 /src/lib/CommandPalette
parentfix(anime): Exclude AiOmoDarkElf from subtitle matching (diff)
downloaddue.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.svelte161
-rw-r--r--src/lib/CommandPalette/actions.ts34
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'
+ }
+];