your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import { AllCardDefinitions } from '$lib/cards';
3 import type { CardDefinition } from '$lib/cards/types';
4 import { Command, Dialog } from 'bits-ui';
5 import { isTyping } from '$lib/helper';
6
7 const CardDefGroups = [
8 'Core',
9 ...Array.from(
10 new Set(
11 AllCardDefinitions.map((cardDef) => cardDef.groups)
12 .flat()
13 .filter((g) => g)
14 )
15 )
16 .sort()
17 .filter((g) => g !== 'Core')
18 ];
19
20 let {
21 open = $bindable(false),
22 onselect,
23 onlink
24 }: {
25 open: boolean;
26 onselect: (cardDef: CardDefinition) => void;
27 onlink?: (url: string, cardDef: CardDefinition) => void;
28 } = $props();
29
30 let searchValue = $state('');
31
32 let normalizedUrl = $derived.by(() => {
33 if (!searchValue || searchValue.length < 8) return '';
34 try {
35 const val = searchValue.trim();
36 const urlStr = val.startsWith('http') ? val : `https://${val}`;
37 const url = new URL(urlStr);
38 if (!url.hostname.includes('.')) return '';
39 return urlStr;
40 } catch {
41 return '';
42 }
43 });
44
45 let urlMatchingCards = $derived.by(() => {
46 if (!normalizedUrl) return [];
47 return AllCardDefinitions.filter((d) => d.onUrlHandler)
48 .filter((d) => {
49 try {
50 const testItem = { cardData: {} };
51 return d.onUrlHandler!(normalizedUrl, testItem as any);
52 } catch {
53 return false;
54 }
55 })
56 .toSorted((a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0));
57 });
58
59 function selectUrl(cardDef: CardDefinition) {
60 const url = normalizedUrl;
61 open = false;
62 searchValue = '';
63 onlink?.(url, cardDef);
64 }
65
66 function commandFilter(value: string, search: string, keywords?: string[]): number {
67 if (value.startsWith('url:')) return 1;
68 const s = search.toLowerCase();
69 for (const t of [value, ...(keywords ?? [])]) {
70 if (t.toLowerCase().includes(s)) return 1;
71 }
72 return 0;
73 }
74
75 function handleKeydown(e: KeyboardEvent) {
76 if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
77 e.preventDefault();
78 open = !open;
79 }
80 if (e.key === '+' && !isTyping()) {
81 e.preventDefault();
82 open = true;
83 }
84 }
85</script>
86
87<svelte:document onkeydown={handleKeydown} />
88
89<Dialog.Root bind:open>
90 <Dialog.Portal>
91 <Dialog.Overlay
92 class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
93 />
94 <Dialog.Content
95 class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-36 left-[50%] z-50 w-full max-w-[94%] translate-x-[-50%] outline-hidden sm:max-w-lg md:w-full"
96 >
97 <Dialog.Title class="sr-only">Command Menu</Dialog.Title>
98 <Dialog.Description class="sr-only">
99 This is the command menu. Use the arrow keys to navigate and press ⌘K to open the search
100 bar.
101 </Dialog.Description>
102 <Command.Root
103 filter={commandFilter}
104 class="border-base-200 dark:border-base-800 mx-auto flex h-full w-full max-w-[90vw] flex-col overflow-hidden rounded-2xl border bg-white dark:bg-black"
105 >
106 <Command.Input
107 class="focus-override placeholder:text-base-900/50 dark:placeholder:text-base-50/50 border-base-200 dark:border-base-800 bg-base-100 mx-1 mt-1 inline-flex truncate rounded-2xl rounded-tl-2xl px-4 text-sm transition-colors focus:ring-0 focus:outline-hidden dark:bg-black"
108 placeholder="Search for a card or paste a link..."
109 oninput={(e) => {
110 searchValue = e.currentTarget.value;
111 }}
112 />
113
114 <Command.List
115 class="focus:outline-accent-500/50 max-h-[50vh] overflow-x-hidden overflow-y-auto rounded-br-2xl rounded-bl-2xl bg-white px-2 pb-2 focus:border-0 dark:bg-black"
116 >
117 <Command.Viewport>
118 <Command.Empty
119 class="text-base-900 dark:text-base-100 flex w-full items-center justify-center pt-8 pb-6 text-sm"
120 >
121 No results found.
122 </Command.Empty>
123
124 {#if urlMatchingCards.length > 0}
125 <Command.Group>
126 <Command.GroupHeading
127 class="text-base-600 dark:text-base-400 px-3 pt-3 pb-2 text-xs"
128 >
129 Add from link
130 </Command.GroupHeading>
131 <Command.GroupItems>
132 {#each urlMatchingCards as cardDef (cardDef.type)}
133 <Command.Item
134 value="url:{cardDef.type}"
135 onSelect={() => {
136 selectUrl(cardDef);
137 }}
138 class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none"
139 >
140 {#if cardDef.icon}
141 <div class="text-base-700 dark:text-base-300">
142 {@html cardDef.icon}
143 </div>
144 {/if}
145 {cardDef.name}
146 </Command.Item>
147 {/each}
148 </Command.GroupItems>
149 </Command.Group>
150 <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" />
151 {/if}
152
153 {#each CardDefGroups as group, index (group)}
154 {#if group && AllCardDefinitions.some((cardDef) => cardDef.groups?.includes(group))}
155 <Command.Group>
156 <Command.GroupHeading
157 class="text-base-600 dark:text-base-400 px-3 pt-4 pb-2 text-xs"
158 >
159 {group}
160 </Command.GroupHeading>
161 <Command.GroupItems>
162 {#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef (cardDef.type)}
163 <Command.Item
164 onSelect={() => {
165 open = false;
166 searchValue = '';
167 onselect(cardDef);
168 }}
169 class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none"
170 keywords={[group, cardDef.type, ...(cardDef.keywords || [])]}
171 >
172 {#if cardDef.icon}
173 <div class="text-base-700 dark:text-base-300">
174 {@html cardDef.icon}
175 </div>
176 {/if}
177 {cardDef.name}
178 </Command.Item>
179 {/each}
180 </Command.GroupItems>
181 </Command.Group>
182 {#if index < CardDefGroups.length - 1}
183 <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" />
184 {/if}
185 {/if}
186 {/each}
187 </Command.Viewport>
188 </Command.List>
189 </Command.Root>
190 </Dialog.Content>
191 </Dialog.Portal>
192</Dialog.Root>