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