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 import { describeRepo, user } from '$lib/atproto';
7
8 let {
9 open = $bindable(false),
10 onselect,
11 onlink
12 }: {
13 open: boolean;
14 onselect: (cardDef: CardDefinition) => void;
15 onlink?: (url: string, cardDef: CardDefinition) => void;
16 } = $props();
17
18 let collections = $state<string[]>([]);
19 let fetchedForDid = $state<string | undefined>(undefined);
20
21 $effect(() => {
22 if (open && user.did && fetchedForDid !== user.did) {
23 const did = user.did;
24 describeRepo({ did }).then((result) => {
25 if (result?.collections) {
26 collections = result.collections;
27 }
28 fetchedForDid = did;
29 });
30 }
31 });
32
33 let filteredCardDefs = $derived(
34 AllCardDefinitions.filter((d) => !d.canAdd || d.canAdd({ collections }))
35 );
36
37 let cardDefGroups = $derived([
38 'Core',
39 ...Array.from(
40 new Set(
41 filteredCardDefs
42 .map((cardDef) => cardDef.groups)
43 .flat()
44 .filter((g) => g)
45 )
46 )
47 .sort()
48 .filter((g) => g !== 'Core')
49 ]);
50
51 let searchValue = $state('');
52
53 let normalizedUrl = $derived.by(() => {
54 if (!searchValue || searchValue.length < 8) return '';
55 try {
56 const val = searchValue.trim();
57 const urlStr = val.startsWith('http') ? val : `https://${val}`;
58 const url = new URL(urlStr);
59 if (!url.hostname.includes('.')) return '';
60 return urlStr;
61 } catch {
62 return '';
63 }
64 });
65
66 let urlMatchingCards = $derived.by(() => {
67 if (!normalizedUrl) return [];
68 return filteredCardDefs
69 .filter((d) => d.onUrlHandler)
70 .filter((d) => {
71 try {
72 const testItem = { cardData: {} };
73 return d.onUrlHandler!(normalizedUrl, testItem as any);
74 } catch {
75 return false;
76 }
77 })
78 .toSorted((a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0));
79 });
80
81 function selectUrl(cardDef: CardDefinition) {
82 const url = normalizedUrl;
83 open = false;
84 searchValue = '';
85 onlink?.(url, cardDef);
86 }
87
88 function commandFilter(value: string, search: string, keywords?: string[]): number {
89 if (value.startsWith('url:')) return 1;
90 const s = search.toLowerCase();
91 for (const t of [value, ...(keywords ?? [])]) {
92 if (t.toLowerCase().includes(s)) return 1;
93 }
94 return 0;
95 }
96
97 function handleKeydown(e: KeyboardEvent) {
98 if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
99 e.preventDefault();
100 open = !open;
101 }
102 if (e.key === '+' && !isTyping()) {
103 e.preventDefault();
104 open = true;
105 }
106 }
107</script>
108
109<svelte:document onkeydown={handleKeydown} />
110
111<Dialog.Root bind:open>
112 <Dialog.Portal>
113 <Dialog.Overlay
114 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"
115 />
116 <Dialog.Content
117 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"
118 >
119 <Dialog.Title class="sr-only">Command Menu</Dialog.Title>
120 <Dialog.Description class="sr-only">
121 This is the command menu. Use the arrow keys to navigate and press ⌘K to open the search
122 bar.
123 </Dialog.Description>
124 <Command.Root
125 filter={commandFilter}
126 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"
127 >
128 <Command.Input
129 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"
130 placeholder="Search for a card or paste a link..."
131 oninput={(e) => {
132 searchValue = e.currentTarget.value;
133 }}
134 />
135
136 <Command.List
137 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"
138 >
139 <Command.Viewport>
140 <Command.Empty
141 class="text-base-900 dark:text-base-100 flex w-full items-center justify-center pt-8 pb-6 text-sm"
142 >
143 No results found.
144 </Command.Empty>
145
146 {#if urlMatchingCards.length > 0}
147 <Command.Group>
148 <Command.GroupHeading
149 class="text-base-600 dark:text-base-400 px-3 pt-3 pb-2 text-xs"
150 >
151 Add from link
152 </Command.GroupHeading>
153 <Command.GroupItems>
154 {#each urlMatchingCards as cardDef (cardDef.type)}
155 <Command.Item
156 value="url:{cardDef.type}"
157 onSelect={() => {
158 selectUrl(cardDef);
159 }}
160 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"
161 >
162 {#if cardDef.icon}
163 <div class="text-base-700 dark:text-base-300">
164 {@html cardDef.icon}
165 </div>
166 {/if}
167 {cardDef.name}
168 </Command.Item>
169 {/each}
170 </Command.GroupItems>
171 </Command.Group>
172 <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" />
173 {/if}
174
175 {#each cardDefGroups as group, index (group)}
176 {#if group && filteredCardDefs.some((cardDef) => cardDef.groups?.includes(group))}
177 <Command.Group>
178 <Command.GroupHeading
179 class="text-base-600 dark:text-base-400 px-3 pt-4 pb-2 text-xs"
180 >
181 {group}
182 </Command.GroupHeading>
183 <Command.GroupItems>
184 {#each filteredCardDefs.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef (cardDef.type)}
185 <Command.Item
186 onSelect={() => {
187 open = false;
188 searchValue = '';
189 onselect(cardDef);
190 }}
191 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"
192 keywords={[group, cardDef.type, ...(cardDef.keywords || [])]}
193 >
194 {#if cardDef.icon}
195 <div class="text-base-700 dark:text-base-300">
196 {@html cardDef.icon}
197 </div>
198 {/if}
199 {cardDef.name}
200 </Command.Item>
201 {/each}
202 </Command.GroupItems>
203 </Command.Group>
204 {#if index < cardDefGroups.length - 1}
205 <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" />
206 {/if}
207 {/if}
208 {/each}
209 </Command.Viewport>
210 </Command.List>
211 </Command.Root>
212 </Dialog.Content>
213 </Dialog.Portal>
214</Dialog.Root>