your personal website on atproto - mirror blento.app
at pages 396 lines 13 kB view raw
1<script lang="ts"> 2 import { dev } from '$app/environment'; 3 import { user } from '$lib/atproto'; 4 import { COLUMNS } from '$lib'; 5 import type { Item, WebsiteData } from '$lib/types'; 6 import { CardDefinitionsByType } from '$lib/cards'; 7 import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core'; 8 import { ColorSelect } from '@foxui/colors'; 9 10 let { 11 data, 12 13 showingMobileView = $bindable(), 14 isSaving = $bindable(), 15 hasUnsavedChanges, 16 17 save, 18 19 handleImageInputChange, 20 handleVideoInputChange, 21 22 newCard, 23 addLink, 24 linkValue = $bindable(''), 25 26 showCardCommand, 27 selectedCard = null, 28 isMobile = false, 29 isCoarse = false, 30 ondeselect, 31 ondelete, 32 onsetsize 33 }: { 34 data: WebsiteData; 35 36 showingMobileView: boolean; 37 38 isSaving: boolean; 39 hasUnsavedChanges: boolean; 40 41 save: () => Promise<void>; 42 43 handleImageInputChange: (evt: Event) => void; 44 handleVideoInputChange: (evt: Event) => void; 45 46 newCard: (type?: string, cardData?: any) => void; 47 addLink: (url: string) => void; 48 linkValue: string; 49 50 showCardCommand: () => void; 51 selectedCard?: Item | null; 52 isMobile?: boolean; 53 isCoarse?: boolean; 54 ondeselect?: () => void; 55 ondelete?: () => void; 56 onsetsize?: (w: number, h: number) => void; 57 } = $props(); 58 59 let linkPopoverOpen = $state(false); 60 let imageInputRef: HTMLInputElement | undefined = $state(); 61 let videoInputRef: HTMLInputElement | undefined = $state(); 62 63 function getShareUrl() { 64 const base = typeof window !== 'undefined' ? window.location.origin : ''; 65 const pagePath = 66 data.page && data.page !== 'blento.self' ? `/${data.page.replace('blento.', '')}` : ''; 67 return `${base}/${data.handle}${pagePath}`; 68 } 69 70 async function copyShareLink() { 71 const url = getShareUrl(); 72 await navigator.clipboard.writeText(url); 73 toast.success('Link copied to clipboard!'); 74 } 75 76 let colorsChoices = [ 77 { class: 'text-base-500', label: 'base' }, 78 { class: 'text-accent-500', label: 'accent' }, 79 { class: 'text-base-300 dark:text-base-700', label: 'transparent' }, 80 { class: 'text-red-500', label: 'red' }, 81 { class: 'text-orange-500', label: 'orange' }, 82 { class: 'text-amber-500', label: 'amber' }, 83 { class: 'text-yellow-500', label: 'yellow' }, 84 { class: 'text-lime-500', label: 'lime' }, 85 { class: 'text-green-500', label: 'green' }, 86 { class: 'text-emerald-500', label: 'emerald' }, 87 { class: 'text-teal-500', label: 'teal' }, 88 { class: 'text-cyan-500', label: 'cyan' }, 89 { class: 'text-sky-500', label: 'sky' }, 90 { class: 'text-blue-500', label: 'blue' }, 91 { class: 'text-indigo-500', label: 'indigo' }, 92 { class: 'text-violet-500', label: 'violet' }, 93 { class: 'text-purple-500', label: 'purple' }, 94 { class: 'text-fuchsia-500', label: 'fuchsia' }, 95 { class: 'text-pink-500', label: 'pink' }, 96 { class: 'text-rose-500', label: 'rose' } 97 ]; 98 99 let selectedColor = $derived( 100 selectedCard 101 ? colorsChoices.find((c) => (selectedCard!.color ?? 'base') === c.label) 102 : undefined 103 ); 104 105 let cardDef = $derived( 106 selectedCard ? (CardDefinitionsByType[selectedCard.cardType] ?? null) : null 107 ); 108 109 let colorPopoverOpen = $state(false); 110 let sizePopoverOpen = $state(false); 111 let settingsPopoverOpen = $state(false); 112 113 const minW = $derived(cardDef?.minW ?? 2); 114 const minH = $derived(cardDef?.minH ?? 2); 115 const maxW = $derived(cardDef?.maxW ?? COLUMNS); 116 const maxH = $derived(cardDef?.maxH ?? (isMobile ? 12 : 6)); 117 118 function canSetSize(w: number, h: number) { 119 if (!cardDef) return false; 120 if (isMobile) { 121 return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH; 122 } 123 return w >= minW && w <= maxW && h >= minH && h <= maxH; 124 } 125 126 const showMobileEditControls = $derived(isCoarse && selectedCard); 127</script> 128 129<input 130 type="file" 131 accept="image/*" 132 onchange={handleImageInputChange} 133 class="hidden" 134 id="image-input" 135 multiple 136 bind:this={imageInputRef} 137/> 138 139<input 140 type="file" 141 accept="video/*" 142 onchange={handleVideoInputChange} 143 class="hidden" 144 id="video-input" 145 multiple 146 bind:this={videoInputRef} 147/> 148 149{#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 150 <Navbar 151 class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto" 152 > 153 {#if showMobileEditControls} 154 <!-- Mobile edit controls: left = color, size, settings; right = delete, deselect --> 155 <div class="flex items-center gap-1"> 156 {#if cardDef?.allowSetColor !== false} 157 <Popover bind:open={colorPopoverOpen}> 158 {#snippet child({ props })} 159 <button 160 {...props} 161 class={[ 162 'cursor-pointer rounded-xl p-2', 163 !selectedCard?.color || 164 selectedCard.color === 'base' || 165 selectedCard.color === 'transparent' 166 ? 'text-base-800 dark:text-base-200' 167 : 'text-accent-500' 168 ]} 169 > 170 <svg 171 xmlns="http://www.w3.org/2000/svg" 172 viewBox="0 0 24 24" 173 fill="currentColor" 174 class="size-5" 175 > 176 <path 177 fill-rule="evenodd" 178 d="M20.599 1.5c-.376 0-.743.111-1.055.32l-5.08 3.385a18.747 18.747 0 0 0-3.471 2.987 10.04 10.04 0 0 1 4.815 4.815 18.748 18.748 0 0 0 2.987-3.472l3.386-5.079A1.902 1.902 0 0 0 20.599 1.5Zm-8.3 14.025a18.76 18.76 0 0 0 1.896-1.207 8.026 8.026 0 0 0-4.513-4.513A18.75 18.75 0 0 0 8.475 11.7l-.278.5a5.26 5.26 0 0 1 3.601 3.602l.502-.278ZM6.75 13.5A3.75 3.75 0 0 0 3 17.25a1.5 1.5 0 0 1-1.601 1.497.75.75 0 0 0-.7 1.123 5.25 5.25 0 0 0 9.8-2.62 3.75 3.75 0 0 0-3.75-3.75Z" 179 clip-rule="evenodd" 180 /> 181 </svg> 182 </button> 183 {/snippet} 184 <ColorSelect 185 selected={selectedColor} 186 colors={colorsChoices} 187 onselected={(color, previous) => { 188 if (typeof previous === 'string' || typeof color === 'string') { 189 return; 190 } 191 if (selectedCard) { 192 selectedCard.color = color.label; 193 } 194 }} 195 class="w-64" 196 /> 197 </Popover> 198 {/if} 199 200 <Popover bind:open={sizePopoverOpen}> 201 {#snippet child({ props })} 202 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 203 <svg 204 xmlns="http://www.w3.org/2000/svg" 205 fill="none" 206 viewBox="0 0 24 24" 207 stroke-width="1.5" 208 stroke="currentColor" 209 class="size-5" 210 > 211 <path 212 stroke-linecap="round" 213 stroke-linejoin="round" 214 d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" 215 /> 216 </svg> 217 </button> 218 {/snippet} 219 <div class="flex items-center gap-1"> 220 {#if canSetSize(2, 2)} 221 <button 222 onclick={() => onsetsize?.(4, 4)} 223 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 224 > 225 <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 226 <span class="sr-only">set size to 1x1</span> 227 </button> 228 {/if} 229 {#if canSetSize(4, 2)} 230 <button 231 onclick={() => onsetsize?.(8, 4)} 232 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 233 > 234 <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 235 <span class="sr-only">set size to 2x1</span> 236 </button> 237 {/if} 238 {#if canSetSize(2, 4)} 239 <button 240 onclick={() => onsetsize?.(4, 8)} 241 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 242 > 243 <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 244 <span class="sr-only">set size to 1x2</span> 245 </button> 246 {/if} 247 {#if canSetSize(4, 4)} 248 <button 249 onclick={() => onsetsize?.(8, 8)} 250 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 251 > 252 <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 253 <span class="sr-only">set size to 2x2</span> 254 </button> 255 {/if} 256 </div> 257 </Popover> 258 259 {#if cardDef?.settingsComponent && selectedCard} 260 <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 261 {#snippet child({ props })} 262 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 263 <svg 264 xmlns="http://www.w3.org/2000/svg" 265 fill="none" 266 viewBox="0 0 24 24" 267 stroke-width="2" 268 stroke="currentColor" 269 class="size-5" 270 > 271 <path 272 stroke-linecap="round" 273 stroke-linejoin="round" 274 d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 275 /> 276 <path 277 stroke-linecap="round" 278 stroke-linejoin="round" 279 d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 280 /> 281 </svg> 282 </button> 283 {/snippet} 284 <cardDef.settingsComponent 285 bind:item={selectedCard} 286 onclose={() => { 287 settingsPopoverOpen = false; 288 }} 289 /> 290 </Popover> 291 {/if} 292 </div> 293 <div class="flex items-center gap-1"> 294 <Button 295 size="iconLg" 296 variant="ghost" 297 class="text-rose-500 backdrop-blur-none" 298 onclick={() => ondelete?.()} 299 > 300 <svg 301 xmlns="http://www.w3.org/2000/svg" 302 fill="none" 303 viewBox="0 0 24 24" 304 stroke-width="1.5" 305 stroke="currentColor" 306 class="size-5" 307 > 308 <path 309 stroke-linecap="round" 310 stroke-linejoin="round" 311 d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 312 /> 313 </svg> 314 </Button> 315 <Button 316 size="iconLg" 317 variant="ghost" 318 class="backdrop-blur-none" 319 onclick={() => ondeselect?.()} 320 > 321 <svg 322 xmlns="http://www.w3.org/2000/svg" 323 fill="none" 324 viewBox="0 0 24 24" 325 stroke-width="2" 326 stroke="currentColor" 327 class="size-5" 328 > 329 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 330 </svg> 331 </Button> 332 </div> 333 {:else} 334 <div class="flex items-center gap-2"> 335 <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 336 <svg 337 xmlns="http://www.w3.org/2000/svg" 338 fill="none" 339 viewBox="0 0 24 24" 340 stroke-width="1.5" 341 stroke="currentColor" 342 > 343 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 344 </svg> 345 </Button> 346 </div> 347 {/if} 348 <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 349 <Toggle 350 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 351 bind:pressed={showingMobileView} 352 > 353 <svg 354 xmlns="http://www.w3.org/2000/svg" 355 fill="none" 356 viewBox="0 0 24 24" 357 stroke-width="1.5" 358 stroke="currentColor" 359 class="size-6" 360 > 361 <path 362 stroke-linecap="round" 363 stroke-linejoin="round" 364 d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" 365 /> 366 </svg> 367 </Toggle> 368 {#if hasUnsavedChanges} 369 <Button 370 disabled={isSaving} 371 onclick={async () => { 372 save(); 373 }}>{isSaving ? 'Saving...' : 'Save'}</Button 374 > 375 {:else} 376 <Button onclick={copyShareLink}> 377 <svg 378 xmlns="http://www.w3.org/2000/svg" 379 fill="none" 380 viewBox="0 0 24 24" 381 stroke-width="1.5" 382 stroke="currentColor" 383 class="size-5" 384 > 385 <path 386 stroke-linecap="round" 387 stroke-linejoin="round" 388 d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" 389 /> 390 </svg> 391 Share 392 </Button> 393 {/if} 394 </div> 395 </Navbar> 396{/if}