your personal website on atproto - mirror blento.app

Merge remote-tracking branch 'origin/next' into card-command-bar-v2

+837 -130
-42
docs/Beta.md
··· 1 - # Todo for beta version 2 - 3 - - site.standard 4 - - move description to markdownDescription and set description as text only 5 - 6 - - allow editing on mobile 7 - 8 - - get automatic layout for mobile if only edited on desktop (and vice versa) 9 - 10 - - add cards in middle of current position (both mobile and desktop version) 11 - 12 - - show nsfw warnings 13 - 14 - - card with big call to action button "create your blento" 15 - 16 - - ask to fill with some default cards on page creation 17 - 18 - - when adding images try to add them in a size that best fits aspect ratio 19 - 20 - - onboarding? 21 - 22 - - switch sidebar to a quick list of available cards with search function 23 - 24 - - test 25 - - selfhosting 26 - 27 - - guestbook card 28 - 29 - - onboarding? 30 - 31 - - switch sidebar to a quick list of available cards with search function 32 - 33 - - test 34 - - selfhosting 35 - 36 - - guestbook card 37 - 38 - - analytics? 39 - 40 - - refresh recently updated blentos (move to top of list, update profiles every 24 hours) 41 - 42 - - server side oauth?
···
+1
package.json
··· 85 "svelte-sonner": "^1.0.7", 86 "tailwind-merge": "^3.4.0", 87 "tailwind-variants": "^3.2.2", 88 "three": "^0.176.0", 89 "turndown": "^7.2.2", 90 "wrangler": "^4.60.0"
··· 85 "svelte-sonner": "^1.0.7", 86 "tailwind-merge": "^3.4.0", 87 "tailwind-variants": "^3.2.2", 88 + "tailwindcss-animate": "^1.0.7", 89 "three": "^0.176.0", 90 "turndown": "^7.2.2", 91 "wrangler": "^4.60.0"
+12
pnpm-lock.yaml
··· 146 tailwind-variants: 147 specifier: ^3.2.2 148 version: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) 149 three: 150 specifier: ^0.176.0 151 version: 0.176.0 ··· 2799 peerDependenciesMeta: 2800 tailwind-merge: 2801 optional: true 2802 2803 tailwindcss@4.1.18: 2804 resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} ··· 5560 tailwindcss: 4.1.18 5561 optionalDependencies: 5562 tailwind-merge: 3.4.0 5563 5564 tailwindcss@4.1.18: {} 5565
··· 146 tailwind-variants: 147 specifier: ^3.2.2 148 version: 3.2.2(tailwind-merge@3.4.0)(tailwindcss@4.1.18) 149 + tailwindcss-animate: 150 + specifier: ^1.0.7 151 + version: 1.0.7(tailwindcss@4.1.18) 152 three: 153 specifier: ^0.176.0 154 version: 0.176.0 ··· 2802 peerDependenciesMeta: 2803 tailwind-merge: 2804 optional: true 2805 + 2806 + tailwindcss-animate@1.0.7: 2807 + resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==, tarball: https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz} 2808 + peerDependencies: 2809 + tailwindcss: '>=3.0.0 || insiders' 2810 2811 tailwindcss@4.1.18: 2812 resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} ··· 5568 tailwindcss: 4.1.18 5569 optionalDependencies: 5570 tailwind-merge: 3.4.0 5571 + 5572 + tailwindcss-animate@1.0.7(tailwindcss@4.1.18): 5573 + dependencies: 5574 + tailwindcss: 4.1.18 5575 5576 tailwindcss@4.1.18: {} 5577
+1
src/lib/cards/ATProtoCollectionsCard/index.ts
··· 25 26 groups: ['Social'], 27 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /></svg>` 28 } as CardDefinition & { type: 'atprotocollections' };
··· 25 26 groups: ['Social'], 27 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /></svg>` 28 + 29 } as CardDefinition & { type: 'atprotocollections' };
+11 -1
src/lib/cards/BaseCard/BaseCard.svelte
··· 5 import type { Snippet } from 'svelte'; 6 import type { HTMLAttributes } from 'svelte/elements'; 7 import { getColor } from '..'; 8 9 const colors = { 10 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 39 id={item.id} 40 data-flip-id={item.id} 41 bind:this={ref} 42 - draggable={isEditing && !locked} 43 class={[ 44 '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 color ? (colors[color] ?? colors.accent) : colors.base,
··· 5 import type { Snippet } from 'svelte'; 6 import type { HTMLAttributes } from 'svelte/elements'; 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(); 18 19 const colors = { 20 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 49 id={item.id} 50 data-flip-id={item.id} 51 bind:this={ref} 52 + draggable={isEditing && !locked && !isCoarse?.()} 53 class={[ 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', 55 color ? (colors[color] ?? colors.accent) : colors.base,
+37 -10
src/lib/cards/BaseCard/BaseEditingCard.svelte
··· 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 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 12 import { fixAllCollisions, fixCollisions } from '$lib/helper'; 13 ··· 53 54 let canEdit = getCanEdit(); 55 let isMobile = getIsMobile(); 56 57 let colorPopoverOpen = $state(false); 58 ··· 173 {item} 174 isEditing={true} 175 bind:ref 176 - showOutline={isResizing} 177 locked={item.cardData?.locked} 178 - class="scale-100 opacity-100 starting:scale-0 starting:opacity-0" 179 {...rest} 180 > 181 - {#if !item.cardData?.locked} 182 - <div class="absolute inset-0 cursor-grab"></div> 183 {/if} 184 {@render children?.()} 185 ··· 187 <div 188 class={cn( 189 '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' 191 )} 192 > 193 <PlainTextEditor ··· 205 {#if changeOptions.length > 1} 206 <div 207 class={[ 208 - 'absolute -top-3 -right-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex', 209 changePopoverOpen ? 'inline-flex' : '' 210 ]} 211 > ··· 253 onclick={() => { 254 ondelete(); 255 }} 256 - class="absolute -top-3 -left-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex" 257 > 258 <svg 259 xmlns="http://www.w3.org/2000/svg" ··· 274 275 <div 276 class={[ 277 - 'absolute -bottom-7 w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover/card:inline-flex', 278 colorPopoverOpen || settingsPopoverOpen ? 'inline-flex' : 'hidden' 279 ]} 280 > ··· 411 <!-- Resize handle at bottom right corner --> 412 <div 413 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" 415 > 416 <svg 417 xmlns="http://www.w3.org/2000/svg"
··· 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 ··· 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 ··· 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 ··· 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 ··· 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 > ··· 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" ··· 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 > ··· 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"
+1
src/lib/cards/BigSocialCard/index.ts
··· 55 56 groups: ['Social'], 57 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z" /></svg>` 58 } as CardDefinition & { type: 'bigsocial' }; 59 60 import {
··· 55 56 groups: ['Social'], 57 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z" /></svg>` 58 + 59 } as CardDefinition & { type: 'bigsocial' }; 60 61 import {
+1
src/lib/cards/BlueskyMediaCard/index.ts
··· 14 15 name: 'Video/Image from Bluesky', 16 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0 1 18 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0 1 18 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 0 1 6 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" /></svg>` 17 } as CardDefinition & { type: 'blueskyMedia' };
··· 14 15 name: 'Video/Image from Bluesky', 16 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0 1 18 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0 1 18 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 0 1 6 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" /></svg>` 17 + 18 } as CardDefinition & { type: 'blueskyMedia' };
+1
src/lib/cards/BlueskyPostCard/index.ts
··· 67 68 groups: ['Social'], 69 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /></svg>` 70 } as CardDefinition & { type: 'blueskyPost' };
··· 67 68 groups: ['Social'], 69 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /></svg>` 70 + 71 } as CardDefinition & { type: 'blueskyPost' };
+1
src/lib/cards/ButtonCard/index.ts
··· 32 groups: ['Utilities'], 33 name: 'Button', 34 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59" /></svg>` 35 };
··· 32 groups: ['Utilities'], 33 name: 'Button', 34 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59" /></svg>` 35 + 36 };
+1
src/lib/cards/DrawCard/index.ts
··· 27 28 groups: ['Visual'], 29 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /></svg>` 30 } as CardDefinition & { type: 'draw' };
··· 27 28 groups: ['Visual'], 29 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /></svg>` 30 + 31 } as CardDefinition & { type: 'draw' };
+1
src/lib/cards/EmbedCard/index.ts
··· 22 name: 'Embed', 23 groups: ['Media'], 24 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>` 25 } as CardDefinition & { type: 'embed' };
··· 22 name: 'Embed', 23 groups: ['Media'], 24 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>` 25 + 26 } as CardDefinition & { type: 'embed' };
+1
src/lib/cards/EventCard/index.ts
··· 116 117 groups: ['Social'], 118 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" /></svg>` 119 } as CardDefinition & { type: 'event' };
··· 116 117 groups: ['Social'], 118 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" /></svg>` 119 + 120 } as CardDefinition & { type: 'event' };
+1
src/lib/cards/FluidTextCard/index.ts
··· 28 groups: ['Visual'], 29 name: 'Fluid Text', 30 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></svg>` 31 } as CardDefinition & { type: 'fluid-text' };
··· 28 groups: ['Visual'], 29 name: 'Fluid Text', 30 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></svg>` 31 + 32 } as CardDefinition & { type: 'fluid-text' };
+1
src/lib/cards/GIFCard/index.ts
··· 49 50 groups: ['Media'], 51 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 8.25v7.5m-6-3.75h3v3.75m-3-7.5h3M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" /></svg>` 52 } as CardDefinition & { type: 'gif' };
··· 49 50 groups: ['Media'], 51 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 8.25v7.5m-6-3.75h3v3.75m-3-7.5h3M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" /></svg>` 52 + 53 } as CardDefinition & { type: 'gif' };
+1
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 19 groups: ['Games'], 20 name: 'Dino Game', 21 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.491 48.491 0 0 1-4.163-.3c-1.228-.158-2.33.895-2.33 2.134v0c0 1.26 1.09 2.22 2.34 2.14a48.089 48.089 0 0 1 3.27-.108c.43 0 .78.348.78.78v0c0 .22-.09.422-.234.577a8.398 8.398 0 0 0-2.07 4.238c-.19 1.14.513 2.163 1.578 2.428a2.07 2.07 0 0 0 2.478-1.41c.203-.636.37-1.294.524-1.947.128-.537.612-.898 1.16-.84 1.378.15 2.782.18 4.17.076 1.156-.087 2.03-1.09 1.883-2.24a8.52 8.52 0 0 0-1.568-3.7A2.01 2.01 0 0 1 18 8.053v0c0-1.064.82-1.98 1.88-2.08A48.678 48.678 0 0 0 24 5.328v0" /></svg>` 22 } as CardDefinition & { type: 'dino-game' };
··· 19 groups: ['Games'], 20 name: 'Dino Game', 21 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.491 48.491 0 0 1-4.163-.3c-1.228-.158-2.33.895-2.33 2.134v0c0 1.26 1.09 2.22 2.34 2.14a48.089 48.089 0 0 1 3.27-.108c.43 0 .78.348.78.78v0c0 .22-.09.422-.234.577a8.398 8.398 0 0 0-2.07 4.238c-.19 1.14.513 2.163 1.578 2.428a2.07 2.07 0 0 0 2.478-1.41c.203-.636.37-1.294.524-1.947.128-.537.612-.898 1.16-.84 1.378.15 2.782.18 4.17.076 1.156-.087 2.03-1.09 1.883-2.24a8.52 8.52 0 0 0-1.568-3.7A2.01 2.01 0 0 1 18 8.053v0c0-1.064.82-1.98 1.88-2.08A48.678 48.678 0 0 0 24 5.328v0" /></svg>` 22 + 23 } as CardDefinition & { type: 'dino-game' };
+1
src/lib/cards/GameCards/TetrisCard/index.ts
··· 25 26 name: 'Tetris', 27 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14 4h-4v4H6v4h4v4h4v-4h4V8h-4V4Z" /></svg>` 28 } as CardDefinition & { type: 'tetris' };
··· 25 26 name: 'Tetris', 27 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14 4h-4v4H6v4h4v4h4v-4h4V8h-4V4Z" /></svg>` 28 + 29 } as CardDefinition & { type: 'tetris' };
+1
src/lib/cards/GitHubProfileCard/index.ts
··· 56 57 groups: ['Social'], 58 icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>` 59 } as CardDefinition & { type: 'githubProfile' }; 60 61 function getGitHubUsername(url: string | undefined): string | undefined {
··· 56 57 groups: ['Social'], 58 icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>` 59 + 60 } as CardDefinition & { type: 'githubProfile' }; 61 62 function getGitHubUsername(url: string | undefined): string | undefined {
+1
src/lib/cards/GuestbookCard/index.ts
··· 63 name: 'Guestbook', 64 groups: ['Social'], 65 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>` 66 } as CardDefinition & { type: 'guestbook' };
··· 63 name: 'Guestbook', 64 groups: ['Social'], 65 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>` 66 + 67 } as CardDefinition & { type: 'guestbook' };
+1
src/lib/cards/LatestBlueskyPostCard/index.ts
··· 24 25 groups: ['Social'], 26 icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M6.335 3.836a47.2 47.2 0 0 1 5.354 4.94c.088.093.165.18.232.26a18 18 0 0 1 .232-.26 47.2 47.2 0 0 1 5.355-4.94C18.882 2.687 21.46 1.37 22.553 2.483c.986 1.003.616 4.264.305 5.857-.567 2.902-2.018 4.274-3.703 4.542 2.348.386 4.678 1.96 3.13 5.602-1.97 4.636-7.065 1.763-9.795-.418a3 3 0 0 1-.18-.15 3 3 0 0 1-.18.15c-2.73 2.18-7.825 5.054-9.795.418-1.548-3.643.782-5.216 3.13-5.602C3.98 12.631 2.529 11.26 1.962 8.357c-.311-1.593-.681-4.854.305-5.857C3.361 1.37 5.94 2.687 6.335 3.836Z" /></svg>` 27 } as CardDefinition & { type: 'latestPost' };
··· 24 25 groups: ['Social'], 26 icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M6.335 3.836a47.2 47.2 0 0 1 5.354 4.94c.088.093.165.18.232.26a18 18 0 0 1 .232-.26 47.2 47.2 0 0 1 5.355-4.94C18.882 2.687 21.46 1.37 22.553 2.483c.986 1.003.616 4.264.305 5.857-.567 2.902-2.018 4.274-3.703 4.542 2.348.386 4.678 1.96 3.13 5.602-1.97 4.636-7.065 1.763-9.795-.418a3 3 0 0 1-.18-.15 3 3 0 0 1-.18.15c-2.73 2.18-7.825 5.054-9.795.418-1.548-3.643.782-5.216 3.13-5.602C3.98 12.631 2.529 11.26 1.962 8.357c-.311-1.593-.681-4.854.305-5.857C3.361 1.37 5.94 2.687 6.335 3.836Z" /></svg>` 27 + 28 } as CardDefinition & { type: 'latestPost' };
+1
src/lib/cards/LivestreamCard/index.ts
··· 84 name: 'Latest Livestream (stream.place)', 85 groups: ['Media'], 86 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" 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" /></svg>` 87 } as CardDefinition & { type: 'latestLivestream' }; 88 89 export const LivestreamEmbedCardDefitition = {
··· 84 name: 'Latest Livestream (stream.place)', 85 groups: ['Media'], 86 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" 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" /></svg>` 87 + 88 } as CardDefinition & { type: 'latestLivestream' }; 89 90 export const LivestreamEmbedCardDefitition = {
+1
src/lib/cards/PopfeedReviews/index.ts
··· 23 groups: ['Media'], 24 name: 'Movie and TV Reviews', 25 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>` 26 } as CardDefinition & { type: 'recentPopfeedReviews' };
··· 23 groups: ['Media'], 24 name: 'Movie and TV Reviews', 25 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>` 26 + 27 } as CardDefinition & { type: 'recentPopfeedReviews' };
+1
src/lib/cards/SpotifyCard/index.ts
··· 44 45 groups: ['Media'], 46 icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" /></svg>` 47 } as CardDefinition & { type: typeof cardType }; 48 49 // Match Spotify album and playlist URLs
··· 44 45 groups: ['Media'], 46 icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" /></svg>` 47 + 48 } as CardDefinition & { type: typeof cardType }; 49 50 // Match Spotify album and playlist URLs
+1
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 48 49 groups: ['Content'], 50 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>` 51 } as CardDefinition & { type: 'site.standard.document list' };
··· 48 49 groups: ['Content'], 50 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>` 51 + 52 } as CardDefinition & { type: 'site.standard.document list' };
+1
src/lib/cards/StatusphereCard/index.ts
··· 52 name: 'Emoji', 53 groups: ['Media'], 54 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" /></svg>` 55 } as CardDefinition & { type: 'statusphere' }; 56 57 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
··· 52 name: 'Emoji', 53 groups: ['Media'], 54 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" /></svg>` 55 + 56 } as CardDefinition & { type: 'statusphere' }; 57 58 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+1
src/lib/cards/TealFMPlaysCard/index.ts
··· 28 29 groups: ['Media'], 30 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 31 } as CardDefinition & { type: 'recentTealFMPlays' };
··· 28 29 groups: ['Media'], 30 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 31 + 32 } as CardDefinition & { type: 'recentTealFMPlays' };
+1
src/lib/cards/TimerCard/index.ts
··· 46 item.cardData.label = data.label; 47 } 48 } 49 } as CardDefinition & { type: 'timer' };
··· 46 item.cardData.label = data.label; 47 } 48 } 49 + 50 } as CardDefinition & { type: 'timer' };
+1
src/lib/cards/VCardCard/index.ts
··· 125 name: 'vCard Card', 126 groups: ['Social'], 127 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" /></svg>` 128 } as CardDefinition & { type: 'vcard' };
··· 125 name: 'vCard Card', 126 groups: ['Social'], 127 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" /></svg>` 128 + 129 } as CardDefinition & { type: 'vcard' };
+3
src/lib/types.ts
··· 55 // theme colors 56 accentColor?: string; 57 baseColor?: string; 58 }; 59 }; 60 profile: AppBskyActorDefs.ProfileViewDetailed;
··· 55 // theme colors 56 accentColor?: string; 57 baseColor?: string; 58 + 59 + // layout mirroring: 0/undefined=never edited, 1=desktop only, 2=mobile only, 3=both 60 + editedOn?: number; 61 }; 62 }; 63 profile: AppBskyActorDefs.ProfileViewDetailed;
+420 -20
src/lib/website/EditBar.svelte
··· 1 <script lang="ts"> 2 import { dev } from '$app/environment'; 3 import { user } from '$lib/atproto'; 4 - import type { WebsiteData } from '$lib/types'; 5 - import { Button, Navbar, Toggle, toast } from '@foxui/core'; 6 7 let { 8 data, ··· 13 14 save, 15 16 - showCardCommand 17 }: { 18 data: WebsiteData; 19 ··· 24 25 save: () => Promise<void>; 26 27 showCardCommand: () => void; 28 } = $props(); 29 30 function getShareUrl() { 31 const base = typeof window !== 'undefined' ? window.location.origin : ''; 32 const pagePath = ··· 39 await navigator.clipboard.writeText(url); 40 toast.success('Link copied to clipboard!'); 41 } 42 </script> 43 44 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 45 <Navbar 46 - class={[ 47 - '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', 48 - !dev ? 'hidden' : '' 49 - ]} 50 > 51 - <div class="flex items-center gap-2"> 52 - <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 53 - <svg 54 - xmlns="http://www.w3.org/2000/svg" 55 - fill="none" 56 - viewBox="0 0 24 24" 57 - stroke-width="1.5" 58 - stroke="currentColor" 59 > 60 - <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 61 - </svg> 62 - </Button> 63 - </div> 64 - <div class="flex items-center gap-2"> 65 <Toggle 66 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 67 bind:pressed={showingMobileView}
··· 1 <script lang="ts"> 2 import { dev } from '$app/environment'; 3 import { user } from '$lib/atproto'; 4 + import { COLUMNS } from '$lib'; 5 + import type { Item, WebsiteData } from '$lib/types'; 6 + import { CardDefinitionsByType } from '$lib/cards'; 7 + import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core'; 8 + import { ColorSelect } from '@foxui/colors'; 9 10 let { 11 data, ··· 16 17 save, 18 19 + handleImageInputChange, 20 + handleVideoInputChange, 21 + 22 + newCard, 23 + addLink, 24 + linkValue = $bindable(''), 25 + 26 + showCardCommand, 27 + selectedCard = null, 28 + isMobile = false, 29 + isCoarse = false, 30 + ondeselect, 31 + ondelete, 32 + onsetsize 33 }: { 34 data: WebsiteData; 35 ··· 40 41 save: () => Promise<void>; 42 43 + handleImageInputChange: (evt: Event) => void; 44 + handleVideoInputChange: (evt: Event) => void; 45 + 46 + newCard: (type?: string, cardData?: any) => void; 47 + addLink: (url: string) => void; 48 + linkValue: string; 49 + 50 showCardCommand: () => void; 51 + selectedCard?: Item | null; 52 + isMobile?: boolean; 53 + isCoarse?: boolean; 54 + ondeselect?: () => void; 55 + ondelete?: () => void; 56 + onsetsize?: (w: number, h: number) => void; 57 } = $props(); 58 59 + let linkPopoverOpen = $state(false); 60 + let imageInputRef: HTMLInputElement | undefined = $state(); 61 + let videoInputRef: HTMLInputElement | undefined = $state(); 62 + 63 function getShareUrl() { 64 const base = typeof window !== 'undefined' ? window.location.origin : ''; 65 const pagePath = ··· 72 await navigator.clipboard.writeText(url); 73 toast.success('Link copied to clipboard!'); 74 } 75 + 76 + let colorsChoices = [ 77 + { class: 'text-base-500', label: 'base' }, 78 + { class: 'text-accent-500', label: 'accent' }, 79 + { class: 'text-base-300 dark:text-base-700', label: 'transparent' }, 80 + { class: 'text-red-500', label: 'red' }, 81 + { class: 'text-orange-500', label: 'orange' }, 82 + { class: 'text-amber-500', label: 'amber' }, 83 + { class: 'text-yellow-500', label: 'yellow' }, 84 + { class: 'text-lime-500', label: 'lime' }, 85 + { class: 'text-green-500', label: 'green' }, 86 + { class: 'text-emerald-500', label: 'emerald' }, 87 + { class: 'text-teal-500', label: 'teal' }, 88 + { class: 'text-cyan-500', label: 'cyan' }, 89 + { class: 'text-sky-500', label: 'sky' }, 90 + { class: 'text-blue-500', label: 'blue' }, 91 + { class: 'text-indigo-500', label: 'indigo' }, 92 + { class: 'text-violet-500', label: 'violet' }, 93 + { class: 'text-purple-500', label: 'purple' }, 94 + { class: 'text-fuchsia-500', label: 'fuchsia' }, 95 + { class: 'text-pink-500', label: 'pink' }, 96 + { class: 'text-rose-500', label: 'rose' } 97 + ]; 98 + 99 + let selectedColor = $derived( 100 + selectedCard 101 + ? colorsChoices.find((c) => (selectedCard!.color ?? 'base') === c.label) 102 + : undefined 103 + ); 104 + 105 + let cardDef = $derived( 106 + selectedCard ? (CardDefinitionsByType[selectedCard.cardType] ?? null) : null 107 + ); 108 + 109 + let colorPopoverOpen = $state(false); 110 + let sizePopoverOpen = $state(false); 111 + let settingsPopoverOpen = $state(false); 112 + 113 + const minW = $derived(cardDef?.minW ?? 2); 114 + const minH = $derived(cardDef?.minH ?? 2); 115 + const maxW = $derived(cardDef?.maxW ?? COLUMNS); 116 + const maxH = $derived(cardDef?.maxH ?? (isMobile ? 12 : 6)); 117 + 118 + function canSetSize(w: number, h: number) { 119 + if (!cardDef) return false; 120 + if (isMobile) { 121 + return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH; 122 + } 123 + return w >= minW && w <= maxW && h >= minH && h <= maxH; 124 + } 125 + 126 + const showMobileEditControls = $derived(isCoarse && selectedCard); 127 </script> 128 129 + <input 130 + type="file" 131 + accept="image/*" 132 + onchange={handleImageInputChange} 133 + class="hidden" 134 + id="image-input" 135 + multiple 136 + bind:this={imageInputRef} 137 + /> 138 + 139 + <input 140 + type="file" 141 + accept="video/*" 142 + onchange={handleVideoInputChange} 143 + class="hidden" 144 + id="video-input" 145 + multiple 146 + bind:this={videoInputRef} 147 + /> 148 + 149 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 150 <Navbar 151 + 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" 152 > 153 + {#if showMobileEditControls} 154 + <!-- Mobile edit controls: left = color, size, settings; right = delete, deselect --> 155 + <div class="flex items-center gap-1"> 156 + {#if cardDef?.allowSetColor !== false} 157 + <Popover bind:open={colorPopoverOpen}> 158 + {#snippet child({ props })} 159 + <button 160 + {...props} 161 + class={[ 162 + 'cursor-pointer rounded-xl p-2', 163 + !selectedCard?.color || 164 + selectedCard.color === 'base' || 165 + selectedCard.color === 'transparent' 166 + ? 'text-base-800 dark:text-base-200' 167 + : 'text-accent-500' 168 + ]} 169 + > 170 + <svg 171 + xmlns="http://www.w3.org/2000/svg" 172 + viewBox="0 0 24 24" 173 + fill="currentColor" 174 + class="size-5" 175 + > 176 + <path 177 + fill-rule="evenodd" 178 + 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" 179 + clip-rule="evenodd" 180 + /> 181 + </svg> 182 + </button> 183 + {/snippet} 184 + <ColorSelect 185 + selected={selectedColor} 186 + colors={colorsChoices} 187 + onselected={(color, previous) => { 188 + if (typeof previous === 'string' || typeof color === 'string') { 189 + return; 190 + } 191 + if (selectedCard) { 192 + selectedCard.color = color.label; 193 + } 194 + }} 195 + class="w-64" 196 + /> 197 + </Popover> 198 + {/if} 199 + 200 + <Popover bind:open={sizePopoverOpen}> 201 + {#snippet child({ props })} 202 + <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 203 + <svg 204 + xmlns="http://www.w3.org/2000/svg" 205 + fill="none" 206 + viewBox="0 0 24 24" 207 + stroke-width="1.5" 208 + stroke="currentColor" 209 + class="size-5" 210 + > 211 + <path 212 + stroke-linecap="round" 213 + stroke-linejoin="round" 214 + 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" 215 + /> 216 + </svg> 217 + </button> 218 + {/snippet} 219 + <div class="flex items-center gap-1"> 220 + {#if canSetSize(2, 2)} 221 + <button 222 + onclick={() => onsetsize?.(4, 4)} 223 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 224 + > 225 + <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 226 + <span class="sr-only">set size to 1x1</span> 227 + </button> 228 + {/if} 229 + {#if canSetSize(4, 2)} 230 + <button 231 + onclick={() => onsetsize?.(8, 4)} 232 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 233 + > 234 + <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 235 + <span class="sr-only">set size to 2x1</span> 236 + </button> 237 + {/if} 238 + {#if canSetSize(2, 4)} 239 + <button 240 + onclick={() => onsetsize?.(4, 8)} 241 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 242 + > 243 + <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 244 + <span class="sr-only">set size to 1x2</span> 245 + </button> 246 + {/if} 247 + {#if canSetSize(4, 4)} 248 + <button 249 + onclick={() => onsetsize?.(8, 8)} 250 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 251 + > 252 + <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 253 + <span class="sr-only">set size to 2x2</span> 254 + </button> 255 + {/if} 256 + </div> 257 + </Popover> 258 + 259 + {#if cardDef?.settingsComponent && selectedCard} 260 + <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 261 + {#snippet child({ props })} 262 + <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 263 + <svg 264 + xmlns="http://www.w3.org/2000/svg" 265 + fill="none" 266 + viewBox="0 0 24 24" 267 + stroke-width="2" 268 + stroke="currentColor" 269 + class="size-5" 270 + > 271 + <path 272 + stroke-linecap="round" 273 + stroke-linejoin="round" 274 + 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" 275 + /> 276 + <path 277 + stroke-linecap="round" 278 + stroke-linejoin="round" 279 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 280 + /> 281 + </svg> 282 + </button> 283 + {/snippet} 284 + <cardDef.settingsComponent 285 + bind:item={selectedCard} 286 + onclose={() => { 287 + settingsPopoverOpen = false; 288 + }} 289 + /> 290 + </Popover> 291 + {/if} 292 + </div> 293 + <div class="flex items-center gap-1"> 294 + <Button 295 + size="iconLg" 296 + variant="ghost" 297 + class="text-rose-500 backdrop-blur-none" 298 + onclick={() => ondelete?.()} 299 > 300 + <svg 301 + xmlns="http://www.w3.org/2000/svg" 302 + fill="none" 303 + viewBox="0 0 24 24" 304 + stroke-width="1.5" 305 + stroke="currentColor" 306 + class="size-5" 307 + > 308 + <path 309 + stroke-linecap="round" 310 + stroke-linejoin="round" 311 + 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" 312 + /> 313 + </svg> 314 + </Button> 315 + <Button 316 + size="iconLg" 317 + variant="ghost" 318 + class="backdrop-blur-none" 319 + onclick={() => ondeselect?.()} 320 + > 321 + <svg 322 + xmlns="http://www.w3.org/2000/svg" 323 + fill="none" 324 + viewBox="0 0 24 24" 325 + stroke-width="2" 326 + stroke="currentColor" 327 + class="size-5" 328 + > 329 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 330 + </svg> 331 + </Button> 332 + </div> 333 + {:else} 334 + <!-- Normal add-card controls --> 335 + <div class="flex items-center gap-2"> 336 + <Button 337 + size="iconLg" 338 + variant="ghost" 339 + class="backdrop-blur-none" 340 + onclick={() => { 341 + newCard('section'); 342 + }} 343 + > 344 + <svg 345 + xmlns="http://www.w3.org/2000/svg" 346 + viewBox="0 0 24 24" 347 + fill="none" 348 + stroke="currentColor" 349 + stroke-width="2" 350 + stroke-linecap="round" 351 + stroke-linejoin="round" 352 + ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 353 + > 354 + </Button> 355 + 356 + <Button 357 + size="iconLg" 358 + variant="ghost" 359 + class="backdrop-blur-none" 360 + onclick={() => { 361 + newCard('text'); 362 + }} 363 + > 364 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 365 + ><path 366 + fill="none" 367 + stroke="currentColor" 368 + stroke-linecap="round" 369 + stroke-linejoin="round" 370 + stroke-width="2" 371 + 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" 372 + /></svg 373 + > 374 + </Button> 375 + 376 + <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 377 + {#snippet child({ props })} 378 + <Button 379 + size="iconLg" 380 + variant="ghost" 381 + class="backdrop-blur-none" 382 + onclick={() => { 383 + newCard('link'); 384 + }} 385 + {...props} 386 + > 387 + <svg 388 + xmlns="http://www.w3.org/2000/svg" 389 + fill="none" 390 + viewBox="-2 -2 28 28" 391 + stroke-width="2" 392 + stroke="currentColor" 393 + > 394 + <path 395 + stroke-linecap="round" 396 + stroke-linejoin="round" 397 + 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" 398 + /> 399 + </svg> 400 + </Button> 401 + {/snippet} 402 + <Input 403 + spellcheck={false} 404 + type="url" 405 + bind:value={linkValue} 406 + onkeydown={(event) => { 407 + if (event.code === 'Enter') { 408 + addLink(linkValue); 409 + event.preventDefault(); 410 + } 411 + }} 412 + placeholder="Enter link" 413 + /> 414 + <Button onclick={() => addLink(linkValue)} size="icon" 415 + ><svg 416 + xmlns="http://www.w3.org/2000/svg" 417 + fill="none" 418 + viewBox="0 0 24 24" 419 + stroke-width="2" 420 + stroke="currentColor" 421 + class="size-6" 422 + > 423 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 424 + </svg> 425 + </Button> 426 + </Popover> 427 + 428 + <Button 429 + size="iconLg" 430 + variant="ghost" 431 + class="backdrop-blur-none" 432 + onclick={() => { 433 + imageInputRef?.click(); 434 + }} 435 + > 436 + <svg 437 + xmlns="http://www.w3.org/2000/svg" 438 + fill="none" 439 + viewBox="0 0 24 24" 440 + stroke-width="2" 441 + stroke="currentColor" 442 + > 443 + <path 444 + stroke-linecap="round" 445 + stroke-linejoin="round" 446 + 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" 447 + /> 448 + </svg> 449 + </Button> 450 + 451 + <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 452 + <svg 453 + xmlns="http://www.w3.org/2000/svg" 454 + fill="none" 455 + viewBox="0 0 24 24" 456 + stroke-width="1.5" 457 + stroke="currentColor" 458 + > 459 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 460 + </svg> 461 + </Button> 462 + </div> 463 + {/if} 464 + <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 465 <Toggle 466 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 467 bind:pressed={showingMobileView}
+253 -57
src/lib/website/EditableWebsite.svelte
··· 26 import { tick, type Component } from 'svelte'; 27 import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 28 import { dev } from '$app/environment'; 29 - import { setIsMobile } from './context'; 30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 31 import Context from './Context.svelte'; 32 import Head from './Head.svelte'; ··· 39 import { launchConfetti } from '@foxui/visual'; 40 import Controls from './Controls.svelte'; 41 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 42 43 let { 44 data ··· 48 49 // Check if floating login button will be visible (to hide MadeWithBlento) 50 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 51 - 52 - let accentColor = $derived(data.publication?.preferences?.accentColor ?? 'pink'); 53 - let baseColor = $derived(data.publication?.preferences?.baseColor ?? 'stone'); 54 55 function updateTheme(newAccent: string, newBase: string) { 56 data.publication.preferences ??= {}; ··· 125 126 setIsMobile(() => isMobile); 127 128 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 129 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 130 ··· 141 } 142 143 function newCard(type: string = 'link', cardData?: any) { 144 // close sidebar if open 145 const popover = document.getElementById('mobile-menu'); 146 if (popover) { ··· 179 compactItems(items, false); 180 compactItems(items, true); 181 182 newItem = {}; 183 184 await tick(); ··· 203 await checkAndUploadImage(data.publication, 'icon'); 204 } 205 206 await savePage(data, items, publication); 207 208 publication = JSON.stringify(data.publication); ··· 230 231 let debugPoint = $state({ x: 0, y: 0 }); 232 233 - function getDragXY( 234 - e: DragEvent & { 235 - currentTarget: EventTarget & HTMLDivElement; 236 - } 237 ): 238 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 239 | undefined { 240 if (!container || !activeDragElement.item) return; 241 242 // 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; 245 246 const rect = container.getBoundingClientRect(); 247 const currentMargin = isMobile ? mobileMargin : margin; ··· 363 return { x: gridX, y: gridY, swapWithId, placement }; 364 } 365 366 function addLink(url: string, specificCardDef?: CardDefinition) { 367 let link = validateLink(url); 368 if (!link) { ··· 492 compactItems(items, false); 493 compactItems(items, true); 494 } 495 496 await tick(); 497 ··· 627 fixCollisions(items, item, true, true); 628 compactItems(items, false); 629 compactItems(items, true); 630 631 await tick(); 632 ··· 647 target.value = ''; 648 } 649 650 - // $inspect(items); 651 - 652 - let showCardCommand = $state(true); 653 </script> 654 655 <svelte:body ··· 761 ]} 762 > 763 <div class="pointer-events-none"></div> 764 - <!-- svelte-ignore a11y_no_static_element_interactions --> 765 <div 766 bind:this={container} 767 ondragover={(e) => { 768 e.preventDefault(); 769 ··· 838 }} 839 ondragend={async (e) => { 840 e.preventDefault(); 841 - const cell = getDragXY(e); 842 - if (!cell) return; 843 - 844 - if (activeDragElement.item) { 845 - if (isMobile) { 846 - activeDragElement.item.mobileX = cell.x; 847 - activeDragElement.item.mobileY = cell.y; 848 - } else { 849 - activeDragElement.item.x = cell.x; 850 - activeDragElement.item.y = cell.y; 851 - } 852 - 853 - // Fix collisions and compact items after drag ends 854 - fixCollisions(items, activeDragElement.item, isMobile); 855 - } 856 activeDragElement.x = -1; 857 activeDragElement.y = -1; 858 activeDragElement.element = null; ··· 874 items = items.filter((it) => it !== item); 875 compactItems(items, false); 876 compactItems(items, true); 877 }} 878 onsetsize={(newW: number, newH: number) => { 879 if (isMobile) { ··· 885 } 886 887 fixCollisions(items, item, isMobile); 888 }} 889 ondragstart={(e: DragEvent) => { 890 const target = e.currentTarget as HTMLDivElement; ··· 892 activeDragElement.w = item.w; 893 activeDragElement.h = item.h; 894 activeDragElement.item = item; 895 896 // Store original positions of all items 897 activeDragElement.originalPositions = new Map(); ··· 919 </div> 920 </div> 921 922 - <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 923 - <div class="flex flex-col gap-2"> 924 - {#each sidebarItems as cardDef (cardDef.type)} 925 - <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 926 - >{cardDef.sidebarButtonText}</Button 927 - > 928 - {/each} 929 - </div> 930 - </Sidebar> 931 - 932 - <input 933 - type="file" 934 - accept="image/*" 935 - onchange={handleImageInputChange} 936 - class="hidden" 937 - id="image-input" 938 - multiple 939 - /> 940 - 941 - <input 942 - type="file" 943 - accept="video/*" 944 - onchange={handleVideoInputChange} 945 - class="hidden" 946 - id="video-input" 947 - multiple 948 - /> 949 - 950 <EditBar 951 {data} 952 bind:isSaving 953 bind:showingMobileView 954 {hasUnsavedChanges} 955 {save} 956 showCardCommand={() => { 957 showCardCommand = true; 958 }} 959 /> 960 961 <Toaster /> 962 963 <FloatingEditButton {data} /> 964 </Context>
··· 26 import { tick, type Component } from 'svelte'; 27 import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 28 import { dev } from '$app/environment'; 29 + import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; 30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 31 import Context from './Context.svelte'; 32 import Head from './Head.svelte'; ··· 39 import { launchConfetti } from '@foxui/visual'; 40 import Controls from './Controls.svelte'; 41 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 42 + import { shouldMirror, mirrorLayout } from './layout-mirror'; 43 44 let { 45 data ··· 49 50 // Check if floating login button will be visible (to hide MadeWithBlento) 51 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 52 53 function updateTheme(newAccent: string, newBase: string) { 54 data.publication.preferences ??= {}; ··· 123 124 setIsMobile(() => isMobile); 125 126 + // svelte-ignore state_referenced_locally 127 + let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 128 + 129 + function onLayoutChanged() { 130 + // Set the bit for the current layout: desktop=1, mobile=2 131 + editedOn = editedOn | (isMobile ? 2 : 1); 132 + if (shouldMirror(editedOn)) { 133 + mirrorLayout(items, isMobile); 134 + } 135 + } 136 + 137 + const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; 138 + setIsCoarse(() => isCoarse); 139 + 140 + let selectedCardId: string | null = $state(null); 141 + let selectedCard = $derived( 142 + selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null 143 + ); 144 + 145 + setSelectedCardId(() => selectedCardId); 146 + setSelectCard((id: string | null) => { 147 + selectedCardId = id; 148 + }); 149 + 150 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 151 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 152 ··· 163 } 164 165 function newCard(type: string = 'link', cardData?: any) { 166 + selectedCardId = null; 167 + 168 // close sidebar if open 169 const popover = document.getElementById('mobile-menu'); 170 if (popover) { ··· 203 compactItems(items, false); 204 compactItems(items, true); 205 206 + onLayoutChanged(); 207 + 208 newItem = {}; 209 210 await tick(); ··· 229 await checkAndUploadImage(data.publication, 'icon'); 230 } 231 232 + // Persist layout editing state 233 + data.publication.preferences ??= {}; 234 + data.publication.preferences.editedOn = editedOn; 235 + 236 await savePage(data, items, publication); 237 238 publication = JSON.stringify(data.publication); ··· 260 261 let debugPoint = $state({ x: 0, y: 0 }); 262 263 + function getGridPosition( 264 + clientX: number, 265 + clientY: number 266 ): 267 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 268 | undefined { 269 if (!container || !activeDragElement.item) return; 270 271 // x, y represent the top-left corner of the dragged card 272 + const x = clientX + activeDragElement.mouseDeltaX; 273 + const y = clientY + activeDragElement.mouseDeltaY; 274 275 const rect = container.getBoundingClientRect(); 276 const currentMargin = isMobile ? mobileMargin : margin; ··· 392 return { x: gridX, y: gridY, swapWithId, placement }; 393 } 394 395 + function getDragXY( 396 + e: DragEvent & { 397 + currentTarget: EventTarget & HTMLDivElement; 398 + } 399 + ) { 400 + return getGridPosition(e.clientX, e.clientY); 401 + } 402 + 403 + // Touch drag system (instant drag on selected card) 404 + let touchDragActive = $state(false); 405 + 406 + function touchStart(e: TouchEvent) { 407 + if (!selectedCardId || !container) return; 408 + const touch = e.touches[0]; 409 + if (!touch) return; 410 + 411 + // Check if the touch is on the selected card element 412 + const target = (e.target as HTMLElement)?.closest?.('.card'); 413 + if (!target || target.id !== selectedCardId) return; 414 + 415 + const item = items.find((i) => i.id === selectedCardId); 416 + if (!item || item.cardData?.locked) return; 417 + 418 + // Start dragging immediately 419 + touchDragActive = true; 420 + 421 + const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; 422 + if (!cardEl) return; 423 + 424 + activeDragElement.element = cardEl; 425 + activeDragElement.w = item.w; 426 + activeDragElement.h = item.h; 427 + activeDragElement.item = item; 428 + 429 + // Store original positions of all items 430 + activeDragElement.originalPositions = new Map(); 431 + for (const it of items) { 432 + activeDragElement.originalPositions.set(it.id, { 433 + x: it.x, 434 + y: it.y, 435 + mobileX: it.mobileX, 436 + mobileY: it.mobileY 437 + }); 438 + } 439 + 440 + const rect = cardEl.getBoundingClientRect(); 441 + activeDragElement.mouseDeltaX = rect.left - touch.clientX; 442 + activeDragElement.mouseDeltaY = rect.top - touch.clientY; 443 + } 444 + 445 + function touchMove(e: TouchEvent) { 446 + if (!touchDragActive) return; 447 + 448 + const touch = e.touches[0]; 449 + if (!touch) return; 450 + 451 + e.preventDefault(); 452 + 453 + const result = getGridPosition(touch.clientX, touch.clientY); 454 + if (!result || !activeDragElement.item) return; 455 + 456 + const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 457 + 458 + // Reset all items to original positions first 459 + for (const it of items) { 460 + const origPos = activeDragElement.originalPositions.get(it.id); 461 + if (origPos && it !== activeDragElement.item) { 462 + if (isMobile) { 463 + it.mobileX = origPos.mobileX; 464 + it.mobileY = origPos.mobileY; 465 + } else { 466 + it.x = origPos.x; 467 + it.y = origPos.y; 468 + } 469 + } 470 + } 471 + 472 + // Update dragged item position 473 + if (isMobile) { 474 + activeDragElement.item.mobileX = result.x; 475 + activeDragElement.item.mobileY = result.y; 476 + } else { 477 + activeDragElement.item.x = result.x; 478 + activeDragElement.item.y = result.y; 479 + } 480 + 481 + // Handle horizontal swap 482 + if (result.swapWithId && draggedOrigPos) { 483 + const swapTarget = items.find((it) => it.id === result.swapWithId); 484 + if (swapTarget) { 485 + if (isMobile) { 486 + swapTarget.mobileX = draggedOrigPos.mobileX; 487 + swapTarget.mobileY = draggedOrigPos.mobileY; 488 + } else { 489 + swapTarget.x = draggedOrigPos.x; 490 + swapTarget.y = draggedOrigPos.y; 491 + } 492 + } 493 + } 494 + 495 + fixCollisions(items, activeDragElement.item, isMobile); 496 + 497 + // Auto-scroll near edges 498 + const scrollZone = 100; 499 + const scrollSpeed = 10; 500 + const viewportHeight = window.innerHeight; 501 + 502 + if (touch.clientY < scrollZone) { 503 + const intensity = 1 - touch.clientY / scrollZone; 504 + window.scrollBy(0, -scrollSpeed * intensity); 505 + } else if (touch.clientY > viewportHeight - scrollZone) { 506 + const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; 507 + window.scrollBy(0, scrollSpeed * intensity); 508 + } 509 + } 510 + 511 + function touchEnd() { 512 + if (touchDragActive && activeDragElement.item) { 513 + // Finalize position 514 + fixCollisions(items, activeDragElement.item, isMobile); 515 + onLayoutChanged(); 516 + 517 + activeDragElement.x = -1; 518 + activeDragElement.y = -1; 519 + activeDragElement.element = null; 520 + activeDragElement.item = null; 521 + activeDragElement.lastTargetId = null; 522 + activeDragElement.lastPlacement = null; 523 + } 524 + 525 + touchDragActive = false; 526 + } 527 + 528 + // Only register non-passive touchmove when actively dragging 529 + $effect(() => { 530 + const el = container; 531 + if (!touchDragActive || !el) return; 532 + 533 + el.addEventListener('touchmove', touchMove, { passive: false }); 534 + return () => { 535 + el.removeEventListener('touchmove', touchMove); 536 + }; 537 + }); 538 + 539 + let linkValue = $state(''); 540 + 541 function addLink(url: string, specificCardDef?: CardDefinition) { 542 let link = validateLink(url); 543 if (!link) { ··· 667 compactItems(items, false); 668 compactItems(items, true); 669 } 670 + 671 + onLayoutChanged(); 672 673 await tick(); 674 ··· 804 fixCollisions(items, item, true, true); 805 compactItems(items, false); 806 compactItems(items, true); 807 + 808 + onLayoutChanged(); 809 810 await tick(); 811 ··· 826 target.value = ''; 827 } 828 829 + let showCardCommand = $state(false); 830 </script> 831 832 <svelte:body ··· 938 ]} 939 > 940 <div class="pointer-events-none"></div> 941 + <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 942 + <!-- svelte-ignore a11y_click_events_have_key_events --> 943 <div 944 bind:this={container} 945 + onclick={(e) => { 946 + // Deselect when tapping empty grid space 947 + if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 948 + selectedCardId = null; 949 + } 950 + }} 951 + ontouchstart={touchStart} 952 + ontouchend={touchEnd} 953 ondragover={(e) => { 954 e.preventDefault(); 955 ··· 1024 }} 1025 ondragend={async (e) => { 1026 e.preventDefault(); 1027 + // safari fix 1028 activeDragElement.x = -1; 1029 activeDragElement.y = -1; 1030 activeDragElement.element = null; ··· 1046 items = items.filter((it) => it !== item); 1047 compactItems(items, false); 1048 compactItems(items, true); 1049 + onLayoutChanged(); 1050 }} 1051 onsetsize={(newW: number, newH: number) => { 1052 if (isMobile) { ··· 1058 } 1059 1060 fixCollisions(items, item, isMobile); 1061 + onLayoutChanged(); 1062 }} 1063 ondragstart={(e: DragEvent) => { 1064 const target = e.currentTarget as HTMLDivElement; ··· 1066 activeDragElement.w = item.w; 1067 activeDragElement.h = item.h; 1068 activeDragElement.item = item; 1069 + // fix for div shadow during drag and drop 1070 + const transparent = document.createElement('div'); 1071 + transparent.style.position = 'fixed'; 1072 + transparent.style.top = '-1000px'; 1073 + transparent.style.width = '1px'; 1074 + transparent.style.height = '1px'; 1075 + document.body.appendChild(transparent); 1076 + e.dataTransfer?.setDragImage(transparent, 0, 0); 1077 + requestAnimationFrame(() => transparent.remove()); 1078 1079 // Store original positions of all items 1080 activeDragElement.originalPositions = new Map(); ··· 1102 </div> 1103 </div> 1104 1105 <EditBar 1106 {data} 1107 + bind:linkValue 1108 bind:isSaving 1109 bind:showingMobileView 1110 {hasUnsavedChanges} 1111 + {newCard} 1112 + {addLink} 1113 {save} 1114 + {handleImageInputChange} 1115 + {handleVideoInputChange} 1116 showCardCommand={() => { 1117 showCardCommand = true; 1118 }} 1119 + {selectedCard} 1120 + {isMobile} 1121 + {isCoarse} 1122 + ondeselect={() => { 1123 + selectedCardId = null; 1124 + }} 1125 + ondelete={() => { 1126 + if (selectedCard) { 1127 + items = items.filter((it) => it.id !== selectedCardId); 1128 + compactItems(items, false); 1129 + compactItems(items, true); 1130 + onLayoutChanged(); 1131 + selectedCardId = null; 1132 + } 1133 + }} 1134 + onsetsize={(w: number, h: number) => { 1135 + if (selectedCard) { 1136 + if (isMobile) { 1137 + selectedCard.mobileW = w; 1138 + selectedCard.mobileH = h; 1139 + } else { 1140 + selectedCard.w = w; 1141 + selectedCard.h = h; 1142 + } 1143 + fixCollisions(items, selectedCard, isMobile); 1144 + onLayoutChanged(); 1145 + } 1146 + }} 1147 /> 1148 1149 <Toaster /> 1150 1151 <FloatingEditButton {data} /> 1152 + 1153 + {#if dev} 1154 + <div 1155 + class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 rounded px-2 py-1 font-mono text-xs" 1156 + > 1157 + editedOn: {editedOn} 1158 + </div> 1159 + {/if} 1160 </Context>
+3
src/lib/website/context.ts
··· 7 export const [getCanEdit, setCanEdit] = createContext<() => boolean>(); 8 export const [getAdditionalUserData, setAdditionalUserData] = 9 createContext<Record<string, unknown>>();
··· 7 export const [getCanEdit, setCanEdit] = createContext<() => boolean>(); 8 export const [getAdditionalUserData, setAdditionalUserData] = 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>();
+73
src/lib/website/layout-mirror.ts
···
··· 1 + import { COLUMNS } from '$lib'; 2 + import { CardDefinitionsByType } from '$lib/cards'; 3 + import { clamp, fixAllCollisions } from '$lib/helper'; 4 + import type { Item } from '$lib/types'; 5 + 6 + /** 7 + * Returns true when mirroring should still happen (i.e. user hasn't edited both layouts). 8 + * editedOn: 0/undefined = never, 1 = desktop only, 2 = mobile only, 3 = both 9 + */ 10 + export function shouldMirror(editedOn: number | undefined): boolean { 11 + return (editedOn ?? 0) !== 3; 12 + } 13 + 14 + /** Snap a value to the nearest even integer (min 2). */ 15 + function snapEven(v: number): number { 16 + return Math.max(2, Math.round(v / 2) * 2); 17 + } 18 + 19 + /** 20 + * Compute the other layout's size for a single item, preserving aspect ratio. 21 + * Clamps to the card definition's minW/maxW/minH/maxH if defined. 22 + * Mutates the item in-place. 23 + */ 24 + export function mirrorItemSize(item: Item, fromMobile: boolean): void { 25 + const def = CardDefinitionsByType[item.cardType]; 26 + const minW = def?.minW ?? 2; 27 + const maxW = def?.maxW ?? COLUMNS; 28 + const minH = def?.minH ?? 2; 29 + const maxH = def?.maxH ?? Infinity; 30 + 31 + if (fromMobile) { 32 + const srcW = item.mobileW; 33 + const srcH = item.mobileH; 34 + // Full-width cards stay full-width 35 + item.w = srcW >= COLUMNS ? COLUMNS : clamp(snapEven(srcW / 2), minW, maxW); 36 + item.h = clamp(snapEven((srcH * item.w) / srcW), minH, maxH); 37 + } else { 38 + const srcW = item.w; 39 + const srcH = item.h; 40 + // Full-width cards stay full-width 41 + if (srcW >= COLUMNS) { 42 + item.mobileW = clamp(COLUMNS, minW, Math.min(maxW, COLUMNS)); 43 + } else { 44 + const scaleFactor = Math.min(2, COLUMNS / srcW); 45 + item.mobileW = clamp(snapEven(srcW * scaleFactor), minW, Math.min(maxW, COLUMNS)); 46 + } 47 + item.mobileH = clamp(snapEven((srcH * item.mobileW) / srcW), minH, maxH); 48 + } 49 + } 50 + 51 + /** 52 + * Mirror the full layout from one view to the other. 53 + * Copies sizes proportionally and maps positions, then resolves collisions. 54 + * Mutates items in-place. 55 + */ 56 + export function mirrorLayout(items: Item[], fromMobile: boolean): void { 57 + for (const item of items) { 58 + mirrorItemSize(item, fromMobile); 59 + 60 + if (fromMobile) { 61 + // Mobile → Desktop positions 62 + item.x = clamp(Math.floor(item.mobileX / 2 / 2) * 2, 0, COLUMNS - item.w); 63 + item.y = Math.max(0, Math.round(item.mobileY / 2)); 64 + } else { 65 + // Desktop → Mobile positions 66 + item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 67 + item.mobileY = Math.max(0, Math.round(item.y * 2)); 68 + } 69 + } 70 + 71 + // Resolve collisions on the target layout 72 + fixAllCollisions(items, !fromMobile); 73 + }
+1
wrangler.jsonc
··· 44 "id": "d6ff203259de48538d332b0a5df258a7" 45 } 46 ] 47 /** 48 * Service Bindings (communicate between multiple Workers) 49 * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
··· 44 "id": "d6ff203259de48538d332b0a5df258a7" 45 } 46 ] 47 + 48 /** 49 * Service Bindings (communicate between multiple Workers) 50 * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings