your personal website on atproto - mirror blento.app
at remove-extra-buttons 474 lines 16 kB view raw
1<script lang="ts"> 2 import type { WithElementRef } from 'bits-ui'; 3 import type { HTMLAttributes } from 'svelte/elements'; 4 import BaseCard from './BaseCard.svelte'; 5 import type { Item } from '$lib/types'; 6 import { Button, cn, Label, Popover } from '@foxui/core'; 7 import { ColorSelect } from '@foxui/colors'; 8 import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..'; 9 import { COLUMNS } from '$lib'; 10 import { 11 getCanEdit, 12 getIsCoarse, 13 getIsMobile, 14 getSelectedCardId, 15 getSelectCard 16 } from '$lib/website/context'; 17 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 18 import { fixAllCollisions, fixCollisions } from '$lib/helper'; 19 20 let colorsChoices = [ 21 { class: 'text-base-500', label: 'base' }, 22 { class: 'text-accent-500', label: 'accent' }, 23 { class: 'text-base-300 dark:text-base-700', label: 'transparent' }, 24 { class: 'text-red-500', label: 'red' }, 25 { class: 'text-orange-500', label: 'orange' }, 26 { class: 'text-amber-500', label: 'amber' }, 27 { class: 'text-yellow-500', label: 'yellow' }, 28 { class: 'text-lime-500', label: 'lime' }, 29 { class: 'text-green-500', label: 'green' }, 30 { class: 'text-emerald-500', label: 'emerald' }, 31 { class: 'text-teal-500', label: 'teal' }, 32 { class: 'text-cyan-500', label: 'cyan' }, 33 { class: 'text-sky-500', label: 'sky' }, 34 { class: 'text-blue-500', label: 'blue' }, 35 { class: 'text-indigo-500', label: 'indigo' }, 36 { class: 'text-violet-500', label: 'violet' }, 37 { class: 'text-purple-500', label: 'purple' }, 38 { class: 'text-fuchsia-500', label: 'fuchsia' }, 39 { class: 'text-pink-500', label: 'pink' }, 40 { class: 'text-rose-500', label: 'rose' } 41 ]; 42 43 export type BaseEditingCardProps = { 44 item: Item; 45 ondelete: () => void; 46 onsetsize: (newW: number, newH: number) => void; 47 } & WithElementRef<HTMLAttributes<HTMLDivElement>>; 48 49 let { 50 item = $bindable(), 51 children, 52 ref = $bindable(null), 53 onsetsize, 54 ondelete, 55 ...rest 56 }: BaseEditingCardProps = $props(); 57 58 let selectedColor = $derived(colorsChoices.find((c) => getColor(item) === c.label)); 59 60 let canEdit = getCanEdit(); 61 let isMobile = getIsMobile(); 62 let isCoarse = getIsCoarse(); 63 64 let selectedCardId = getSelectedCardId(); 65 let selectCard = getSelectCard(); 66 let isSelected = $derived(selectedCardId?.() === item.id); 67 let isDimmed = $derived(isCoarse?.() && selectedCardId?.() != null && !isSelected); 68 69 let colorPopoverOpen = $state(false); 70 71 const cardDef = $derived(CardDefinitionsByType[item.cardType] ?? {}); 72 73 const minW = $derived(cardDef.minW ?? (isMobile() ? 2 : 2)); 74 const minH = $derived(cardDef.minH ?? (isMobile() ? 2 : 2)); 75 76 const maxW = $derived(cardDef.maxW ?? COLUMNS); 77 const maxH = $derived(cardDef.maxH ?? (isMobile() ? 12 : 6)); 78 79 // Resize handle state 80 let isResizing = $state(false); 81 let resizeStartX = $state(0); 82 let resizeStartY = $state(0); 83 let resizeStartW = $state(0); 84 let resizeStartH = $state(0); 85 86 function handleResizeStart(e: PointerEvent) { 87 e.preventDefault(); 88 e.stopPropagation(); 89 isResizing = true; 90 resizeStartX = e.clientX; 91 resizeStartY = e.clientY; 92 // For mobile view, sizes are doubled so we need to account for that 93 resizeStartW = isMobile() ? (item.mobileW ?? item.w) : item.w; 94 resizeStartH = isMobile() ? (item.mobileH ?? item.h) : item.h; 95 96 document.addEventListener('pointermove', handleResizeMove); 97 document.addEventListener('pointerup', handleResizeEnd); 98 } 99 100 function handleResizeMove(e: PointerEvent) { 101 if (!isResizing || !ref) return; 102 103 // Get the container width to calculate cell size 104 const container = ref.closest('.\\@container\\/grid') as HTMLElement; 105 if (!container) return; 106 107 const containerRect = container.getBoundingClientRect(); 108 const cellSize = containerRect.width / COLUMNS; 109 110 // Calculate delta in grid units (each visual unit is 2 grid units) 111 const deltaX = e.clientX - resizeStartX; 112 const deltaY = e.clientY - resizeStartY; 113 114 // Convert pixel delta to grid units (2 grid units = 1 visual cell) 115 const gridDeltaW = Math.round(deltaX / cellSize); 116 const gridDeltaH = Math.round(deltaY / cellSize); 117 118 let newW = resizeStartW + gridDeltaW; 119 let newH = resizeStartH + gridDeltaH; 120 121 if (isMobile()) { 122 newW = Math.round(newW / 4) * 4; 123 } else { 124 newW = Math.round(newW / 2) * 2; 125 } 126 let mult = isMobile() ? 2 : 1; 127 128 // Clamp to min/max 129 newW = Math.max(minW * mult, Math.min(maxW, newW)); 130 newH = Math.max(minH * mult, Math.min(maxH, newH)); 131 132 // Only call onsetsize if size changed 133 const currentW = isMobile() ? (item.mobileW ?? item.w) : item.w; 134 const currentH = isMobile() ? (item.mobileH ?? item.h) : item.h; 135 136 if (newW !== currentW || newH !== currentH) { 137 onsetsize?.(newW, newH); 138 } 139 } 140 141 function handleResizeEnd() { 142 isResizing = false; 143 document.removeEventListener('pointermove', handleResizeMove); 144 document.removeEventListener('pointerup', handleResizeEnd); 145 } 146 147 function canSetSize(w: number, h: number) { 148 if (!cardDef) return false; 149 150 if (isMobile()) { 151 return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH; 152 } 153 154 return w >= minW && w <= maxW && h >= minH && h <= maxH; 155 } 156 157 function setSize(w: number, h: number) { 158 if (isMobile()) { 159 w *= 2; 160 h *= 2; 161 } 162 onsetsize?.(w, h); 163 } 164 165 let settingsPopoverOpen = $state(false); 166 let changePopoverOpen = $state(false); 167 168 const changeOptions = $derived(AllCardDefinitions.filter((def) => def.canChange?.(item))); 169 170 function applyChange(def: (typeof AllCardDefinitions)[number]) { 171 const updated = def.change ? def.change(item) : item; 172 if (updated && updated !== item) { 173 item = updated; 174 } 175 item.cardType = def.type; 176 changePopoverOpen = false; 177 } 178 179 function getChangeLabel(def: (typeof AllCardDefinitions)[number]) { 180 return def.name; 181 } 182</script> 183 184<BaseCard 185 {item} 186 isEditing={true} 187 bind:ref 188 showOutline={isResizing || (isCoarse?.() && isSelected)} 189 locked={item.cardData?.locked} 190 class={[ 191 'scale-100 starting:scale-0 starting:opacity-0', 192 isCoarse?.() && isSelected ? 'ring-accent-500 z-10 ring-2 ring-offset-2' : '', 193 isDimmed ? 'opacity-70' : 'opacity-100' 194 ]} 195 {...rest} 196> 197 {#if isCoarse?.() ? !isSelected : !item.cardData?.locked} 198 <!-- svelte-ignore a11y_click_events_have_key_events --> 199 <div 200 role="button" 201 tabindex="-1" 202 class={['absolute inset-0', isCoarse?.() ? 'z-20 cursor-pointer' : 'cursor-grab']} 203 onclick={(e) => { 204 if (isCoarse?.()) { 205 e.stopPropagation(); 206 selectCard?.(item.id); 207 } 208 }} 209 ></div> 210 {/if} 211 {@render children?.()} 212 213 {#if cardDef.canHaveLabel} 214 <div 215 class={cn( 216 'bg-base-200/50 dark:bg-base-900/50 absolute top-2 left-2 z-100 w-fit max-w-[calc(100%-1rem)] rounded-xl p-1 px-2 backdrop-blur-md', 217 !item.cardData.label && 'hidden lg:group-hover/card:block' 218 )} 219 > 220 <PlainTextEditor 221 class="text-base-900 dark:text-base-50 w-fit text-base font-semibold" 222 key="label" 223 bind:contentDict={item.cardData} 224 placeholder="Label" 225 /> 226 </div> 227 {/if} 228 229 {#snippet controls()} 230 <!-- class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 absolute -top-3 -left-3 hidden cursor-pointer items-center justify-center rounded-full border p-2 shadow-lg group-focus-within:inline-flex group-hover/card:inline-flex" --> 231 {#if canEdit()} 232 {#if changeOptions.length > 1} 233 <div 234 class={[ 235 'absolute -top-3 -right-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex', 236 changePopoverOpen ? 'inline-flex' : '' 237 ]} 238 > 239 <Popover bind:open={changePopoverOpen} class="bg-base-50 dark:bg-base-900"> 240 {#snippet child({ props })} 241 <Button size="icon" variant="secondary" {...props}> 242 <svg 243 xmlns="http://www.w3.org/2000/svg" 244 fill="none" 245 viewBox="0 0 24 24" 246 stroke-width="1.5" 247 stroke="currentColor" 248 class="size-6" 249 > 250 <path 251 stroke-linecap="round" 252 stroke-linejoin="round" 253 d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" 254 /> 255 </svg> 256 257 <span class="sr-only">Change card type</span> 258 </Button> 259 {/snippet} 260 261 <div class="flex min-w-36 flex-col gap-1"> 262 <Label class="mb-2">Card type</Label> 263 {#each changeOptions as changeDef, i (i)} 264 <Button 265 class="justify-start" 266 variant={changeDef.type === item.cardType ? 'primary' : 'ghost'} 267 onclick={() => applyChange(changeDef)} 268 > 269 {getChangeLabel(changeDef)} 270 </Button> 271 {/each} 272 </div> 273 </Popover> 274 </div> 275 {/if} 276 277 <Button 278 size="icon" 279 variant="rose" 280 onclick={() => { 281 ondelete(); 282 }} 283 class="absolute -top-3 -left-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex" 284 > 285 <svg 286 xmlns="http://www.w3.org/2000/svg" 287 fill="none" 288 viewBox="0 0 24 24" 289 stroke-width="1.5" 290 stroke="currentColor" 291 > 292 <path 293 stroke-linecap="round" 294 stroke-linejoin="round" 295 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" 296 /> 297 </svg> 298 299 <span class="sr-only">Delete card</span> 300 </Button> 301 302 <div 303 class={[ 304 'absolute -bottom-7 w-full items-center justify-center text-xs lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex', 305 colorPopoverOpen || settingsPopoverOpen ? 'inline-flex' : 'hidden' 306 ]} 307 > 308 <div 309 class="bg-base-100 border-base-200 dark:bg-base-800 dark:border-base-700 z-100 inline-flex items-center gap-0.5 rounded-2xl border p-1 px-2 shadow-lg" 310 > 311 {#if cardDef.allowSetColor !== false} 312 <Popover bind:open={colorPopoverOpen}> 313 {#snippet child({ props })} 314 <button 315 {...props} 316 class={[ 317 'm-2 size-4 cursor-pointer rounded-full', 318 !item.color || item.color === 'base' || item.color === 'transparent' 319 ? 'text-base-800 dark:text-base-200' 320 : 'text-accent-500' 321 ]} 322 > 323 <svg 324 xmlns="http://www.w3.org/2000/svg" 325 viewBox="0 0 24 24" 326 fill="currentColor" 327 class="size-4" 328 > 329 <path 330 fill-rule="evenodd" 331 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" 332 clip-rule="evenodd" 333 /> 334 </svg> 335 </button> 336 {/snippet} 337 <ColorSelect 338 selected={selectedColor} 339 colors={colorsChoices} 340 onselected={(color, previous) => { 341 if (typeof previous === 'string' || typeof color === 'string') { 342 return; 343 } 344 345 item.color = color.label; 346 }} 347 class="w-64" 348 /> 349 </Popover> 350 {/if} 351 352 {#if canSetSize(2, 2)} 353 <button 354 onclick={() => { 355 setSize(2, 2); 356 }} 357 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 358 > 359 <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 360 361 <span class="sr-only">set size to 1x1</span> 362 </button> 363 {/if} 364 365 {#if canSetSize(4, 2)} 366 <button 367 onclick={() => { 368 setSize(4, 2); 369 }} 370 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 371 > 372 <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 373 <span class="sr-only">set size to 2x1</span> 374 </button> 375 {/if} 376 {#if canSetSize(2, 4)} 377 <button 378 onclick={() => { 379 setSize(2, 4); 380 }} 381 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 382 > 383 <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 384 385 <span class="sr-only">set size to 1x2</span> 386 </button> 387 {/if} 388 {#if canSetSize(4, 4)} 389 <button 390 onclick={() => { 391 setSize(4, 4); 392 }} 393 class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 394 > 395 <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 396 397 <span class="sr-only">set size to 2x2</span> 398 </button> 399 {/if} 400 401 {#if cardDef.settingsComponent} 402 <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 403 {#snippet child({ props })} 404 <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 405 <svg 406 xmlns="http://www.w3.org/2000/svg" 407 fill="none" 408 viewBox="0 0 24 24" 409 stroke-width="2" 410 stroke="currentColor" 411 class="size-5" 412 > 413 <path 414 stroke-linecap="round" 415 stroke-linejoin="round" 416 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" 417 /> 418 <path 419 stroke-linecap="round" 420 stroke-linejoin="round" 421 d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 422 /> 423 </svg> 424 </button> 425 {/snippet} 426 <cardDef.settingsComponent 427 bind:item 428 onclose={() => { 429 settingsPopoverOpen = false; 430 }} 431 /> 432 </Popover> 433 {/if} 434 </div> 435 </div> 436 437 {#if cardDef.canResize !== false} 438 <!-- Resize handle at bottom right corner --> 439 <div 440 onpointerdown={handleResizeStart} 441 class="bg-base-300/70 dark:bg-base-900/70 pointer-events-auto absolute right-0.5 bottom-0.5 hidden cursor-se-resize rounded-md rounded-br-3xl p-1 lg:group-hover/card:block" 442 > 443 <svg 444 xmlns="http://www.w3.org/2000/svg" 445 viewBox="0 0 24 24" 446 fill="none" 447 stroke="currentColor" 448 stroke-width="2" 449 stroke-linecap="round" 450 stroke-linejoin="round" 451 class=" dark:text-base-400 text-base-600 size-4" 452 > 453 <circle cx="12" cy="5" r="1" /><circle cx="19" cy="5" r="1" /><circle 454 cx="5" 455 cy="5" 456 r="1" 457 /> 458 <circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle 459 cx="5" 460 cy="12" 461 r="1" 462 /> 463 <circle cx="12" cy="19" r="1" /><circle cx="19" cy="19" r="1" /><circle 464 cx="5" 465 cy="19" 466 r="1" 467 /> 468 </svg> 469 <span class="sr-only">Resize card</span> 470 </div> 471 {/if} 472 {/if} 473 {/snippet} 474</BaseCard>