your personal website on atproto - mirror blento.app

commit

+595 -145
+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"
+346 -119
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, ··· 17 20 save, 18 21 19 22 handleImageInputChange, 20 - handleVideoInputChange 23 + handleVideoInputChange, 24 + 25 + selectedCard = null, 26 + isMobile = false, 27 + isCoarse = false, 28 + ondeselect, 29 + ondelete, 30 + onsetsize 21 31 }: { 22 32 data: WebsiteData; 23 33 linkValue: string; ··· 33 43 34 44 handleImageInputChange: (evt: Event) => void; 35 45 handleVideoInputChange: (evt: Event) => void; 46 + 47 + selectedCard?: Item | null; 48 + isMobile?: boolean; 49 + isCoarse?: boolean; 50 + ondeselect?: () => void; 51 + ondelete?: () => void; 52 + onsetsize?: (w: number, h: number) => void; 36 53 } = $props(); 37 54 38 55 let linkPopoverOpen = $state(false); ··· 52 69 await navigator.clipboard.writeText(url); 53 70 toast.success('Link copied to clipboard!'); 54 71 } 72 + 73 + let colorsChoices = [ 74 + { class: 'text-base-500', label: 'base' }, 75 + { class: 'text-accent-500', label: 'accent' }, 76 + { class: 'text-base-300 dark:text-base-700', label: 'transparent' }, 77 + { class: 'text-red-500', label: 'red' }, 78 + { class: 'text-orange-500', label: 'orange' }, 79 + { class: 'text-amber-500', label: 'amber' }, 80 + { class: 'text-yellow-500', label: 'yellow' }, 81 + { class: 'text-lime-500', label: 'lime' }, 82 + { class: 'text-green-500', label: 'green' }, 83 + { class: 'text-emerald-500', label: 'emerald' }, 84 + { class: 'text-teal-500', label: 'teal' }, 85 + { class: 'text-cyan-500', label: 'cyan' }, 86 + { class: 'text-sky-500', label: 'sky' }, 87 + { class: 'text-blue-500', label: 'blue' }, 88 + { class: 'text-indigo-500', label: 'indigo' }, 89 + { class: 'text-violet-500', label: 'violet' }, 90 + { class: 'text-purple-500', label: 'purple' }, 91 + { class: 'text-fuchsia-500', label: 'fuchsia' }, 92 + { class: 'text-pink-500', label: 'pink' }, 93 + { class: 'text-rose-500', label: 'rose' } 94 + ]; 95 + 96 + let selectedColor = $derived( 97 + selectedCard 98 + ? colorsChoices.find((c) => (selectedCard!.color ?? 'base') === c.label) 99 + : undefined 100 + ); 101 + 102 + let cardDef = $derived( 103 + selectedCard ? (CardDefinitionsByType[selectedCard.cardType] ?? null) : null 104 + ); 105 + 106 + let colorPopoverOpen = $state(false); 107 + let settingsPopoverOpen = $state(false); 108 + 109 + const minW = $derived(cardDef?.minW ?? 2); 110 + const minH = $derived(cardDef?.minH ?? 2); 111 + const maxW = $derived(cardDef?.maxW ?? COLUMNS); 112 + const maxH = $derived(cardDef?.maxH ?? (isMobile ? 12 : 6)); 113 + 114 + function canSetSize(w: number, h: number) { 115 + if (!cardDef) return false; 116 + if (isMobile) { 117 + return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH; 118 + } 119 + return w >= minW && w <= maxW && h >= minH && h <= maxH; 120 + } 121 + 122 + const showMobileEditControls = $derived(isCoarse && selectedCard); 55 123 </script> 56 124 57 125 <input ··· 74 142 75 143 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 76 144 <Navbar 77 - class={[ 78 - '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', 79 - !dev ? 'hidden' : '' 80 - ]} 145 + 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" 81 146 > 82 - <div class="flex items-center gap-2"> 83 - <Button 84 - size="iconLg" 85 - variant="ghost" 86 - class="backdrop-blur-none" 87 - onclick={() => { 88 - newCard('section'); 89 - }} 90 - > 91 - <svg 92 - xmlns="http://www.w3.org/2000/svg" 93 - viewBox="0 0 24 24" 94 - fill="none" 95 - stroke="currentColor" 96 - stroke-width="2" 97 - stroke-linecap="round" 98 - stroke-linejoin="round" 99 - ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 147 + {#if showMobileEditControls} 148 + <!-- Mobile edit controls when a card is selected --> 149 + <div class="flex items-center gap-1"> 150 + <Button 151 + size="iconLg" 152 + variant="ghost" 153 + class="backdrop-blur-none" 154 + onclick={() => ondeselect?.()} 155 + > 156 + <svg 157 + xmlns="http://www.w3.org/2000/svg" 158 + fill="none" 159 + viewBox="0 0 24 24" 160 + stroke-width="2" 161 + stroke="currentColor" 162 + class="size-5" 163 + > 164 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 165 + </svg> 166 + </Button> 167 + 168 + <Button 169 + size="iconLg" 170 + variant="ghost" 171 + class="text-rose-500 backdrop-blur-none" 172 + onclick={() => ondelete?.()} 100 173 > 101 - </Button> 174 + <svg 175 + xmlns="http://www.w3.org/2000/svg" 176 + fill="none" 177 + viewBox="0 0 24 24" 178 + stroke-width="1.5" 179 + stroke="currentColor" 180 + class="size-5" 181 + > 182 + <path 183 + stroke-linecap="round" 184 + stroke-linejoin="round" 185 + 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" 186 + /> 187 + </svg> 188 + </Button> 102 189 103 - <Button 104 - size="iconLg" 105 - variant="ghost" 106 - class="backdrop-blur-none" 107 - onclick={() => { 108 - newCard('text'); 109 - }} 110 - > 111 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 112 - ><path 190 + {#if cardDef?.allowSetColor !== false} 191 + <Popover bind:open={colorPopoverOpen}> 192 + {#snippet child({ props })} 193 + <button 194 + {...props} 195 + class={[ 196 + 'm-1 size-5 cursor-pointer rounded-full', 197 + !selectedCard?.color || 198 + selectedCard.color === 'base' || 199 + selectedCard.color === 'transparent' 200 + ? 'text-base-800 dark:text-base-200' 201 + : 'text-accent-500' 202 + ]} 203 + > 204 + <svg 205 + xmlns="http://www.w3.org/2000/svg" 206 + viewBox="0 0 24 24" 207 + fill="currentColor" 208 + class="size-5" 209 + > 210 + <path 211 + fill-rule="evenodd" 212 + 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" 213 + clip-rule="evenodd" 214 + /> 215 + </svg> 216 + </button> 217 + {/snippet} 218 + <ColorSelect 219 + selected={selectedColor} 220 + colors={colorsChoices} 221 + onselected={(color, previous) => { 222 + if (typeof previous === 'string' || typeof color === 'string') { 223 + return; 224 + } 225 + if (selectedCard) { 226 + selectedCard.color = color.label; 227 + } 228 + }} 229 + class="w-64" 230 + /> 231 + </Popover> 232 + {/if} 233 + 234 + {#if canSetSize(2, 2)} 235 + <button 236 + onclick={() => onsetsize?.(4, 4)} 237 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2 text-xs font-bold" 238 + > 239 + S 240 + </button> 241 + {/if} 242 + {#if canSetSize(4, 2)} 243 + <button 244 + onclick={() => onsetsize?.(8, 4)} 245 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2 text-xs font-bold" 246 + > 247 + M 248 + </button> 249 + {/if} 250 + {#if canSetSize(2, 4)} 251 + <button 252 + onclick={() => onsetsize?.(4, 8)} 253 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2 text-xs font-bold" 254 + > 255 + L 256 + </button> 257 + {/if} 258 + {#if canSetSize(4, 4)} 259 + <button 260 + onclick={() => onsetsize?.(8, 8)} 261 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2 text-xs font-bold" 262 + > 263 + XL 264 + </button> 265 + {/if} 266 + 267 + {#if cardDef?.settingsComponent && selectedCard} 268 + <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 269 + {#snippet child({ props })} 270 + <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 271 + <svg 272 + xmlns="http://www.w3.org/2000/svg" 273 + fill="none" 274 + viewBox="0 0 24 24" 275 + stroke-width="2" 276 + stroke="currentColor" 277 + class="size-5" 278 + > 279 + <path 280 + stroke-linecap="round" 281 + stroke-linejoin="round" 282 + 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" 283 + /> 284 + <path 285 + stroke-linecap="round" 286 + stroke-linejoin="round" 287 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 288 + /> 289 + </svg> 290 + </button> 291 + {/snippet} 292 + <cardDef.settingsComponent 293 + bind:item={selectedCard} 294 + onclose={() => { 295 + settingsPopoverOpen = false; 296 + }} 297 + /> 298 + </Popover> 299 + {/if} 300 + </div> 301 + {:else} 302 + <!-- Normal add-card controls --> 303 + <div class="flex items-center gap-2"> 304 + <Button 305 + size="iconLg" 306 + variant="ghost" 307 + class="backdrop-blur-none" 308 + onclick={() => { 309 + newCard('section'); 310 + }} 311 + > 312 + <svg 313 + xmlns="http://www.w3.org/2000/svg" 314 + viewBox="0 0 24 24" 113 315 fill="none" 114 316 stroke="currentColor" 317 + stroke-width="2" 115 318 stroke-linecap="round" 116 319 stroke-linejoin="round" 117 - stroke-width="2" 118 - 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" 119 - /></svg 320 + ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 321 + > 322 + </Button> 323 + 324 + <Button 325 + size="iconLg" 326 + variant="ghost" 327 + class="backdrop-blur-none" 328 + onclick={() => { 329 + newCard('text'); 330 + }} 120 331 > 121 - </Button> 332 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 333 + ><path 334 + fill="none" 335 + stroke="currentColor" 336 + stroke-linecap="round" 337 + stroke-linejoin="round" 338 + stroke-width="2" 339 + 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" 340 + /></svg 341 + > 342 + </Button> 122 343 123 - <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 124 - {#snippet child({ props })} 125 - <Button 126 - size="iconLg" 127 - variant="ghost" 128 - class="backdrop-blur-none" 129 - onclick={() => { 130 - newCard('link'); 344 + <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 345 + {#snippet child({ props })} 346 + <Button 347 + size="iconLg" 348 + variant="ghost" 349 + class="backdrop-blur-none" 350 + onclick={() => { 351 + newCard('link'); 352 + }} 353 + {...props} 354 + > 355 + <svg 356 + xmlns="http://www.w3.org/2000/svg" 357 + fill="none" 358 + viewBox="-2 -2 28 28" 359 + stroke-width="2" 360 + stroke="currentColor" 361 + > 362 + <path 363 + stroke-linecap="round" 364 + stroke-linejoin="round" 365 + 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" 366 + /> 367 + </svg> 368 + </Button> 369 + {/snippet} 370 + <Input 371 + spellcheck={false} 372 + type="url" 373 + bind:value={linkValue} 374 + onkeydown={(event) => { 375 + if (event.code === 'Enter') { 376 + addLink(linkValue); 377 + event.preventDefault(); 378 + } 131 379 }} 132 - {...props} 133 - > 134 - <svg 380 + placeholder="Enter link" 381 + /> 382 + <Button onclick={() => addLink(linkValue)} size="icon" 383 + ><svg 135 384 xmlns="http://www.w3.org/2000/svg" 136 385 fill="none" 137 - viewBox="-2 -2 28 28" 386 + viewBox="0 0 24 24" 138 387 stroke-width="2" 139 388 stroke="currentColor" 389 + class="size-6" 140 390 > 141 - <path 142 - stroke-linecap="round" 143 - stroke-linejoin="round" 144 - 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" 145 - /> 391 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 146 392 </svg> 147 393 </Button> 148 - {/snippet} 149 - <Input 150 - spellcheck={false} 151 - type="url" 152 - bind:value={linkValue} 153 - onkeydown={(event) => { 154 - if (event.code === 'Enter') { 155 - addLink(linkValue); 156 - event.preventDefault(); 157 - } 394 + </Popover> 395 + 396 + <Button 397 + size="iconLg" 398 + variant="ghost" 399 + class="backdrop-blur-none" 400 + onclick={() => { 401 + imageInputRef?.click(); 158 402 }} 159 - placeholder="Enter link" 160 - /> 161 - <Button onclick={() => addLink(linkValue)} size="icon" 162 - ><svg 403 + > 404 + <svg 163 405 xmlns="http://www.w3.org/2000/svg" 164 406 fill="none" 165 407 viewBox="0 0 24 24" 166 408 stroke-width="2" 167 409 stroke="currentColor" 168 - class="size-6" 169 410 > 170 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 411 + <path 412 + stroke-linecap="round" 413 + stroke-linejoin="round" 414 + 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" 415 + /> 171 416 </svg> 172 417 </Button> 173 - </Popover> 174 418 175 - <Button 176 - size="iconLg" 177 - variant="ghost" 178 - class="backdrop-blur-none" 179 - onclick={() => { 180 - imageInputRef?.click(); 181 - }} 182 - > 183 - <svg 184 - xmlns="http://www.w3.org/2000/svg" 185 - fill="none" 186 - viewBox="0 0 24 24" 187 - stroke-width="2" 188 - stroke="currentColor" 189 - > 190 - <path 191 - stroke-linecap="round" 192 - stroke-linejoin="round" 193 - 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" 194 - /> 195 - </svg> 196 - </Button> 419 + {#if dev} 420 + <Button 421 + size="iconLg" 422 + variant="ghost" 423 + class="backdrop-blur-none" 424 + onclick={() => { 425 + videoInputRef?.click(); 426 + }} 427 + > 428 + <svg 429 + xmlns="http://www.w3.org/2000/svg" 430 + fill="none" 431 + viewBox="0 0 24 24" 432 + stroke-width="1.5" 433 + stroke="currentColor" 434 + > 435 + <path 436 + stroke-linecap="round" 437 + stroke-linejoin="round" 438 + 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" 439 + /> 440 + </svg> 441 + </Button> 442 + {/if} 197 443 198 - {#if dev} 199 444 <Button 200 445 size="iconLg" 201 446 variant="ghost" 202 447 class="backdrop-blur-none" 203 - onclick={() => { 204 - videoInputRef?.click(); 205 - }} 448 + popovertarget="mobile-menu" 206 449 > 207 450 <svg 208 451 xmlns="http://www.w3.org/2000/svg" ··· 211 454 stroke-width="1.5" 212 455 stroke="currentColor" 213 456 > 214 - <path 215 - stroke-linecap="round" 216 - stroke-linejoin="round" 217 - 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" 218 - /> 457 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 219 458 </svg> 220 459 </Button> 221 - {/if} 222 - 223 - <Button size="iconLg" variant="ghost" class="backdrop-blur-none" popovertarget="mobile-menu"> 224 - <svg 225 - xmlns="http://www.w3.org/2000/svg" 226 - fill="none" 227 - viewBox="0 0 24 24" 228 - stroke-width="1.5" 229 - stroke="currentColor" 230 - > 231 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 232 - </svg> 233 - </Button> 234 - </div> 235 - <div class="flex items-center gap-2"> 460 + </div> 461 + {/if} 462 + <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 236 463 <Toggle 237 464 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 238 465 bind:pressed={showingMobileView}
+199 -16
src/lib/website/EditableWebsite.svelte
··· 26 26 import { tick, type Component } from 'svelte'; 27 27 import type { 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'; ··· 124 124 125 125 setIsMobile(() => isMobile); 126 126 127 + const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; 128 + setIsCoarse(() => isCoarse); 129 + 130 + let selectedCardId: string | null = $state(null); 131 + let selectedCard = $derived( 132 + selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null 133 + ); 134 + 135 + setSelectedCardId(() => selectedCardId); 136 + setSelectCard((id: string | null) => { 137 + selectedCardId = id; 138 + }); 139 + 127 140 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 128 141 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 129 142 ··· 140 153 } 141 154 142 155 function newCard(type: string = 'link', cardData?: any) { 156 + selectedCardId = null; 157 + 143 158 // close sidebar if open 144 159 const popover = document.getElementById('mobile-menu'); 145 160 if (popover) { ··· 229 244 230 245 let debugPoint = $state({ x: 0, y: 0 }); 231 246 232 - function getDragXY( 233 - e: DragEvent & { 234 - currentTarget: EventTarget & HTMLDivElement; 235 - } 247 + function getGridPosition( 248 + clientX: number, 249 + clientY: number 236 250 ): 237 251 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 238 252 | undefined { 239 253 if (!container || !activeDragElement.item) return; 240 254 241 255 // x, y represent the top-left corner of the dragged card 242 - const x = e.clientX + activeDragElement.mouseDeltaX; 243 - const y = e.clientY + activeDragElement.mouseDeltaY; 256 + const x = clientX + activeDragElement.mouseDeltaX; 257 + const y = clientY + activeDragElement.mouseDeltaY; 244 258 245 259 const rect = container.getBoundingClientRect(); 246 260 const currentMargin = isMobile ? mobileMargin : margin; ··· 362 376 return { x: gridX, y: gridY, swapWithId, placement }; 363 377 } 364 378 379 + function getDragXY( 380 + e: DragEvent & { 381 + currentTarget: EventTarget & HTMLDivElement; 382 + } 383 + ) { 384 + return getGridPosition(e.clientX, e.clientY); 385 + } 386 + 387 + // Touch drag system (instant drag on selected card) 388 + let touchDragActive = $state(false); 389 + 390 + function touchStart(e: TouchEvent) { 391 + if (!selectedCardId || !container) return; 392 + const touch = e.touches[0]; 393 + if (!touch) return; 394 + 395 + // Check if the touch is on the selected card element 396 + const target = (e.target as HTMLElement)?.closest?.('.card'); 397 + if (!target || target.id !== selectedCardId) return; 398 + 399 + const item = items.find((i) => i.id === selectedCardId); 400 + if (!item || item.cardData?.locked) return; 401 + 402 + // Start dragging immediately 403 + touchDragActive = true; 404 + 405 + const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; 406 + if (!cardEl) return; 407 + 408 + activeDragElement.element = cardEl; 409 + activeDragElement.w = item.w; 410 + activeDragElement.h = item.h; 411 + activeDragElement.item = item; 412 + 413 + // Store original positions of all items 414 + activeDragElement.originalPositions = new Map(); 415 + for (const it of items) { 416 + activeDragElement.originalPositions.set(it.id, { 417 + x: it.x, 418 + y: it.y, 419 + mobileX: it.mobileX, 420 + mobileY: it.mobileY 421 + }); 422 + } 423 + 424 + const rect = cardEl.getBoundingClientRect(); 425 + activeDragElement.mouseDeltaX = rect.left - touch.clientX; 426 + activeDragElement.mouseDeltaY = rect.top - touch.clientY; 427 + } 428 + 429 + function touchMove(e: TouchEvent) { 430 + if (!touchDragActive) return; 431 + 432 + const touch = e.touches[0]; 433 + if (!touch) return; 434 + 435 + e.preventDefault(); 436 + 437 + const result = getGridPosition(touch.clientX, touch.clientY); 438 + if (!result || !activeDragElement.item) return; 439 + 440 + const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 441 + 442 + // Reset all items to original positions first 443 + for (const it of items) { 444 + const origPos = activeDragElement.originalPositions.get(it.id); 445 + if (origPos && it !== activeDragElement.item) { 446 + if (isMobile) { 447 + it.mobileX = origPos.mobileX; 448 + it.mobileY = origPos.mobileY; 449 + } else { 450 + it.x = origPos.x; 451 + it.y = origPos.y; 452 + } 453 + } 454 + } 455 + 456 + // Update dragged item position 457 + if (isMobile) { 458 + activeDragElement.item.mobileX = result.x; 459 + activeDragElement.item.mobileY = result.y; 460 + } else { 461 + activeDragElement.item.x = result.x; 462 + activeDragElement.item.y = result.y; 463 + } 464 + 465 + // Handle horizontal swap 466 + if (result.swapWithId && draggedOrigPos) { 467 + const swapTarget = items.find((it) => it.id === result.swapWithId); 468 + if (swapTarget) { 469 + if (isMobile) { 470 + swapTarget.mobileX = draggedOrigPos.mobileX; 471 + swapTarget.mobileY = draggedOrigPos.mobileY; 472 + } else { 473 + swapTarget.x = draggedOrigPos.x; 474 + swapTarget.y = draggedOrigPos.y; 475 + } 476 + } 477 + } 478 + 479 + fixCollisions(items, activeDragElement.item, isMobile); 480 + 481 + // Auto-scroll near edges 482 + const scrollZone = 100; 483 + const scrollSpeed = 10; 484 + const viewportHeight = window.innerHeight; 485 + 486 + if (touch.clientY < scrollZone) { 487 + const intensity = 1 - touch.clientY / scrollZone; 488 + window.scrollBy(0, -scrollSpeed * intensity); 489 + } else if (touch.clientY > viewportHeight - scrollZone) { 490 + const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; 491 + window.scrollBy(0, scrollSpeed * intensity); 492 + } 493 + } 494 + 495 + function touchEnd() { 496 + if (touchDragActive && activeDragElement.item) { 497 + // Finalize position 498 + fixCollisions(items, activeDragElement.item, isMobile); 499 + 500 + activeDragElement.x = -1; 501 + activeDragElement.y = -1; 502 + activeDragElement.element = null; 503 + activeDragElement.item = null; 504 + activeDragElement.lastTargetId = null; 505 + activeDragElement.lastPlacement = null; 506 + } 507 + 508 + touchDragActive = false; 509 + } 510 + 511 + // Only register non-passive touchmove when actively dragging 512 + $effect(() => { 513 + const el = container; 514 + if (!touchDragActive || !el) return; 515 + 516 + el.addEventListener('touchmove', touchMove, { passive: false }); 517 + return () => { 518 + el.removeEventListener('touchmove', touchMove); 519 + }; 520 + }); 521 + 365 522 let linkValue = $state(''); 366 523 367 524 function addLink(url: string) { ··· 676 833 <Account {data} /> 677 834 678 835 <Context {data}> 679 - {#if !dev} 680 - <div 681 - class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden" 682 - > 683 - Editing on mobile is not supported yet. Please use a desktop browser. 684 - </div> 685 - {/if} 686 - 687 836 <Controls bind:data /> 688 837 689 838 {#if showingMobileView} ··· 732 881 ]} 733 882 > 734 883 <div class="pointer-events-none"></div> 735 - <!-- svelte-ignore a11y_no_static_element_interactions --> 884 + <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 736 885 <div 737 886 bind:this={container} 887 + onclick={(e) => { 888 + // Deselect when tapping empty grid space 889 + if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 890 + selectedCardId = null; 891 + } 892 + }} 893 + ontouchstart={touchStart} 894 + ontouchend={touchEnd} 738 895 ondragover={(e) => { 739 896 e.preventDefault(); 740 897 ··· 911 1068 {save} 912 1069 {handleImageInputChange} 913 1070 {handleVideoInputChange} 1071 + {selectedCard} 1072 + {isMobile} 1073 + {isCoarse} 1074 + ondeselect={() => { 1075 + selectedCardId = null; 1076 + }} 1077 + ondelete={() => { 1078 + if (selectedCard) { 1079 + items = items.filter((it) => it.id !== selectedCardId); 1080 + compactItems(items, false); 1081 + compactItems(items, true); 1082 + selectedCardId = null; 1083 + } 1084 + }} 1085 + onsetsize={(w: number, h: number) => { 1086 + if (selectedCard) { 1087 + if (isMobile) { 1088 + selectedCard.mobileW = w; 1089 + selectedCard.mobileH = h; 1090 + } else { 1091 + selectedCard.w = w; 1092 + selectedCard.h = h; 1093 + } 1094 + fixCollisions(items, selectedCard, isMobile); 1095 + } 1096 + }} 914 1097 /> 915 1098 916 1099 <Toaster />
+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>();