your personal website on atproto - mirror blento.app

Merge pull request #113 from flo-bit/mobile-editing

Mobile editing

authored by

Florian and committed by
GitHub
1b021a9c 21079b5f

+1073 -230
-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
+4
src/app.css
··· 3 @plugin '@tailwindcss/forms'; 4 @plugin '@tailwindcss/typography'; 5 6 @source '../node_modules/@foxui'; 7 8 @custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
··· 3 @plugin '@tailwindcss/forms'; 4 @plugin '@tailwindcss/typography'; 5 6 + 7 + @plugin "tailwindcss-animate"; 8 + 9 + 10 @source '../node_modules/@foxui'; 11 12 @custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
+5 -1
src/lib/cards/ATProtoCollectionsCard/index.ts
··· 19 item.w = 4; 20 item.mobileW = 8; 21 }, 22 - sidebarButtonText: 'Atmosphere Collections' 23 } as CardDefinition & { type: 'atprotocollections' };
··· 19 item.w = 4; 20 item.mobileW = 8; 21 }, 22 + sidebarButtonText: 'Atmosphere Collections', 23 + 24 + name: 'ATProto Collections', 25 + 26 + groups: ['Social'] 27 } 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"
+3 -1
src/lib/cards/BigSocialCard/index.ts
··· 51 return item; 52 }, 53 urlHandlerPriority: 1, 54 - canHaveLabel: true 55 } as CardDefinition & { type: 'bigsocial' }; 56 57 import {
··· 51 return item; 52 }, 53 urlHandlerPriority: 1, 54 + canHaveLabel: true, 55 + 56 + groups: ['Social'] 57 } as CardDefinition & { type: 'bigsocial' }; 58 59 import {
+5 -1
src/lib/cards/BlueskyMediaCard/index.ts
··· 8 createNew: () => {}, 9 creationModalComponent: CreateBlueskyMediaCardModal, 10 sidebarButtonText: 'Bluesky Media', 11 - canHaveLabel: true 12 } as CardDefinition & { type: 'blueskyMedia' };
··· 8 createNew: () => {}, 9 creationModalComponent: CreateBlueskyMediaCardModal, 10 sidebarButtonText: 'Bluesky Media', 11 + canHaveLabel: true, 12 + 13 + groups: ['Media'], 14 + 15 + name: 'Video/Image from Bluesky' 16 } as CardDefinition & { type: 'blueskyMedia' };
+3 -1
src/lib/cards/BlueskyPostCard/index.ts
··· 63 return postsMap; 64 }, 65 minW: 4, 66 - name: 'Bluesky Post' 67 } as CardDefinition & { type: 'blueskyPost' };
··· 63 return postsMap; 64 }, 65 minW: 4, 66 + name: 'Bluesky Post', 67 + 68 + groups: ['Social'] 69 } as CardDefinition & { type: 'blueskyPost' };
+4 -1
src/lib/cards/ButtonCard/index.ts
··· 27 minW: 2, 28 minH: 1, 29 maxW: 8, 30 - maxH: 4 31 };
··· 27 minW: 2, 28 minH: 1, 29 maxW: 8, 30 + maxH: 4, 31 + 32 + groups: ['Utilities'], 33 + name: 'Button' 34 };
+3 -1
src/lib/cards/DrawCard/index.ts
··· 23 strokeWidth: 1, 24 locked: true 25 }; 26 - } 27 } as CardDefinition & { type: 'draw' };
··· 23 strokeWidth: 1, 24 locked: true 25 }; 26 + }, 27 + 28 + groups: ['Visual'] 29 } as CardDefinition & { type: 'draw' };
+1 -1
src/lib/cards/EmbedCard/index.ts
··· 19 // change: (item) => { 20 // return item; 21 // }, 22 - name: 'Embed Card' 23 } as CardDefinition & { type: 'embed' };
··· 19 // change: (item) => { 20 // return item; 21 // }, 22 + name: 'Embed' 23 } as CardDefinition & { type: 'embed' };
+3 -1
src/lib/cards/EventCard/index.ts
··· 112 113 urlHandlerPriority: 5, 114 115 - name: 'Event Card' 116 } as CardDefinition & { type: 'event' };
··· 112 113 urlHandlerPriority: 5, 114 115 + name: 'Event', 116 + 117 + groups: ['Social'] 118 } as CardDefinition & { type: 'event' };
+4 -1
src/lib/cards/FluidTextCard/index.ts
··· 23 sidebarButtonText: 'Fluid Text', 24 defaultColor: 'transparent', 25 allowSetColor: true, 26 - minW: 2 27 } as CardDefinition & { type: 'fluid-text' };
··· 23 sidebarButtonText: 'Fluid Text', 24 defaultColor: 'transparent', 25 allowSetColor: true, 26 + minW: 2, 27 + 28 + groups: ['Visual'], 29 + name: 'Fluid Text' 30 } as CardDefinition & { type: 'fluid-text' };
+3 -1
src/lib/cards/GIFCard/index.ts
··· 45 return null; 46 }, 47 urlHandlerPriority: 5, 48 - name: 'GIF' 49 } as CardDefinition & { type: 'gif' };
··· 45 return null; 46 }, 47 urlHandlerPriority: 5, 48 + name: 'GIF', 49 + 50 + groups: ['Media'] 51 } as CardDefinition & { type: 'gif' };
+4 -1
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 14 card.mobileH = 6; 15 card.cardData = {}; 16 }, 17 - canHaveLabel: true 18 } as CardDefinition & { type: 'dino-game' };
··· 14 card.mobileH = 6; 15 card.cardData = {}; 16 }, 17 + canHaveLabel: true, 18 + 19 + groups: ['Games'], 20 + name: 'Dino Game' 21 } as CardDefinition & { type: 'dino-game' };
+5 -1
src/lib/cards/GameCards/TetrisCard/index.ts
··· 19 card.cardData = {}; 20 }, 21 maxH: 10, 22 - canHaveLabel: true 23 } as CardDefinition & { type: 'tetris' };
··· 19 card.cardData = {}; 20 }, 21 maxH: 10, 22 + canHaveLabel: true, 23 + 24 + groups: ['Games'], 25 + 26 + name: 'Tetris' 27 } as CardDefinition & { type: 'tetris' };
+3 -1
src/lib/cards/GitHubProfileCard/index.ts
··· 50 51 return item; 52 }, 53 - name: 'Github Profile' 54 } as CardDefinition & { type: 'githubProfile' }; 55 56 function getGitHubUsername(url: string | undefined): string | undefined {
··· 50 51 return item; 52 }, 53 + name: 'Github Profile', 54 + 55 + groups: ['Social'] 56 } as CardDefinition & { type: 'githubProfile' }; 57 58 function getGitHubUsername(url: string | undefined): string | undefined {
+2 -1
src/lib/cards/GuestbookCard/index.ts
··· 60 61 return results; 62 }, 63 - name: 'Guestbook' 64 } as CardDefinition & { type: 'guestbook' };
··· 60 61 return results; 62 }, 63 + name: 'Guestbook', 64 + groups: ['Social'] 65 } as CardDefinition & { type: 'guestbook' };
+19 -2
src/lib/cards/ImageCard/index.ts
··· 42 }, 43 urlHandlerPriority: 3, 44 45 - name: 'Image Card', 46 47 - canHaveLabel: true 48 } as CardDefinition & { type: 'image' };
··· 42 }, 43 urlHandlerPriority: 3, 44 45 + name: 'Image', 46 + 47 + canHaveLabel: true, 48 + 49 + groups: ['Core'], 50 51 + icon: `<svg 52 + xmlns="http://www.w3.org/2000/svg" 53 + fill="none" 54 + viewBox="0 0 24 24" 55 + stroke-width="2" 56 + stroke="currentColor" 57 + class="size-4" 58 + > 59 + <path 60 + stroke-linecap="round" 61 + stroke-linejoin="round" 62 + 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" 63 + /> 64 + </svg>` 65 } as CardDefinition & { type: 'image' };
+5 -1
src/lib/cards/LatestBlueskyPostCard/index.ts
··· 18 19 return JSON.parse(JSON.stringify(authorFeed)); 20 }, 21 - minW: 4 22 } as CardDefinition & { type: 'latestPost' };
··· 18 19 return JSON.parse(JSON.stringify(authorFeed)); 20 }, 21 + minW: 4, 22 + 23 + name: 'Latest Bluesky Post', 24 + 25 + groups: ['Social'] 26 } as CardDefinition & { type: 'latestPost' };
+44
src/lib/cards/LinkCard/CreateLinkCardModal.svelte
···
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { validateLink } from '$lib/helper'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let isFetchingLocation = $state(false); 9 + 10 + let errorMessage = $state(''); 11 + </script> 12 + 13 + <Modal open={true} closeButton={false}> 14 + <form 15 + onsubmit={() => { 16 + if (!item.cardData.href.trim()) return; 17 + 18 + let link = validateLink(item.cardData.href); 19 + if (!link) { 20 + errorMessage = 'Invalid link'; 21 + return; 22 + } 23 + 24 + item.cardData.href = link; 25 + item.cardData.domain = new URL(link).hostname; 26 + item.cardData.hasFetched = false; 27 + 28 + oncreate?.(); 29 + }} 30 + class="flex flex-col gap-2" 31 + > 32 + <Subheading>Enter a link</Subheading> 33 + <Input bind:value={item.cardData.href} class="mt-4" /> 34 + 35 + {#if errorMessage} 36 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 37 + {/if} 38 + 39 + <div class="mt-4 flex justify-end gap-2"> 40 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 41 + <Button type="submit" disabled={isFetchingLocation}>Create</Button> 42 + </div> 43 + </form> 44 + </Modal>
+22 -2
src/lib/cards/LinkCard/index.ts
··· 1 import { checkAndUploadImage, validateLink } from '$lib/helper'; 2 import type { CardDefinition } from '../types'; 3 import EditingLinkCard from './EditingLinkCard.svelte'; 4 import LinkCard from './LinkCard.svelte'; 5 import LinkCardSettings from './LinkCardSettings.svelte'; ··· 13 }, 14 settingsComponent: LinkCardSettings, 15 16 - name: 'Link Card', 17 canChange: (item) => Boolean(validateLink(item.cardData?.href)), 18 change: (item) => { 19 const href = validateLink(item.cardData?.href); ··· 36 await checkAndUploadImage(item.cardData, 'favicon'); 37 return item; 38 }, 39 - urlHandlerPriority: 0 40 } as CardDefinition & { type: 'link' };
··· 1 import { checkAndUploadImage, validateLink } from '$lib/helper'; 2 import type { CardDefinition } from '../types'; 3 + import CreateLinkCardModal from './CreateLinkCardModal.svelte'; 4 import EditingLinkCard from './EditingLinkCard.svelte'; 5 import LinkCard from './LinkCard.svelte'; 6 import LinkCardSettings from './LinkCardSettings.svelte'; ··· 14 }, 15 settingsComponent: LinkCardSettings, 16 17 + creationModalComponent: CreateLinkCardModal, 18 + 19 + name: 'Link', 20 canChange: (item) => Boolean(validateLink(item.cardData?.href)), 21 change: (item) => { 22 const href = validateLink(item.cardData?.href); ··· 39 await checkAndUploadImage(item.cardData, 'favicon'); 40 return item; 41 }, 42 + urlHandlerPriority: 0, 43 + 44 + groups: ['Core'], 45 + 46 + icon: `<svg 47 + xmlns="http://www.w3.org/2000/svg" 48 + fill="none" 49 + viewBox="-2 -2 28 28" 50 + stroke-width="2" 51 + stroke="currentColor" 52 + class="size-4" 53 + > 54 + <path 55 + stroke-linecap="round" 56 + stroke-linejoin="round" 57 + 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" 58 + /> 59 + </svg>` 60 } as CardDefinition & { type: 'link' };
+2 -1
src/lib/cards/LivestreamCard/index.ts
··· 81 82 urlHandlerPriority: 5, 83 84 - name: 'stream.place Card' 85 } as CardDefinition & { type: 'latestLivestream' }; 86 87 export const LivestreamEmbedCardDefitition = {
··· 81 82 urlHandlerPriority: 5, 83 84 + name: 'Latest Livestream (stream.place)', 85 + groups: ['Media'] 86 } as CardDefinition & { type: 'latestLivestream' }; 87 88 export const LivestreamEmbedCardDefitition = {
+10 -1
src/lib/cards/MapCard/index.ts
··· 17 creationModalComponent: CreateMapCardModal, 18 allowSetColor: false, 19 canHaveLabel: true, 20 - settingsComponent: MapCardSettings 21 } as CardDefinition & { type: 'mapLocation' }; 22 23 export function getZoomLevel(type: string | undefined): number {
··· 17 creationModalComponent: CreateMapCardModal, 18 allowSetColor: false, 19 canHaveLabel: true, 20 + settingsComponent: MapCardSettings, 21 + 22 + groups: ['Core'], 23 + 24 + name: 'Map', 25 + 26 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"> 27 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z" /> 28 + </svg> 29 + ` 30 } as CardDefinition & { type: 'mapLocation' }; 31 32 export function getZoomLevel(type: string | undefined): number {
+4 -1
src/lib/cards/PopfeedReviews/index.ts
··· 18 }, 19 minH: 3, 20 sidebarButtonText: 'Popfeed Reviews', 21 - canHaveLabel: true 22 } as CardDefinition & { type: 'recentPopfeedReviews' };
··· 18 }, 19 minH: 3, 20 sidebarButtonText: 'Popfeed Reviews', 21 + canHaveLabel: true, 22 + 23 + groups: ['Media'], 24 + name: 'Movie and TV Reviews' 25 } as CardDefinition & { type: 'recentPopfeedReviews' };
+16 -1
src/lib/cards/SectionCard/index.ts
··· 26 defaultColor: 'transparent', 27 maxH: 1, 28 canResize: false, 29 - settingsComponent: SectionCardSettings 30 } as CardDefinition & { type: 'section' }; 31 32 export const textAlignClasses: Record<string, string> = {
··· 26 defaultColor: 'transparent', 27 maxH: 1, 28 canResize: false, 29 + settingsComponent: SectionCardSettings, 30 + 31 + name: 'Heading', 32 + groups: ['Core'], 33 + 34 + icon: `<svg 35 + xmlns="http://www.w3.org/2000/svg" 36 + viewBox="0 0 24 24" 37 + fill="none" 38 + stroke="currentColor" 39 + stroke-width="2" 40 + stroke-linecap="round" 41 + stroke-linejoin="round" 42 + class="size-4" 43 + ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 44 + >` 45 } as CardDefinition & { type: 'section' }; 46 47 export const textAlignClasses: Record<string, string> = {
+3 -1
src/lib/cards/SpotifyCard/index.ts
··· 40 name: 'Spotify Embed', 41 canResize: true, 42 minW: 4, 43 - minH: 5 44 } as CardDefinition & { type: typeof cardType }; 45 46 // Match Spotify album and playlist URLs
··· 40 name: 'Spotify Embed', 41 canResize: true, 42 minW: 4, 43 + minH: 5, 44 + 45 + groups: ['Media'] 46 } as CardDefinition & { type: typeof cardType }; 47 48 // Match Spotify album and playlist URLs
+5 -1
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 42 return records; 43 }, 44 45 - sidebarButtonText: 'site.standard.document list' 46 } as CardDefinition & { type: 'site.standard.document list' };
··· 42 return records; 43 }, 44 45 + sidebarButtonText: 'site.standard.document list', 46 + 47 + name: 'Blog Posts', 48 + 49 + groups: ['Content'] 50 } as CardDefinition & { type: 'site.standard.document list' };
+4 -1
src/lib/cards/StatusphereCard/index.ts
··· 47 item.cardData.label = item.cardData.title; 48 } 49 }, 50 - canHaveLabel: true 51 } as CardDefinition & { type: 'statusphere' }; 52 53 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
··· 47 item.cardData.label = item.cardData.title; 48 } 49 }, 50 + canHaveLabel: true, 51 + 52 + name: 'Emoji', 53 + groups: ['Media'] 54 } as CardDefinition & { type: 'statusphere' }; 55 56 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+5 -1
src/lib/cards/TealFMPlaysCard/index.ts
··· 22 }, 23 minW: 4, 24 sidebarButtonText: 'teal.fm Plays', 25 - canHaveLabel: true 26 } as CardDefinition & { type: 'recentTealFMPlays' };
··· 22 }, 23 minW: 4, 24 sidebarButtonText: 'teal.fm Plays', 25 + canHaveLabel: true, 26 + 27 + name: 'Teal.fm Plays', 28 + 29 + groups: ['Media'] 30 } as CardDefinition & { type: 'recentTealFMPlays' };
+16 -1
src/lib/cards/TextCard/index.ts
··· 14 }; 15 }, 16 17 - settingsComponent: TextCardSettings 18 } as CardDefinition & { type: 'text' }; 19 20 export const textAlignClasses: Record<string, string> = {
··· 14 }; 15 }, 16 17 + settingsComponent: TextCardSettings, 18 + 19 + name: 'Text', 20 + 21 + groups: ['Core'], 22 + 23 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" 24 + ><path 25 + fill="none" 26 + stroke="currentColor" 27 + stroke-linecap="round" 28 + stroke-linejoin="round" 29 + stroke-width="2" 30 + 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" 31 + /></svg 32 + >` 33 } as CardDefinition & { type: 'text' }; 34 35 export const textAlignClasses: Record<string, string> = {
+2 -1
src/lib/cards/TimerCard/index.ts
··· 33 allowSetColor: true, 34 name: 'Timer Card', 35 minW: 4, 36 - canHaveLabel: true 37 } as CardDefinition & { type: 'timer' };
··· 33 allowSetColor: true, 34 name: 'Timer Card', 35 minW: 4, 36 + canHaveLabel: true, 37 + groups: ['Utilities'] 38 } as CardDefinition & { type: 'timer' };
+2 -1
src/lib/cards/VCardCard/index.ts
··· 122 123 sidebarButtonText: 'vCard', 124 allowSetColor: true, 125 - name: 'vCard Card' 126 } as CardDefinition & { type: 'vcard' };
··· 122 123 sidebarButtonText: 'vCard', 124 allowSetColor: true, 125 + name: 'vCard Card', 126 + groups: ['Social'] 127 } as CardDefinition & { type: 'vcard' };
+10 -1
src/lib/cards/YoutubeVideoCard/index.ts
··· 51 52 return item; 53 }, 54 - name: 'Youtube Video' 55 } as CardDefinition & { type: 'youtubeVideo' }; 56 57 // Thanks to eleventy-plugin-youtube-embed
··· 51 52 return item; 53 }, 54 + name: 'Youtube Video', 55 + 56 + groups: ['Media'], 57 + 58 + icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-3" viewBox="0 0 256 180" 59 + ><path 60 + fill="currentColor" 61 + d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134" 62 + /><path fill="currentColor" class="invert" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg 63 + >` 64 } as CardDefinition & { type: 'youtubeVideo' }; 65 66 // Thanks to eleventy-plugin-youtube-embed
+6
src/lib/cards/types.ts
··· 73 canHaveLabel?: boolean; 74 75 migrate?: (item: Item) => void; 76 };
··· 73 canHaveLabel?: boolean; 74 75 migrate?: (item: Item) => void; 76 + 77 + groups?: string[]; 78 + 79 + keywords?: string[]; 80 + 81 + icon?: string; 82 };
+102
src/lib/components/card-command/CardCommand.svelte
···
··· 1 + <script lang="ts"> 2 + import { AllCardDefinitions } from '$lib/cards'; 3 + import type { CardDefinition } from '$lib/cards/types'; 4 + import { Command, Dialog } from 'bits-ui'; 5 + 6 + const CardDefGroups = [ 7 + 'Core', 8 + ...Array.from( 9 + new Set( 10 + AllCardDefinitions.map((cardDef) => cardDef.groups) 11 + .flat() 12 + .filter((g) => g) 13 + ) 14 + ) 15 + .sort() 16 + .filter((g) => g !== 'Core') 17 + ]; 18 + 19 + let { 20 + open = $bindable(false), 21 + onselect 22 + }: { open: boolean; onselect: (cardDef: CardDefinition) => void } = $props(); 23 + 24 + function handleKeydown(e: KeyboardEvent) { 25 + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { 26 + e.preventDefault(); 27 + open = true; 28 + } 29 + } 30 + </script> 31 + 32 + <svelte:document onkeydown={handleKeydown} /> 33 + 34 + <Dialog.Root bind:open> 35 + <Dialog.Portal> 36 + <Dialog.Overlay 37 + class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80" 38 + /> 39 + <Dialog.Content 40 + class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-36 left-[50%] z-50 w-full max-w-[94%] translate-x-[-50%] outline-hidden sm:max-w-lg md:w-full" 41 + > 42 + <Dialog.Title class="sr-only">Command Menu</Dialog.Title> 43 + <Dialog.Description class="sr-only"> 44 + This is the command menu. Use the arrow keys to navigate and press ⌘K to open the search 45 + bar. 46 + </Dialog.Description> 47 + <Command.Root 48 + class="border-base-200 dark:border-base-800 mx-auto flex h-full w-full max-w-[90vw] flex-col overflow-hidden rounded-2xl border bg-white dark:bg-black" 49 + > 50 + <Command.Input 51 + class="focus-override placeholder:text-base-900/50 dark:placeholder:text-base-50/50 border-base-200 dark:border-base-800 bg-base-100 mx-1 mt-1 inline-flex truncate rounded-2xl rounded-tl-2xl px-4 text-sm transition-colors focus:ring-0 focus:outline-hidden dark:bg-black" 52 + placeholder="Search for a card..." 53 + /> 54 + <Command.List 55 + class="focus:outline-accent-500/50 max-h-[50vh] overflow-x-hidden overflow-y-auto rounded-br-2xl rounded-bl-2xl bg-white px-2 pb-2 focus:border-0 dark:bg-black" 56 + > 57 + <Command.Viewport> 58 + <Command.Empty 59 + class="text-base-900 dark:text-base-100 flex w-full items-center justify-center pt-8 pb-6 text-sm" 60 + > 61 + No results found. 62 + </Command.Empty> 63 + 64 + {#each CardDefGroups as group, index} 65 + {#if group && AllCardDefinitions.some((cardDef) => cardDef.groups?.includes(group))} 66 + <Command.Group> 67 + <Command.GroupHeading 68 + class="text-base-600 dark:text-base-400 px-3 pt-4 pb-2 text-xs" 69 + > 70 + {group} 71 + </Command.GroupHeading> 72 + <Command.GroupItems> 73 + {#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef} 74 + <Command.Item 75 + onSelect={() => { 76 + open = false; 77 + onselect(cardDef); 78 + }} 79 + class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none" 80 + keywords={[group, cardDef.type, ...(cardDef.keywords || [])]} 81 + > 82 + {#if cardDef.icon} 83 + <div class="text-base-700 dark:text-base-300"> 84 + {@html cardDef.icon} 85 + </div> 86 + {/if} 87 + {cardDef.name} 88 + </Command.Item> 89 + {/each} 90 + </Command.GroupItems> 91 + </Command.Group> 92 + {#if index < CardDefGroups.length - 1} 93 + <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" /> 94 + {/if} 95 + {/if} 96 + {/each} 97 + </Command.Viewport> 98 + </Command.List> 99 + </Command.Root> 100 + </Dialog.Content> 101 + </Dialog.Portal> 102 + </Dialog.Root>
+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;
+350 -123
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, Input, Navbar, Popover, Toggle, toast } from '@foxui/core'; 6 7 let { 8 data, ··· 17 save, 18 19 handleImageInputChange, 20 - handleVideoInputChange 21 }: { 22 data: WebsiteData; 23 linkValue: string; ··· 33 34 handleImageInputChange: (evt: Event) => void; 35 handleVideoInputChange: (evt: Event) => void; 36 } = $props(); 37 38 let linkPopoverOpen = $state(false); ··· 52 await navigator.clipboard.writeText(url); 53 toast.success('Link copied to clipboard!'); 54 } 55 </script> 56 57 <input ··· 59 accept="image/*" 60 onchange={handleImageInputChange} 61 class="hidden" 62 multiple 63 bind:this={imageInputRef} 64 /> ··· 74 75 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 76 <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 - ]} 81 > 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 100 > 101 - </Button> 102 - 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 113 fill="none" 114 stroke="currentColor" 115 - stroke-linecap="round" 116 - 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 120 - > 121 - </Button> 122 - 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'); 131 - }} 132 - {...props} 133 > 134 - <svg 135 - xmlns="http://www.w3.org/2000/svg" 136 - fill="none" 137 - viewBox="-2 -2 28 28" 138 - stroke-width="2" 139 - stroke="currentColor" 140 - > 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 - /> 146 - </svg> 147 - </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 - } 158 - }} 159 - placeholder="Enter link" 160 - /> 161 - <Button onclick={() => addLink(linkValue)} size="icon" 162 - ><svg 163 xmlns="http://www.w3.org/2000/svg" 164 fill="none" 165 viewBox="0 0 24 24" 166 stroke-width="2" 167 stroke="currentColor" 168 - class="size-6" 169 > 170 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 171 </svg> 172 </Button> 173 - </Popover> 174 - 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> 197 198 - {#if dev} 199 <Button 200 size="iconLg" 201 variant="ghost" 202 class="backdrop-blur-none" 203 onclick={() => { 204 - videoInputRef?.click(); 205 }} 206 > 207 <svg 208 xmlns="http://www.w3.org/2000/svg" 209 fill="none" 210 viewBox="0 0 24 24" 211 - stroke-width="1.5" 212 stroke="currentColor" 213 > 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 /> 219 </svg> 220 </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"> 236 <Toggle 237 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 238 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, ··· 20 save, 21 22 handleImageInputChange, 23 + handleVideoInputChange, 24 + 25 + showCardCommand, 26 + selectedCard = null, 27 + isMobile = false, 28 + isCoarse = false, 29 + ondeselect, 30 + ondelete, 31 + onsetsize 32 }: { 33 data: WebsiteData; 34 linkValue: string; ··· 44 45 handleImageInputChange: (evt: Event) => void; 46 handleVideoInputChange: (evt: Event) => void; 47 + 48 + showCardCommand: () => void; 49 + selectedCard?: Item | null; 50 + isMobile?: boolean; 51 + isCoarse?: boolean; 52 + ondeselect?: () => void; 53 + ondelete?: () => void; 54 + onsetsize?: (w: number, h: number) => void; 55 } = $props(); 56 57 let linkPopoverOpen = $state(false); ··· 71 await navigator.clipboard.writeText(url); 72 toast.success('Link copied to clipboard!'); 73 } 74 + 75 + let colorsChoices = [ 76 + { class: 'text-base-500', label: 'base' }, 77 + { class: 'text-accent-500', label: 'accent' }, 78 + { class: 'text-base-300 dark:text-base-700', label: 'transparent' }, 79 + { class: 'text-red-500', label: 'red' }, 80 + { class: 'text-orange-500', label: 'orange' }, 81 + { class: 'text-amber-500', label: 'amber' }, 82 + { class: 'text-yellow-500', label: 'yellow' }, 83 + { class: 'text-lime-500', label: 'lime' }, 84 + { class: 'text-green-500', label: 'green' }, 85 + { class: 'text-emerald-500', label: 'emerald' }, 86 + { class: 'text-teal-500', label: 'teal' }, 87 + { class: 'text-cyan-500', label: 'cyan' }, 88 + { class: 'text-sky-500', label: 'sky' }, 89 + { class: 'text-blue-500', label: 'blue' }, 90 + { class: 'text-indigo-500', label: 'indigo' }, 91 + { class: 'text-violet-500', label: 'violet' }, 92 + { class: 'text-purple-500', label: 'purple' }, 93 + { class: 'text-fuchsia-500', label: 'fuchsia' }, 94 + { class: 'text-pink-500', label: 'pink' }, 95 + { class: 'text-rose-500', label: 'rose' } 96 + ]; 97 + 98 + let selectedColor = $derived( 99 + selectedCard 100 + ? colorsChoices.find((c) => (selectedCard!.color ?? 'base') === c.label) 101 + : undefined 102 + ); 103 + 104 + let cardDef = $derived( 105 + selectedCard ? (CardDefinitionsByType[selectedCard.cardType] ?? null) : null 106 + ); 107 + 108 + let colorPopoverOpen = $state(false); 109 + let sizePopoverOpen = $state(false); 110 + let settingsPopoverOpen = $state(false); 111 + 112 + const minW = $derived(cardDef?.minW ?? 2); 113 + const minH = $derived(cardDef?.minH ?? 2); 114 + const maxW = $derived(cardDef?.maxW ?? COLUMNS); 115 + const maxH = $derived(cardDef?.maxH ?? (isMobile ? 12 : 6)); 116 + 117 + function canSetSize(w: number, h: number) { 118 + if (!cardDef) return false; 119 + if (isMobile) { 120 + return w >= minW && w * 2 <= maxW && h >= minH && h * 2 <= maxH; 121 + } 122 + return w >= minW && w <= maxW && h >= minH && h <= maxH; 123 + } 124 + 125 + const showMobileEditControls = $derived(isCoarse && selectedCard); 126 </script> 127 128 <input ··· 130 accept="image/*" 131 onchange={handleImageInputChange} 132 class="hidden" 133 + id="image-input" 134 multiple 135 bind:this={imageInputRef} 136 /> ··· 146 147 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 148 <Navbar 149 + class="dark:bg-base-900 bg-base-100 top-auto bottom-2 mx-4 mt-3 max-w-3xl rounded-full px-4 md:mx-auto" 150 > 151 + {#if showMobileEditControls} 152 + <!-- Mobile edit controls: left = color, size, settings; right = delete, deselect --> 153 + <div class="flex items-center gap-1"> 154 + {#if cardDef?.allowSetColor !== false} 155 + <Popover bind:open={colorPopoverOpen}> 156 + {#snippet child({ props })} 157 + <button 158 + {...props} 159 + class={[ 160 + 'cursor-pointer rounded-xl p-2', 161 + !selectedCard?.color || 162 + selectedCard.color === 'base' || 163 + selectedCard.color === 'transparent' 164 + ? 'text-base-800 dark:text-base-200' 165 + : 'text-accent-500' 166 + ]} 167 + > 168 + <svg 169 + xmlns="http://www.w3.org/2000/svg" 170 + viewBox="0 0 24 24" 171 + fill="currentColor" 172 + class="size-5" 173 + > 174 + <path 175 + fill-rule="evenodd" 176 + d="M20.599 1.5c-.376 0-.743.111-1.055.32l-5.08 3.385a18.747 18.747 0 0 0-3.471 2.987 10.04 10.04 0 0 1 4.815 4.815 18.748 18.748 0 0 0 2.987-3.472l3.386-5.079A1.902 1.902 0 0 0 20.599 1.5Zm-8.3 14.025a18.76 18.76 0 0 0 1.896-1.207 8.026 8.026 0 0 0-4.513-4.513A18.75 18.75 0 0 0 8.475 11.7l-.278.5a5.26 5.26 0 0 1 3.601 3.602l.502-.278ZM6.75 13.5A3.75 3.75 0 0 0 3 17.25a1.5 1.5 0 0 1-1.601 1.497.75.75 0 0 0-.7 1.123 5.25 5.25 0 0 0 9.8-2.62 3.75 3.75 0 0 0-3.75-3.75Z" 177 + clip-rule="evenodd" 178 + /> 179 + </svg> 180 + </button> 181 + {/snippet} 182 + <ColorSelect 183 + selected={selectedColor} 184 + colors={colorsChoices} 185 + onselected={(color, previous) => { 186 + if (typeof previous === 'string' || typeof color === 'string') { 187 + return; 188 + } 189 + if (selectedCard) { 190 + selectedCard.color = color.label; 191 + } 192 + }} 193 + class="w-64" 194 + /> 195 + </Popover> 196 + {/if} 197 + 198 + <Popover bind:open={sizePopoverOpen}> 199 + {#snippet child({ props })} 200 + <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 201 + <svg 202 + xmlns="http://www.w3.org/2000/svg" 203 + fill="none" 204 + viewBox="0 0 24 24" 205 + stroke-width="1.5" 206 + stroke="currentColor" 207 + class="size-5" 208 + > 209 + <path 210 + stroke-linecap="round" 211 + stroke-linejoin="round" 212 + d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" 213 + /> 214 + </svg> 215 + </button> 216 + {/snippet} 217 + <div class="flex items-center gap-1"> 218 + {#if canSetSize(2, 2)} 219 + <button 220 + onclick={() => onsetsize?.(4, 4)} 221 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 222 + > 223 + <div class="border-base-900 dark:border-base-50 size-3 rounded-sm border-2"></div> 224 + <span class="sr-only">set size to 1x1</span> 225 + </button> 226 + {/if} 227 + {#if canSetSize(4, 2)} 228 + <button 229 + onclick={() => onsetsize?.(8, 4)} 230 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 231 + > 232 + <div class="border-base-900 dark:border-base-50 h-3 w-5 rounded-sm border-2"></div> 233 + <span class="sr-only">set size to 2x1</span> 234 + </button> 235 + {/if} 236 + {#if canSetSize(2, 4)} 237 + <button 238 + onclick={() => onsetsize?.(4, 8)} 239 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 240 + > 241 + <div class="border-base-900 dark:border-base-50 h-5 w-3 rounded-sm border-2"></div> 242 + <span class="sr-only">set size to 1x2</span> 243 + </button> 244 + {/if} 245 + {#if canSetSize(4, 4)} 246 + <button 247 + onclick={() => onsetsize?.(8, 8)} 248 + class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2" 249 + > 250 + <div class="border-base-900 dark:border-base-50 h-5 w-5 rounded-sm border-2"></div> 251 + <span class="sr-only">set size to 2x2</span> 252 + </button> 253 + {/if} 254 + </div> 255 + </Popover> 256 + 257 + {#if cardDef?.settingsComponent && selectedCard} 258 + <Popover bind:open={settingsPopoverOpen} class="bg-base-50 dark:bg-base-900"> 259 + {#snippet child({ props })} 260 + <button {...props} class="hover:bg-accent-500/10 cursor-pointer rounded-xl p-2"> 261 + <svg 262 + xmlns="http://www.w3.org/2000/svg" 263 + fill="none" 264 + viewBox="0 0 24 24" 265 + stroke-width="2" 266 + stroke="currentColor" 267 + class="size-5" 268 + > 269 + <path 270 + stroke-linecap="round" 271 + stroke-linejoin="round" 272 + d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" 273 + /> 274 + <path 275 + stroke-linecap="round" 276 + stroke-linejoin="round" 277 + d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" 278 + /> 279 + </svg> 280 + </button> 281 + {/snippet} 282 + <cardDef.settingsComponent 283 + bind:item={selectedCard} 284 + onclose={() => { 285 + settingsPopoverOpen = false; 286 + }} 287 + /> 288 + </Popover> 289 + {/if} 290 + </div> 291 + <div class="flex items-center gap-1"> 292 + <Button 293 + size="iconLg" 294 + variant="ghost" 295 + class="text-rose-500 backdrop-blur-none" 296 + onclick={() => ondelete?.()} 297 > 298 + <svg 299 + xmlns="http://www.w3.org/2000/svg" 300 fill="none" 301 + viewBox="0 0 24 24" 302 + stroke-width="1.5" 303 stroke="currentColor" 304 + class="size-5" 305 > 306 + <path 307 + stroke-linecap="round" 308 + stroke-linejoin="round" 309 + d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" 310 + /> 311 + </svg> 312 + </Button> 313 + <Button 314 + size="iconLg" 315 + variant="ghost" 316 + class="backdrop-blur-none" 317 + onclick={() => ondeselect?.()} 318 + > 319 + <svg 320 xmlns="http://www.w3.org/2000/svg" 321 fill="none" 322 viewBox="0 0 24 24" 323 stroke-width="2" 324 stroke="currentColor" 325 + class="size-5" 326 > 327 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 328 </svg> 329 </Button> 330 + </div> 331 + {:else} 332 + <!-- Normal add-card controls --> 333 + <div class="flex items-center gap-2"> 334 + <Button 335 + size="iconLg" 336 + variant="ghost" 337 + class="backdrop-blur-none" 338 + onclick={() => { 339 + newCard('section'); 340 + }} 341 > 342 + <svg 343 + xmlns="http://www.w3.org/2000/svg" 344 + viewBox="0 0 24 24" 345 + fill="none" 346 + stroke="currentColor" 347 + stroke-width="2" 348 stroke-linecap="round" 349 stroke-linejoin="round" 350 + ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 351 + > 352 + </Button> 353 + 354 + <Button 355 + size="iconLg" 356 + variant="ghost" 357 + class="backdrop-blur-none" 358 + onclick={() => { 359 + newCard('text'); 360 + }} 361 + > 362 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 363 + ><path 364 + fill="none" 365 + stroke="currentColor" 366 + stroke-linecap="round" 367 + stroke-linejoin="round" 368 + stroke-width="2" 369 + d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 370 + /></svg 371 + > 372 + </Button> 373 + 374 + <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 375 + {#snippet child({ props })} 376 + <Button 377 + size="iconLg" 378 + variant="ghost" 379 + class="backdrop-blur-none" 380 + onclick={() => { 381 + newCard('link'); 382 + }} 383 + {...props} 384 + > 385 + <svg 386 + xmlns="http://www.w3.org/2000/svg" 387 + fill="none" 388 + viewBox="-2 -2 28 28" 389 + stroke-width="2" 390 + stroke="currentColor" 391 + > 392 + <path 393 + stroke-linecap="round" 394 + stroke-linejoin="round" 395 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 396 + /> 397 + </svg> 398 + </Button> 399 + {/snippet} 400 + <Input 401 + spellcheck={false} 402 + type="url" 403 + bind:value={linkValue} 404 + onkeydown={(event) => { 405 + if (event.code === 'Enter') { 406 + addLink(linkValue); 407 + event.preventDefault(); 408 + } 409 + }} 410 + placeholder="Enter link" 411 /> 412 + <Button onclick={() => addLink(linkValue)} size="icon" 413 + ><svg 414 + xmlns="http://www.w3.org/2000/svg" 415 + fill="none" 416 + viewBox="0 0 24 24" 417 + stroke-width="2" 418 + stroke="currentColor" 419 + class="size-6" 420 + > 421 + <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 422 + </svg> 423 + </Button> 424 + </Popover> 425 426 <Button 427 size="iconLg" 428 variant="ghost" 429 class="backdrop-blur-none" 430 onclick={() => { 431 + imageInputRef?.click(); 432 }} 433 > 434 <svg 435 xmlns="http://www.w3.org/2000/svg" 436 fill="none" 437 viewBox="0 0 24 24" 438 + stroke-width="2" 439 stroke="currentColor" 440 > 441 <path 442 stroke-linecap="round" 443 stroke-linejoin="round" 444 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 445 /> 446 </svg> 447 </Button> 448 449 + <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 450 + <svg 451 + xmlns="http://www.w3.org/2000/svg" 452 + fill="none" 453 + viewBox="0 0 24 24" 454 + stroke-width="1.5" 455 + stroke="currentColor" 456 + > 457 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 458 + </svg> 459 + </Button> 460 + </div> 461 + {/if} 462 + <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 463 <Toggle 464 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 465 bind:pressed={showingMobileView}
+254 -23
src/lib/website/EditableWebsite.svelte
··· 24 import EditingCard from '../cards/Card/EditingCard.svelte'; 25 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 26 import { tick, type Component } from 'svelte'; 27 - import type { 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'; ··· 38 import { user } from '$lib/atproto'; 39 import { launchConfetti } from '@foxui/visual'; 40 import Controls from './Controls.svelte'; 41 42 let { 43 data ··· 47 48 // Check if floating login button will be visible (to hide MadeWithBlento) 49 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn); 50 - 51 - let accentColor = $derived(data.publication?.preferences?.accentColor ?? 'pink'); 52 - let baseColor = $derived(data.publication?.preferences?.baseColor ?? 'stone'); 53 54 function updateTheme(newAccent: string, newBase: string) { 55 data.publication.preferences ??= {}; ··· 124 125 setIsMobile(() => isMobile); 126 127 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 128 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 129 ··· 140 } 141 142 function newCard(type: string = 'link', cardData?: any) { 143 // close sidebar if open 144 const popover = document.getElementById('mobile-menu'); 145 if (popover) { ··· 178 compactItems(items, false); 179 compactItems(items, true); 180 181 newItem = {}; 182 183 await tick(); ··· 202 await checkAndUploadImage(data.publication, 'icon'); 203 } 204 205 await savePage(data, items, publication); 206 207 publication = JSON.stringify(data.publication); ··· 229 230 let debugPoint = $state({ x: 0, y: 0 }); 231 232 - function getDragXY( 233 - e: DragEvent & { 234 - currentTarget: EventTarget & HTMLDivElement; 235 - } 236 ): 237 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 238 | undefined { 239 if (!container || !activeDragElement.item) return; 240 241 // 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; 244 245 const rect = container.getBoundingClientRect(); 246 const currentMargin = isMobile ? mobileMargin : margin; ··· 362 return { x: gridX, y: gridY, swapWithId, placement }; 363 } 364 365 let linkValue = $state(''); 366 367 function addLink(url: string) { ··· 490 compactItems(items, true); 491 } 492 493 await tick(); 494 495 scrollToItem(item, isMobile, container); ··· 624 fixCollisions(items, item, true, true); 625 compactItems(items, false); 626 compactItems(items, true); 627 628 await tick(); 629 ··· 644 target.value = ''; 645 } 646 647 - // $inspect(items); 648 </script> 649 650 <svelte:body ··· 684 </div> 685 {/if} 686 687 <Controls bind:data /> 688 689 {#if showingMobileView} ··· 732 ]} 733 > 734 <div class="pointer-events-none"></div> 735 - <!-- svelte-ignore a11y_no_static_element_interactions --> 736 <div 737 bind:this={container} 738 ondragover={(e) => { 739 e.preventDefault(); 740 ··· 831 items = items.filter((it) => it !== item); 832 compactItems(items, false); 833 compactItems(items, true); 834 }} 835 onsetsize={(newW: number, newH: number) => { 836 if (isMobile) { ··· 842 } 843 844 fixCollisions(items, item, isMobile); 845 }} 846 ondragstart={(e: DragEvent) => { 847 const target = e.currentTarget as HTMLDivElement; ··· 885 </div> 886 </div> 887 888 - <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 889 - <div class="flex flex-col gap-2"> 890 - {#each sidebarItems as cardDef (cardDef.type)} 891 - <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 892 - >{cardDef.sidebarButtonText}</Button 893 - > 894 - {/each} 895 - </div> 896 - </Sidebar> 897 - 898 <EditBar 899 {data} 900 bind:linkValue ··· 906 {save} 907 {handleImageInputChange} 908 {handleVideoInputChange} 909 /> 910 911 <Toaster /> 912 913 <FloatingEditButton {data} /> 914 </Context>
··· 24 import EditingCard from '../cards/Card/EditingCard.svelte'; 25 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 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'; ··· 38 import { user } from '$lib/atproto'; 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) { ··· 664 compactItems(items, true); 665 } 666 667 + onLayoutChanged(); 668 + 669 await tick(); 670 671 scrollToItem(item, isMobile, container); ··· 800 fixCollisions(items, item, true, true); 801 compactItems(items, false); 802 compactItems(items, true); 803 + 804 + onLayoutChanged(); 805 806 await tick(); 807 ··· 822 target.value = ''; 823 } 824 825 + let showCardCommand = $state(false); 826 </script> 827 828 <svelte:body ··· 862 </div> 863 {/if} 864 865 + <CardCommand 866 + bind:open={showCardCommand} 867 + onselect={(cardDef: CardDefinition) => { 868 + if (cardDef.type === 'image') { 869 + const input = document.getElementById('image-input') as HTMLInputElement; 870 + if (input) { 871 + input.click(); 872 + return; 873 + } 874 + } else { 875 + newCard(cardDef.type); 876 + } 877 + }} 878 + /> 879 + 880 <Controls bind:data /> 881 882 {#if showingMobileView} ··· 925 ]} 926 > 927 <div class="pointer-events-none"></div> 928 + <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 929 + <!-- svelte-ignore a11y_click_events_have_key_events --> 930 <div 931 bind:this={container} 932 + onclick={(e) => { 933 + // Deselect when tapping empty grid space 934 + if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 935 + selectedCardId = null; 936 + } 937 + }} 938 + ontouchstart={touchStart} 939 + ontouchend={touchEnd} 940 ondragover={(e) => { 941 e.preventDefault(); 942 ··· 1033 items = items.filter((it) => it !== item); 1034 compactItems(items, false); 1035 compactItems(items, true); 1036 + onLayoutChanged(); 1037 }} 1038 onsetsize={(newW: number, newH: number) => { 1039 if (isMobile) { ··· 1045 } 1046 1047 fixCollisions(items, item, isMobile); 1048 + onLayoutChanged(); 1049 }} 1050 ondragstart={(e: DragEvent) => { 1051 const target = e.currentTarget as HTMLDivElement; ··· 1089 </div> 1090 </div> 1091 1092 <EditBar 1093 {data} 1094 bind:linkValue ··· 1100 {save} 1101 {handleImageInputChange} 1102 {handleVideoInputChange} 1103 + showCardCommand={() => { 1104 + showCardCommand = true; 1105 + }} 1106 + {selectedCard} 1107 + {isMobile} 1108 + {isCoarse} 1109 + ondeselect={() => { 1110 + selectedCardId = null; 1111 + }} 1112 + ondelete={() => { 1113 + if (selectedCard) { 1114 + items = items.filter((it) => it.id !== selectedCardId); 1115 + compactItems(items, false); 1116 + compactItems(items, true); 1117 + onLayoutChanged(); 1118 + selectedCardId = null; 1119 + } 1120 + }} 1121 + onsetsize={(w: number, h: number) => { 1122 + if (selectedCard) { 1123 + if (isMobile) { 1124 + selectedCard.mobileW = w; 1125 + selectedCard.mobileH = h; 1126 + } else { 1127 + selectedCard.w = w; 1128 + selectedCard.h = h; 1129 + } 1130 + fixCollisions(items, selectedCard, isMobile); 1131 + onLayoutChanged(); 1132 + } 1133 + }} 1134 /> 1135 1136 <Toaster /> 1137 1138 <FloatingEditButton {data} /> 1139 + 1140 + {#if dev} 1141 + <div class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 rounded px-2 py-1 font-mono text-xs"> 1142 + editedOn: {editedOn} 1143 + </div> 1144 + {/if} 1145 </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 + }