your personal website on atproto - mirror blento.app
at mobile-editing 510 lines 16 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 linkValue = $bindable(), 13 newCard, 14 addLink, 15 16 showingMobileView = $bindable(), 17 isSaving = $bindable(), 18 hasUnsavedChanges, 19 20 save, 21 22 handleImageInputChange, 23 handleVideoInputChange, 24 25 showCardCommand, 26 selectedCard = null, 27 isMobile = false, 28 isCoarse = false, 29 ondeselect, 30 ondelete, 31 onsetsize 32 }: { 33 data: WebsiteData; 34 linkValue: string; 35 newCard: (type: string) => void; 36 addLink: (url: string) => void; 37 38 showingMobileView: boolean; 39 40 isSaving: boolean; 41 hasUnsavedChanges: boolean; 42 43 save: () => Promise<void>; 44 45 handleImageInputChange: (evt: Event) => void; 46 handleVideoInputChange: (evt: Event) => void; 47 48 showCardCommand: () => void; 49 selectedCard?: Item | null; 50 isMobile?: boolean; 51 isCoarse?: boolean; 52 ondeselect?: () => void; 53 ondelete?: () => void; 54 onsetsize?: (w: number, h: number) => void; 55 } = $props(); 56 57 let linkPopoverOpen = $state(false); 58 59 let imageInputRef: HTMLInputElement | undefined = $state(); 60 let videoInputRef: HTMLInputElement | undefined = $state(); 61 62 function getShareUrl() { 63 const base = typeof window !== 'undefined' ? window.location.origin : ''; 64 const pagePath = 65 data.page && data.page !== 'blento.self' ? `/${data.page.replace('blento.', '')}` : ''; 66 return `${base}/${data.handle}${pagePath}`; 67 } 68 69 async function copyShareLink() { 70 const url = getShareUrl(); 71 await navigator.clipboard.writeText(url); 72 toast.success('Link copied to clipboard!'); 73 } 74 75 let colorsChoices = [ 76 { class: 'text-base-500', label: 'base' }, 77 { class: 'text-accent-500', label: 'accent' }, 78 { class: 'text-base-300 dark:text-base-700', label: 'transparent' }, 79 { class: 'text-red-500', label: 'red' }, 80 { class: 'text-orange-500', label: 'orange' }, 81 { class: 'text-amber-500', label: 'amber' }, 82 { class: 'text-yellow-500', label: 'yellow' }, 83 { class: 'text-lime-500', label: 'lime' }, 84 { class: 'text-green-500', label: 'green' }, 85 { class: 'text-emerald-500', label: 'emerald' }, 86 { class: 'text-teal-500', label: 'teal' }, 87 { class: 'text-cyan-500', label: 'cyan' }, 88 { class: 'text-sky-500', label: 'sky' }, 89 { class: 'text-blue-500', label: 'blue' }, 90 { class: 'text-indigo-500', label: 'indigo' }, 91 { class: 'text-violet-500', label: 'violet' }, 92 { class: 'text-purple-500', label: 'purple' }, 93 { class: 'text-fuchsia-500', label: 'fuchsia' }, 94 { class: 'text-pink-500', label: 'pink' }, 95 { class: 'text-rose-500', label: 'rose' } 96 ]; 97 98 let selectedColor = $derived( 99 selectedCard 100 ? colorsChoices.find((c) => (selectedCard!.color ?? 'base') === c.label) 101 : undefined 102 ); 103 104 let cardDef = $derived( 105 selectedCard ? (CardDefinitionsByType[selectedCard.cardType] ?? null) : null 106 ); 107 108 let colorPopoverOpen = $state(false); 109 let sizePopoverOpen = $state(false); 110 let settingsPopoverOpen = $state(false); 111 112 const minW = $derived(cardDef?.minW ?? 2); 113 const minH = $derived(cardDef?.minH ?? 2); 114 const maxW = $derived(cardDef?.maxW ?? COLUMNS); 115 const maxH = $derived(cardDef?.maxH ?? (isMobile ? 12 : 6)); 116 117 function canSetSize(w: number, h: number) { 118 if (!cardDef) return false; 119 if (isMobile) { 120 return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH; 121 } 122 return w >= minW && w <= maxW && h >= minH && h <= maxH; 123 } 124 125 const showMobileEditControls = $derived(isCoarse && selectedCard); 126</script> 127 128<input 129 type="file" 130 accept="image/*" 131 onchange={handleImageInputChange} 132 class="hidden" 133 id="image-input" 134 multiple 135 bind:this={imageInputRef} 136/> 137 138<input 139 type="file" 140 accept="video/*" 141 onchange={handleVideoInputChange} 142 class="hidden" 143 multiple 144 bind:this={videoInputRef} 145/> 146 147{#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 148 <Navbar 149 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" 150 > 151 {#if showMobileEditControls} 152 <!-- Mobile edit controls: left = color, size, settings; right = delete, deselect --> 153 <div class="flex items-center gap-1"> 154 {#if cardDef?.allowSetColor !== false} 155 <Popover bind:open={colorPopoverOpen}> 156 {#snippet child({ props })} 157 <button 158 {...props} 159 class={[ 160 'cursor-pointer rounded-xl p-2', 161 !selectedCard?.color || 162 selectedCard.color === 'base' || 163 selectedCard.color === 'transparent' 164 ? 'text-base-800 dark:text-base-200' 165 : 'text-accent-500' 166 ]} 167 > 168 <svg 169 xmlns="http://www.w3.org/2000/svg" 170 viewBox="0 0 24 24" 171 fill="currentColor" 172 class="size-5" 173 > 174 <path 175 fill-rule="evenodd" 176 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" 177 clip-rule="evenodd" 178 /> 179 </svg> 180 </button> 181 {/snippet} 182 <ColorSelect 183 selected={selectedColor} 184 colors={colorsChoices} 185 onselected={(color, previous) => { 186 if (typeof previous === 'string' || typeof color === 'string') { 187 return; 188 } 189 if (selectedCard) { 190 selectedCard.color = color.label; 191 } 192 }} 193 class="w-64" 194 /> 195 </Popover> 196 {/if} 197 198 <Popover bind:open={sizePopoverOpen}> 199 {#snippet child({ props })} 200 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 201 <svg 202 xmlns="http://www.w3.org/2000/svg" 203 fill="none" 204 viewBox="0 0 24 24" 205 stroke-width="1.5" 206 stroke="currentColor" 207 class="size-5" 208 > 209 <path 210 stroke-linecap="round" 211 stroke-linejoin="round" 212 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" 213 /> 214 </svg> 215 </button> 216 {/snippet} 217 <div class="flex items-center gap-1"> 218 {#if canSetSize(2, 2)} 219 <button 220 onclick={() => onsetsize?.(4, 4)} 221 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 222 > 223 <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 224 <span class="sr-only">set size to 1x1</span> 225 </button> 226 {/if} 227 {#if canSetSize(4, 2)} 228 <button 229 onclick={() => onsetsize?.(8, 4)} 230 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 231 > 232 <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 233 <span class="sr-only">set size to 2x1</span> 234 </button> 235 {/if} 236 {#if canSetSize(2, 4)} 237 <button 238 onclick={() => onsetsize?.(4, 8)} 239 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 240 > 241 <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 242 <span class="sr-only">set size to 1x2</span> 243 </button> 244 {/if} 245 {#if canSetSize(4, 4)} 246 <button 247 onclick={() => onsetsize?.(8, 8)} 248 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 249 > 250 <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 251 <span class="sr-only">set size to 2x2</span> 252 </button> 253 {/if} 254 </div> 255 </Popover> 256 257 {#if cardDef?.settingsComponent && selectedCard} 258 <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 259 {#snippet child({ props })} 260 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 261 <svg 262 xmlns="http://www.w3.org/2000/svg" 263 fill="none" 264 viewBox="0 0 24 24" 265 stroke-width="2" 266 stroke="currentColor" 267 class="size-5" 268 > 269 <path 270 stroke-linecap="round" 271 stroke-linejoin="round" 272 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" 273 /> 274 <path 275 stroke-linecap="round" 276 stroke-linejoin="round" 277 d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 278 /> 279 </svg> 280 </button> 281 {/snippet} 282 <cardDef.settingsComponent 283 bind:item={selectedCard} 284 onclose={() => { 285 settingsPopoverOpen = false; 286 }} 287 /> 288 </Popover> 289 {/if} 290 </div> 291 <div class="flex items-center gap-1"> 292 <Button 293 size="iconLg" 294 variant="ghost" 295 class="text-rose-500 backdrop-blur-none" 296 onclick={() => ondelete?.()} 297 > 298 <svg 299 xmlns="http://www.w3.org/2000/svg" 300 fill="none" 301 viewBox="0 0 24 24" 302 stroke-width="1.5" 303 stroke="currentColor" 304 class="size-5" 305 > 306 <path 307 stroke-linecap="round" 308 stroke-linejoin="round" 309 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" 310 /> 311 </svg> 312 </Button> 313 <Button 314 size="iconLg" 315 variant="ghost" 316 class="backdrop-blur-none" 317 onclick={() => ondeselect?.()} 318 > 319 <svg 320 xmlns="http://www.w3.org/2000/svg" 321 fill="none" 322 viewBox="0 0 24 24" 323 stroke-width="2" 324 stroke="currentColor" 325 class="size-5" 326 > 327 <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 328 </svg> 329 </Button> 330 </div> 331 {:else} 332 <!-- Normal add-card controls --> 333 <div class="flex items-center gap-2"> 334 <Button 335 size="iconLg" 336 variant="ghost" 337 class="backdrop-blur-none" 338 onclick={() => { 339 newCard('section'); 340 }} 341 > 342 <svg 343 xmlns="http://www.w3.org/2000/svg" 344 viewBox="0 0 24 24" 345 fill="none" 346 stroke="currentColor" 347 stroke-width="2" 348 stroke-linecap="round" 349 stroke-linejoin="round" 350 ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 351 > 352 </Button> 353 354 <Button 355 size="iconLg" 356 variant="ghost" 357 class="backdrop-blur-none" 358 onclick={() => { 359 newCard('text'); 360 }} 361 > 362 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 363 ><path 364 fill="none" 365 stroke="currentColor" 366 stroke-linecap="round" 367 stroke-linejoin="round" 368 stroke-width="2" 369 d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 370 /></svg 371 > 372 </Button> 373 374 <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 375 {#snippet child({ props })} 376 <Button 377 size="iconLg" 378 variant="ghost" 379 class="backdrop-blur-none" 380 onclick={() => { 381 newCard('link'); 382 }} 383 {...props} 384 > 385 <svg 386 xmlns="http://www.w3.org/2000/svg" 387 fill="none" 388 viewBox="-2 -2 28 28" 389 stroke-width="2" 390 stroke="currentColor" 391 > 392 <path 393 stroke-linecap="round" 394 stroke-linejoin="round" 395 d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 396 /> 397 </svg> 398 </Button> 399 {/snippet} 400 <Input 401 spellcheck={false} 402 type="url" 403 bind:value={linkValue} 404 onkeydown={(event) => { 405 if (event.code === 'Enter') { 406 addLink(linkValue); 407 event.preventDefault(); 408 } 409 }} 410 placeholder="Enter link" 411 /> 412 <Button onclick={() => addLink(linkValue)} size="icon" 413 ><svg 414 xmlns="http://www.w3.org/2000/svg" 415 fill="none" 416 viewBox="0 0 24 24" 417 stroke-width="2" 418 stroke="currentColor" 419 class="size-6" 420 > 421 <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 422 </svg> 423 </Button> 424 </Popover> 425 426 <Button 427 size="iconLg" 428 variant="ghost" 429 class="backdrop-blur-none" 430 onclick={() => { 431 imageInputRef?.click(); 432 }} 433 > 434 <svg 435 xmlns="http://www.w3.org/2000/svg" 436 fill="none" 437 viewBox="0 0 24 24" 438 stroke-width="2" 439 stroke="currentColor" 440 > 441 <path 442 stroke-linecap="round" 443 stroke-linejoin="round" 444 d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 445 /> 446 </svg> 447 </Button> 448 449 <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 450 <svg 451 xmlns="http://www.w3.org/2000/svg" 452 fill="none" 453 viewBox="0 0 24 24" 454 stroke-width="1.5" 455 stroke="currentColor" 456 > 457 <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 458 </svg> 459 </Button> 460 </div> 461 {/if} 462 <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 463 <Toggle 464 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 465 bind:pressed={showingMobileView} 466 > 467 <svg 468 xmlns="http://www.w3.org/2000/svg" 469 fill="none" 470 viewBox="0 0 24 24" 471 stroke-width="1.5" 472 stroke="currentColor" 473 class="size-6" 474 > 475 <path 476 stroke-linecap="round" 477 stroke-linejoin="round" 478 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" 479 /> 480 </svg> 481 </Toggle> 482 {#if hasUnsavedChanges} 483 <Button 484 disabled={isSaving} 485 onclick={async () => { 486 save(); 487 }}>{isSaving ? 'Saving...' : 'Save'}</Button 488 > 489 {:else} 490 <Button onclick={copyShareLink}> 491 <svg 492 xmlns="http://www.w3.org/2000/svg" 493 fill="none" 494 viewBox="0 0 24 24" 495 stroke-width="1.5" 496 stroke="currentColor" 497 class="size-5" 498 > 499 <path 500 stroke-linecap="round" 501 stroke-linejoin="round" 502 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" 503 /> 504 </svg> 505 Share 506 </Button> 507 {/if} 508 </div> 509 </Navbar> 510{/if}