your personal website on atproto - mirror blento.app

Merge branch 'mobile-editing' into card-command-bar

authored by Florian and committed by GitHub f79afab5 5f504652

+619 -122
+11 -1
src/lib/cards/BaseCard/BaseCard.svelte
··· 5 5 import type { Snippet } from 'svelte'; 6 6 import type { HTMLAttributes } from 'svelte/elements'; 7 7 import { getColor } from '..'; 8 + import { getIsCoarse } from '$lib/website/context'; 9 + 10 + function tryGetIsCoarse(): (() => boolean) | undefined { 11 + try { 12 + return getIsCoarse(); 13 + } catch { 14 + return undefined; 15 + } 16 + } 17 + const isCoarse = tryGetIsCoarse(); 8 18 9 19 const colors = { 10 20 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 39 49 id={item.id} 40 50 data-flip-id={item.id} 41 51 bind:this={ref} 42 - draggable={isEditing && !locked} 52 + draggable={isEditing && !locked && !isCoarse?.()} 43 53 class={[ 44 54 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2', 45 55 color ? (colors[color] ?? colors.accent) : colors.base,
+36 -9
src/lib/cards/BaseCard/BaseEditingCard.svelte
··· 7 7 import { ColorSelect } from '@foxui/colors'; 8 8 import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..'; 9 9 import { COLUMNS } from '$lib'; 10 - import { getCanEdit, getIsMobile } from '$lib/website/context'; 10 + import { 11 + getCanEdit, 12 + getIsCoarse, 13 + getIsMobile, 14 + getSelectedCardId, 15 + getSelectCard 16 + } from '$lib/website/context'; 11 17 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 12 18 import { fixAllCollisions, fixCollisions } from '$lib/helper'; 13 19 ··· 53 59 54 60 let canEdit = getCanEdit(); 55 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); 56 68 57 69 let colorPopoverOpen = $state(false); 58 70 ··· 173 185 {item} 174 186 isEditing={true} 175 187 bind:ref 176 - showOutline={isResizing} 188 + showOutline={isResizing || (isCoarse?.() && isSelected)} 177 189 locked={item.cardData?.locked} 178 - class="scale-100 opacity-100 starting:scale-0 starting:opacity-0" 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 + ]} 179 195 {...rest} 180 196 > 181 197 {#if !item.cardData?.locked} 182 - <div class="absolute inset-0 cursor-grab"></div> 198 + <!-- svelte-ignore a11y_click_events_have_key_events --> 199 + <div 200 + role="button" 201 + tabindex="-1" 202 + class={['absolute inset-0', isCoarse?.() ? 'cursor-pointer' : 'cursor-grab']} 203 + onclick={(e) => { 204 + if (isCoarse?.()) { 205 + e.stopPropagation(); 206 + selectCard?.(item.id); 207 + } 208 + }} 209 + ></div> 183 210 {/if} 184 211 {@render children?.()} 185 212 ··· 187 214 <div 188 215 class={cn( 189 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', 190 - !item.cardData.label && 'hidden group-hover/card:block' 217 + !item.cardData.label && 'hidden lg:group-hover/card:block' 191 218 )} 192 219 > 193 220 <PlainTextEditor ··· 205 232 {#if changeOptions.length > 1} 206 233 <div 207 234 class={[ 208 - 'absolute -top-3 -right-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex', 235 + 'absolute -top-3 -right-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex', 209 236 changePopoverOpen ? 'inline-flex' : '' 210 237 ]} 211 238 > ··· 253 280 onclick={() => { 254 281 ondelete(); 255 282 }} 256 - class="absolute -top-3 -left-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex" 283 + class="absolute -top-3 -left-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex" 257 284 > 258 285 <svg 259 286 xmlns="http://www.w3.org/2000/svg" ··· 274 301 275 302 <div 276 303 class={[ 277 - 'absolute -bottom-7 w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover/card:inline-flex', 304 + 'absolute -bottom-7 w-full items-center justify-center text-xs lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex', 278 305 colorPopoverOpen || settingsPopoverOpen ? 'inline-flex' : 'hidden' 279 306 ]} 280 307 > ··· 411 438 <!-- Resize handle at bottom right corner --> 412 439 <div 413 440 onpointerdown={handleResizeStart} 414 - 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" 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" 415 442 > 416 443 <svg 417 444 xmlns="http://www.w3.org/2000/svg"
+371 -104
src/lib/website/EditBar.svelte
··· 1 1 <script lang="ts"> 2 2 import { dev } from '$app/environment'; 3 3 import { user } from '$lib/atproto'; 4 - import type { WebsiteData } from '$lib/types'; 4 + import { COLUMNS } from '$lib'; 5 + import type { Item, WebsiteData } from '$lib/types'; 6 + import { CardDefinitionsByType } from '$lib/cards'; 5 7 import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core'; 8 + import { ColorSelect } from '@foxui/colors'; 6 9 7 10 let { 8 11 data, ··· 20 23 handleVideoInputChange, 21 24 22 25 showCardCommand 26 + selectedCard = null, 27 + isMobile = false, 28 + isCoarse = false, 29 + ondeselect, 30 + ondelete, 31 + onsetsize 23 32 }: { 24 33 data: WebsiteData; 25 34 linkValue: string; ··· 37 46 handleVideoInputChange: (evt: Event) => void; 38 47 39 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; 40 55 } = $props(); 41 56 42 57 let linkPopoverOpen = $state(false); ··· 56 71 await navigator.clipboard.writeText(url); 57 72 toast.success('Link copied to clipboard!'); 58 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); 59 126 </script> 60 127 61 128 <input ··· 79 146 80 147 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 81 148 <Navbar 82 - class={[ 83 - '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 lg:inline-flex', 84 - !dev ? 'hidden' : '' 85 - ]} 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" 86 150 > 87 - <div class="flex items-center gap-2"> 88 - <Button 89 - size="iconLg" 90 - variant="ghost" 91 - class="backdrop-blur-none" 92 - onclick={() => { 93 - newCard('section'); 94 - }} 95 - > 96 - <svg 97 - xmlns="http://www.w3.org/2000/svg" 98 - viewBox="0 0 24 24" 99 - fill="none" 100 - stroke="currentColor" 101 - stroke-width="2" 102 - stroke-linecap="round" 103 - stroke-linejoin="round" 104 - ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 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?.()} 105 318 > 106 - </Button> 107 - 108 - <Button 109 - size="iconLg" 110 - variant="ghost" 111 - class="backdrop-blur-none" 112 - onclick={() => { 113 - newCard('text'); 114 - }} 115 - > 116 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 117 - ><path 319 + <svg 320 + xmlns="http://www.w3.org/2000/svg" 118 321 fill="none" 322 + viewBox="0 0 24 24" 323 + stroke-width="2" 119 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" 120 348 stroke-linecap="round" 121 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" 122 438 stroke-width="2" 123 - 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" 124 - /></svg 125 - > 126 - </Button> 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> 127 448 128 - <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 129 - {#snippet child({ props })} 449 + {#if dev} 130 450 <Button 131 451 size="iconLg" 132 452 variant="ghost" 133 453 class="backdrop-blur-none" 134 454 onclick={() => { 135 - newCard('link'); 455 + videoInputRef?.click(); 136 456 }} 137 - {...props} 138 457 > 139 458 <svg 140 459 xmlns="http://www.w3.org/2000/svg" 141 460 fill="none" 142 - viewBox="-2 -2 28 28" 143 - stroke-width="2" 461 + viewBox="0 0 24 24" 462 + stroke-width="1.5" 144 463 stroke="currentColor" 145 464 > 146 465 <path 147 466 stroke-linecap="round" 148 467 stroke-linejoin="round" 149 - 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" 468 + d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" 150 469 /> 151 470 </svg> 152 471 </Button> 153 - {/snippet} 154 - <Input 155 - spellcheck={false} 156 - type="url" 157 - bind:value={linkValue} 158 - onkeydown={(event) => { 159 - if (event.code === 'Enter') { 160 - addLink(linkValue); 161 - event.preventDefault(); 162 - } 163 - }} 164 - placeholder="Enter link" 165 - /> 166 - <Button onclick={() => addLink(linkValue)} size="icon" 167 - ><svg 168 - xmlns="http://www.w3.org/2000/svg" 169 - fill="none" 170 - viewBox="0 0 24 24" 171 - stroke-width="2" 172 - stroke="currentColor" 173 - class="size-6" 174 - > 175 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 176 - </svg> 177 - </Button> 178 - </Popover> 472 + {/if} 179 473 180 - <Button 181 - size="iconLg" 182 - variant="ghost" 183 - class="backdrop-blur-none" 184 - onclick={() => { 185 - imageInputRef?.click(); 186 - }} 187 - > 188 - <svg 189 - xmlns="http://www.w3.org/2000/svg" 190 - fill="none" 191 - viewBox="0 0 24 24" 192 - stroke-width="2" 193 - stroke="currentColor" 194 - > 195 - <path 196 - stroke-linecap="round" 197 - stroke-linejoin="round" 198 - 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" 199 - /> 200 - </svg> 201 - </Button> 202 - 203 - {#if dev} 204 474 <Button 205 475 size="iconLg" 206 476 variant="ghost" 207 477 class="backdrop-blur-none" 208 - onclick={() => { 209 - videoInputRef?.click(); 210 - }} 478 + popovertarget="mobile-menu" 211 479 > 212 480 <svg 213 481 xmlns="http://www.w3.org/2000/svg" ··· 216 484 stroke-width="1.5" 217 485 stroke="currentColor" 218 486 > 219 - <path 220 - stroke-linecap="round" 221 - stroke-linejoin="round" 222 - d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" 223 - /> 487 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 224 488 </svg> 225 489 </Button> 226 490 {/if} ··· 238 502 </Button> 239 503 </div> 240 504 <div class="flex items-center gap-2"> 505 + </div> 506 + {/if} 507 + <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 241 508 <Toggle 242 509 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 243 510 bind:pressed={showingMobileView}
+198 -8
src/lib/website/EditableWebsite.svelte
··· 26 26 import { tick, type Component } from 'svelte'; 27 27 import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 28 28 import { dev } from '$app/environment'; 29 - import { setIsMobile } from './context'; 29 + import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; 30 30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 31 31 import Context from './Context.svelte'; 32 32 import Head from './Head.svelte'; ··· 125 125 126 126 setIsMobile(() => isMobile); 127 127 128 + const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; 129 + setIsCoarse(() => isCoarse); 130 + 131 + let selectedCardId: string | null = $state(null); 132 + let selectedCard = $derived( 133 + selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null 134 + ); 135 + 136 + setSelectedCardId(() => selectedCardId); 137 + setSelectCard((id: string | null) => { 138 + selectedCardId = id; 139 + }); 140 + 128 141 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 129 142 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 130 143 ··· 141 154 } 142 155 143 156 function newCard(type: string = 'link', cardData?: any) { 157 + selectedCardId = null; 158 + 144 159 // close sidebar if open 145 160 const popover = document.getElementById('mobile-menu'); 146 161 if (popover) { ··· 230 245 231 246 let debugPoint = $state({ x: 0, y: 0 }); 232 247 233 - function getDragXY( 234 - e: DragEvent & { 235 - currentTarget: EventTarget & HTMLDivElement; 236 - } 248 + function getGridPosition( 249 + clientX: number, 250 + clientY: number 237 251 ): 238 252 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 239 253 | undefined { 240 254 if (!container || !activeDragElement.item) return; 241 255 242 256 // x, y represent the top-left corner of the dragged card 243 - const x = e.clientX + activeDragElement.mouseDeltaX; 244 - const y = e.clientY + activeDragElement.mouseDeltaY; 257 + const x = clientX + activeDragElement.mouseDeltaX; 258 + const y = clientY + activeDragElement.mouseDeltaY; 245 259 246 260 const rect = container.getBoundingClientRect(); 247 261 const currentMargin = isMobile ? mobileMargin : margin; ··· 363 377 return { x: gridX, y: gridY, swapWithId, placement }; 364 378 } 365 379 380 + function getDragXY( 381 + e: DragEvent & { 382 + currentTarget: EventTarget & HTMLDivElement; 383 + } 384 + ) { 385 + return getGridPosition(e.clientX, e.clientY); 386 + } 387 + 388 + // Touch drag system (instant drag on selected card) 389 + let touchDragActive = $state(false); 390 + 391 + function touchStart(e: TouchEvent) { 392 + if (!selectedCardId || !container) return; 393 + const touch = e.touches[0]; 394 + if (!touch) return; 395 + 396 + // Check if the touch is on the selected card element 397 + const target = (e.target as HTMLElement)?.closest?.('.card'); 398 + if (!target || target.id !== selectedCardId) return; 399 + 400 + const item = items.find((i) => i.id === selectedCardId); 401 + if (!item || item.cardData?.locked) return; 402 + 403 + // Start dragging immediately 404 + touchDragActive = true; 405 + 406 + const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; 407 + if (!cardEl) return; 408 + 409 + activeDragElement.element = cardEl; 410 + activeDragElement.w = item.w; 411 + activeDragElement.h = item.h; 412 + activeDragElement.item = item; 413 + 414 + // Store original positions of all items 415 + activeDragElement.originalPositions = new Map(); 416 + for (const it of items) { 417 + activeDragElement.originalPositions.set(it.id, { 418 + x: it.x, 419 + y: it.y, 420 + mobileX: it.mobileX, 421 + mobileY: it.mobileY 422 + }); 423 + } 424 + 425 + const rect = cardEl.getBoundingClientRect(); 426 + activeDragElement.mouseDeltaX = rect.left - touch.clientX; 427 + activeDragElement.mouseDeltaY = rect.top - touch.clientY; 428 + } 429 + 430 + function touchMove(e: TouchEvent) { 431 + if (!touchDragActive) return; 432 + 433 + const touch = e.touches[0]; 434 + if (!touch) return; 435 + 436 + e.preventDefault(); 437 + 438 + const result = getGridPosition(touch.clientX, touch.clientY); 439 + if (!result || !activeDragElement.item) return; 440 + 441 + const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 442 + 443 + // Reset all items to original positions first 444 + for (const it of items) { 445 + const origPos = activeDragElement.originalPositions.get(it.id); 446 + if (origPos && it !== activeDragElement.item) { 447 + if (isMobile) { 448 + it.mobileX = origPos.mobileX; 449 + it.mobileY = origPos.mobileY; 450 + } else { 451 + it.x = origPos.x; 452 + it.y = origPos.y; 453 + } 454 + } 455 + } 456 + 457 + // Update dragged item position 458 + if (isMobile) { 459 + activeDragElement.item.mobileX = result.x; 460 + activeDragElement.item.mobileY = result.y; 461 + } else { 462 + activeDragElement.item.x = result.x; 463 + activeDragElement.item.y = result.y; 464 + } 465 + 466 + // Handle horizontal swap 467 + if (result.swapWithId && draggedOrigPos) { 468 + const swapTarget = items.find((it) => it.id === result.swapWithId); 469 + if (swapTarget) { 470 + if (isMobile) { 471 + swapTarget.mobileX = draggedOrigPos.mobileX; 472 + swapTarget.mobileY = draggedOrigPos.mobileY; 473 + } else { 474 + swapTarget.x = draggedOrigPos.x; 475 + swapTarget.y = draggedOrigPos.y; 476 + } 477 + } 478 + } 479 + 480 + fixCollisions(items, activeDragElement.item, isMobile); 481 + 482 + // Auto-scroll near edges 483 + const scrollZone = 100; 484 + const scrollSpeed = 10; 485 + const viewportHeight = window.innerHeight; 486 + 487 + if (touch.clientY < scrollZone) { 488 + const intensity = 1 - touch.clientY / scrollZone; 489 + window.scrollBy(0, -scrollSpeed * intensity); 490 + } else if (touch.clientY > viewportHeight - scrollZone) { 491 + const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; 492 + window.scrollBy(0, scrollSpeed * intensity); 493 + } 494 + } 495 + 496 + function touchEnd() { 497 + if (touchDragActive && activeDragElement.item) { 498 + // Finalize position 499 + fixCollisions(items, activeDragElement.item, isMobile); 500 + 501 + activeDragElement.x = -1; 502 + activeDragElement.y = -1; 503 + activeDragElement.element = null; 504 + activeDragElement.item = null; 505 + activeDragElement.lastTargetId = null; 506 + activeDragElement.lastPlacement = null; 507 + } 508 + 509 + touchDragActive = false; 510 + } 511 + 512 + // Only register non-passive touchmove when actively dragging 513 + $effect(() => { 514 + const el = container; 515 + if (!touchDragActive || !el) return; 516 + 517 + el.addEventListener('touchmove', touchMove, { passive: false }); 518 + return () => { 519 + el.removeEventListener('touchmove', touchMove); 520 + }; 521 + }); 522 + 366 523 let linkValue = $state(''); 367 524 368 525 function addLink(url: string) { ··· 750 907 ]} 751 908 > 752 909 <div class="pointer-events-none"></div> 753 - <!-- svelte-ignore a11y_no_static_element_interactions --> 910 + <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 754 911 <div 755 912 bind:this={container} 913 + onclick={(e) => { 914 + // Deselect when tapping empty grid space 915 + if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 916 + selectedCardId = null; 917 + } 918 + }} 919 + ontouchstart={touchStart} 920 + ontouchend={touchEnd} 756 921 ondragover={(e) => { 757 922 e.preventDefault(); 758 923 ··· 931 1096 {handleVideoInputChange} 932 1097 showCardCommand={() => { 933 1098 showCardCommand = true; 1099 + {selectedCard} 1100 + {isMobile} 1101 + {isCoarse} 1102 + ondeselect={() => { 1103 + selectedCardId = null; 1104 + }} 1105 + ondelete={() => { 1106 + if (selectedCard) { 1107 + items = items.filter((it) => it.id !== selectedCardId); 1108 + compactItems(items, false); 1109 + compactItems(items, true); 1110 + selectedCardId = null; 1111 + } 1112 + }} 1113 + onsetsize={(w: number, h: number) => { 1114 + if (selectedCard) { 1115 + if (isMobile) { 1116 + selectedCard.mobileW = w; 1117 + selectedCard.mobileH = h; 1118 + } else { 1119 + selectedCard.w = w; 1120 + selectedCard.h = h; 1121 + } 1122 + fixCollisions(items, selectedCard, isMobile); 1123 + } 934 1124 }} 935 1125 /> 936 1126
+3
src/lib/website/context.ts
··· 7 7 export const [getCanEdit, setCanEdit] = createContext<() => boolean>(); 8 8 export const [getAdditionalUserData, setAdditionalUserData] = 9 9 createContext<Record<string, unknown>>(); 10 + export const [getIsCoarse, setIsCoarse] = createContext<() => boolean>(); 11 + export const [getSelectedCardId, setSelectedCardId] = createContext<() => string | null>(); 12 + export const [getSelectCard, setSelectCard] = createContext<(id: string | null) => void>();