your personal website on atproto - mirror blento.app
at update-link-card 214 lines 6.9 kB view raw
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>