your personal website on atproto - mirror blento.app
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #117 from flo-bit/next

Next

authored by

Florian and committed by
GitHub
aa5ae9b2 3cdc9f4b

+2430 -571
+3 -1
.claude/settings.local.json
··· 25 25 "Bash(pnpm exec svelte-kit:*)", 26 26 "Bash(pnpm build:*)", 27 27 "Bash(pnpm remove:*)", 28 - "Bash(grep:*)" 28 + "Bash(grep:*)", 29 + "Bash(find:*)", 30 + "Bash(npx prettier:*)" 29 31 ] 30 32 } 31 33 }
-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 85 "svelte-sonner": "^1.0.7", 86 86 "tailwind-merge": "^3.4.0", 87 87 "tailwind-variants": "^3.2.2", 88 + "tailwindcss-animate": "^1.0.7", 88 89 "three": "^0.176.0", 89 90 "turndown": "^7.2.2", 90 91 "wrangler": "^4.60.0"
+12
pnpm-lock.yaml
··· 146 146 tailwind-variants: 147 147 specifier: ^3.2.2 148 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) 149 152 three: 150 153 specifier: ^0.176.0 151 154 version: 0.176.0 ··· 2799 2802 peerDependenciesMeta: 2800 2803 tailwind-merge: 2801 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' 2802 2810 2803 2811 tailwindcss@4.1.18: 2804 2812 resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} ··· 5560 5568 tailwindcss: 4.1.18 5561 5569 optionalDependencies: 5562 5570 tailwind-merge: 3.4.0 5571 + 5572 + tailwindcss-animate@1.0.7(tailwindcss@4.1.18): 5573 + dependencies: 5574 + tailwindcss: 4.1.18 5563 5575 5564 5576 tailwindcss@4.1.18: {} 5565 5577
+2
src/app.css
··· 3 3 @plugin '@tailwindcss/forms'; 4 4 @plugin '@tailwindcss/typography'; 5 5 6 + @plugin 'tailwindcss-animate'; 7 + 6 8 @source '../node_modules/@foxui'; 7 9 8 10 @custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
+19 -5
src/lib/cards/ATProtoCollectionsCard/ATProtoCollectionsCard.svelte
··· 39 39 <Badge size="md" class="accent:text-accent-950">{collections.length}</Badge> 40 40 {/if} 41 41 </div> 42 - <div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4"> 43 - {#each collections ?? [] as collection (collection)} 44 - <Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button> 45 - {/each} 46 - </div> 42 + {#if collections && collections.length > 0} 43 + <div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4"> 44 + {#each collections as collection (collection)} 45 + <Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button> 46 + {/each} 47 + </div> 48 + {:else if collections} 49 + <div 50 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 51 + > 52 + No collections found. 53 + </div> 54 + {:else} 55 + <div 56 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 57 + > 58 + Loading collections... 59 + </div> 60 + {/if} 47 61 </div>
+5 -1
src/lib/cards/ATProtoCollectionsCard/index.ts
··· 19 19 item.w = 4; 20 20 item.mobileW = 8; 21 21 }, 22 - sidebarButtonText: 'Atmosphere Collections' 22 + name: 'ATProto Collections', 23 + 24 + keywords: ['bluesky', 'records', 'pds', 'data'], 25 + groups: ['Social'], 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"><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>` 23 27 } as CardDefinition & { type: 'atprotocollections' };
+11 -1
src/lib/cards/BaseCard/BaseCard.svelte
··· 5 5 import type { Snippet } from 'svelte'; 6 6 import type { HTMLAttributes } from 'svelte/elements'; 7 7 import { getColor } from '..'; 8 + import { getIsCoarse } from '$lib/website/context'; 9 + 10 + function tryGetIsCoarse(): (() => boolean) | undefined { 11 + try { 12 + return getIsCoarse(); 13 + } catch { 14 + return undefined; 15 + } 16 + } 17 + const isCoarse = tryGetIsCoarse(); 8 18 9 19 const colors = { 10 20 base: 'bg-base-200/50 dark:bg-base-950/50', ··· 39 49 id={item.id} 40 50 data-flip-id={item.id} 41 51 bind:this={ref} 42 - draggable={isEditing && !locked} 52 + draggable={isEditing && !locked && !isCoarse?.()} 43 53 class={[ 44 54 'card group/card selection:bg-accent-600/50 focus-within:outline-accent-500 @container/card absolute isolate z-0 rounded-3xl outline-offset-2 transition-all duration-200 focus-within:outline-2', 45 55 color ? (colors[color] ?? colors.accent) : colors.base,
+37 -10
src/lib/cards/BaseCard/BaseEditingCard.svelte
··· 7 7 import { ColorSelect } from '@foxui/colors'; 8 8 import { AllCardDefinitions, CardDefinitionsByType, getColor } from '..'; 9 9 import { COLUMNS } from '$lib'; 10 - import { getCanEdit, getIsMobile } from '$lib/website/context'; 10 + import { 11 + getCanEdit, 12 + getIsCoarse, 13 + getIsMobile, 14 + getSelectedCardId, 15 + getSelectCard 16 + } from '$lib/website/context'; 11 17 import PlainTextEditor from '$lib/components/PlainTextEditor.svelte'; 12 18 import { fixAllCollisions, fixCollisions } from '$lib/helper'; 13 19 ··· 53 59 54 60 let canEdit = getCanEdit(); 55 61 let isMobile = getIsMobile(); 62 + let isCoarse = getIsCoarse(); 63 + 64 + let selectedCardId = getSelectedCardId(); 65 + let selectCard = getSelectCard(); 66 + let isSelected = $derived(selectedCardId?.() === item.id); 67 + let isDimmed = $derived(isCoarse?.() && selectedCardId?.() != null && !isSelected); 56 68 57 69 let colorPopoverOpen = $state(false); 58 70 ··· 173 185 {item} 174 186 isEditing={true} 175 187 bind:ref 176 - showOutline={isResizing} 188 + showOutline={isResizing || (isCoarse?.() && isSelected)} 177 189 locked={item.cardData?.locked} 178 - class="scale-100 opacity-100 starting:scale-0 starting:opacity-0" 190 + class={[ 191 + 'scale-100 starting:scale-0 starting:opacity-0', 192 + isCoarse?.() && isSelected ? 'ring-accent-500 z-10 ring-2 ring-offset-2' : '', 193 + isDimmed ? 'opacity-70' : 'opacity-100' 194 + ]} 179 195 {...rest} 180 196 > 181 - {#if !item.cardData?.locked} 182 - <div class="absolute inset-0 cursor-grab"></div> 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> 183 210 {/if} 184 211 {@render children?.()} 185 212 ··· 187 214 <div 188 215 class={cn( 189 216 'bg-base-200/50 dark:bg-base-900/50 absolute top-2 left-2 z-100 w-fit max-w-[calc(100%-1rem)] rounded-xl p-1 px-2 backdrop-blur-md', 190 - !item.cardData.label && 'hidden group-hover/card:block' 217 + !item.cardData.label && 'hidden lg:group-hover/card:block' 191 218 )} 192 219 > 193 220 <PlainTextEditor ··· 205 232 {#if changeOptions.length > 1} 206 233 <div 207 234 class={[ 208 - 'absolute -top-3 -right-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex', 235 + 'absolute -top-3 -right-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex', 209 236 changePopoverOpen ? 'inline-flex' : '' 210 237 ]} 211 238 > ··· 253 280 onclick={() => { 254 281 ondelete(); 255 282 }} 256 - class="absolute -top-3 -left-3 hidden group-focus-within:inline-flex group-hover/card:inline-flex" 283 + class="absolute -top-3 -left-3 hidden lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex" 257 284 > 258 285 <svg 259 286 xmlns="http://www.w3.org/2000/svg" ··· 274 301 275 302 <div 276 303 class={[ 277 - 'absolute -bottom-7 w-full items-center justify-center text-xs group-focus-within:inline-flex group-hover/card:inline-flex', 304 + 'absolute -bottom-7 w-full items-center justify-center text-xs lg:group-focus-within:inline-flex lg:group-hover/card:inline-flex', 278 305 colorPopoverOpen || settingsPopoverOpen ? 'inline-flex' : 'hidden' 279 306 ]} 280 307 > ··· 411 438 <!-- Resize handle at bottom right corner --> 412 439 <div 413 440 onpointerdown={handleResizeStart} 414 - class="bg-base-300/70 dark:bg-base-900/70 pointer-events-auto absolute right-0.5 bottom-0.5 hidden cursor-se-resize rounded-md rounded-br-3xl p-1 group-hover/card:block" 441 + class="bg-base-300/70 dark:bg-base-900/70 pointer-events-auto absolute right-0.5 bottom-0.5 hidden cursor-se-resize rounded-md rounded-br-3xl p-1 lg:group-hover/card:block" 415 442 > 416 443 <svg 417 444 xmlns="http://www.w3.org/2000/svg"
+14 -1
src/lib/cards/BigSocialCard/index.ts
··· 51 51 return item; 52 52 }, 53 53 urlHandlerPriority: 1, 54 - canHaveLabel: true 54 + canHaveLabel: true, 55 + 56 + keywords: [ 57 + 'twitter', 58 + 'instagram', 59 + 'tiktok', 60 + 'youtube', 61 + 'github', 62 + 'discord', 63 + 'linkedin', 64 + 'mastodon' 65 + ], 66 + groups: ['Social'], 67 + 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>` 55 68 } as CardDefinition & { type: 'bigsocial' }; 56 69 57 70 import {
+7 -2
src/lib/cards/BlueskyMediaCard/index.ts
··· 7 7 contentComponent: BlueskyMediaCard, 8 8 createNew: () => {}, 9 9 creationModalComponent: CreateBlueskyMediaCardModal, 10 - sidebarButtonText: 'Bluesky Media', 11 - canHaveLabel: true 10 + canHaveLabel: true, 11 + 12 + keywords: ['bsky', 'atproto', 'media', 'feed'], 13 + groups: ['Media'], 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>` 12 17 } as CardDefinition & { type: 'blueskyMedia' };
+5 -2
src/lib/cards/BlueskyPostCard/index.ts
··· 9 9 type: 'blueskyPost', 10 10 contentComponent: BlueskyPostCard, 11 11 creationModalComponent: CreateBlueskyPostCardModal, 12 - sidebarButtonText: 'Bluesky Post', 13 12 createNew: (card) => { 14 13 card.cardType = 'blueskyPost'; 15 14 card.w = 4; ··· 63 62 return postsMap; 64 63 }, 65 64 minW: 4, 66 - name: 'Bluesky Post' 65 + name: 'Bluesky Post', 66 + 67 + keywords: ['skeet', 'bsky', 'atproto', 'post'], 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>` 67 70 } as CardDefinition & { type: 'blueskyPost' };
+1
src/lib/cards/BlueskyProfileCard/index.ts
··· 4 4 export const BlueskyProfileCardDefinition = { 5 5 type: 'blueskyProfile', 6 6 contentComponent: BlueskyProfileCard, 7 + keywords: ['bsky', 'atproto', 'account', 'user'], 7 8 createNew: () => {} 8 9 } as CardDefinition & { type: 'blueskyProfile' };
+6 -3
src/lib/cards/ButtonCard/index.ts
··· 8 8 contentComponent: ButtonCard, 9 9 editingContentComponent: EditingButtonCard, 10 10 settingsComponent: ButtonCardSettings, 11 - sidebarButtonText: 'Button', 12 - 13 11 createNew: (card) => { 14 12 card.cardData = { 15 13 text: 'Click me' ··· 27 25 minW: 2, 28 26 minH: 1, 29 27 maxW: 8, 30 - maxH: 4 28 + maxH: 4, 29 + 30 + keywords: ['cta', 'action', 'click', 'link'], 31 + groups: ['Utilities'], 32 + name: 'Button', 33 + 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>` 31 34 };
+87
src/lib/cards/ClockCard/ClockCard.svelte
··· 1 + <script lang="ts"> 2 + import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import type { ClockCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + let cardData = $derived(item.cardData as ClockCardData); 10 + 11 + let now = $state(new Date()); 12 + 13 + onMount(() => { 14 + const interval = setInterval(() => { 15 + now = new Date(); 16 + }, 1000); 17 + return () => clearInterval(interval); 18 + }); 19 + 20 + let clockParts = $derived.by(() => { 21 + try { 22 + return new Intl.DateTimeFormat('en-US', { 23 + timeZone: cardData.timezone || 'UTC', 24 + hour: '2-digit', 25 + minute: '2-digit', 26 + second: '2-digit', 27 + hour12: false 28 + }).formatToParts(now); 29 + } catch { 30 + return null; 31 + } 32 + }); 33 + 34 + let clockHours = $derived( 35 + clockParts ? parseInt(clockParts.find((p) => p.type === 'hour')?.value || '0') : 0 36 + ); 37 + let clockMinutes = $derived( 38 + clockParts ? parseInt(clockParts.find((p) => p.type === 'minute')?.value || '0') : 0 39 + ); 40 + let clockSeconds = $derived( 41 + clockParts ? parseInt(clockParts.find((p) => p.type === 'second')?.value || '0') : 0 42 + ); 43 + 44 + let timezoneDisplay = $derived.by(() => { 45 + if (!cardData.timezone) return ''; 46 + try { 47 + const formatter = new Intl.DateTimeFormat('en-US', { 48 + timeZone: cardData.timezone, 49 + timeZoneName: 'short' 50 + }); 51 + const parts = formatter.formatToParts(now); 52 + return parts.find((p) => p.type === 'timeZoneName')?.value || cardData.timezone; 53 + } catch { 54 + return cardData.timezone; 55 + } 56 + }); 57 + </script> 58 + 59 + <div class="@container flex h-full w-full flex-col items-center justify-center p-4"> 60 + <NumberFlowGroup> 61 + <div 62 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-center text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 63 + style="font-variant-numeric: tabular-nums;" 64 + > 65 + <NumberFlow value={clockHours} format={{ minimumIntegerDigits: 2 }} /> 66 + <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 67 + <NumberFlow 68 + value={clockMinutes} 69 + format={{ minimumIntegerDigits: 2 }} 70 + digits={{ 1: { max: 5 } }} 71 + trend={1} 72 + /> 73 + <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 74 + <NumberFlow 75 + value={clockSeconds} 76 + format={{ minimumIntegerDigits: 2 }} 77 + digits={{ 1: { max: 5 } }} 78 + trend={1} 79 + /> 80 + </div> 81 + </NumberFlowGroup> 82 + {#if timezoneDisplay} 83 + <div class="text-base-500 dark:text-base-400 accent:text-base-600 mt-1 text-xs @sm:text-sm"> 84 + {timezoneDisplay} 85 + </div> 86 + {/if} 87 + </div>
+74
src/lib/cards/ClockCard/ClockCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Button, Label } from '@foxui/core'; 4 + import type { ClockCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: { item: Item; onclose: () => void } = $props(); 8 + 9 + let cardData = $derived(item.cardData as ClockCardData); 10 + 11 + const timezoneOptions = [ 12 + { value: 'Pacific/Midway', label: 'UTC-11 (Midway)' }, 13 + { value: 'Pacific/Honolulu', label: 'UTC-10 (Honolulu)' }, 14 + { value: 'America/Anchorage', label: 'UTC-9 (Anchorage)' }, 15 + { value: 'America/Los_Angeles', label: 'UTC-8 (Los Angeles)' }, 16 + { value: 'America/Denver', label: 'UTC-7 (Denver)' }, 17 + { value: 'America/Chicago', label: 'UTC-6 (Chicago)' }, 18 + { value: 'America/New_York', label: 'UTC-5 (New York)' }, 19 + { value: 'America/Halifax', label: 'UTC-4 (Halifax)' }, 20 + { value: 'America/Sao_Paulo', label: 'UTC-3 (São Paulo)' }, 21 + { value: 'Atlantic/South_Georgia', label: 'UTC-2 (South Georgia)' }, 22 + { value: 'Atlantic/Azores', label: 'UTC-1 (Azores)' }, 23 + { value: 'UTC', label: 'UTC+0 (London)' }, 24 + { value: 'Europe/Paris', label: 'UTC+1 (Paris)' }, 25 + { value: 'Europe/Helsinki', label: 'UTC+2 (Helsinki)' }, 26 + { value: 'Europe/Moscow', label: 'UTC+3 (Moscow)' }, 27 + { value: 'Asia/Dubai', label: 'UTC+4 (Dubai)' }, 28 + { value: 'Asia/Karachi', label: 'UTC+5 (Karachi)' }, 29 + { value: 'Asia/Kolkata', label: 'UTC+5:30 (Mumbai)' }, 30 + { value: 'Asia/Dhaka', label: 'UTC+6 (Dhaka)' }, 31 + { value: 'Asia/Bangkok', label: 'UTC+7 (Bangkok)' }, 32 + { value: 'Asia/Shanghai', label: 'UTC+8 (Shanghai)' }, 33 + { value: 'Asia/Tokyo', label: 'UTC+9 (Tokyo)' }, 34 + { value: 'Australia/Sydney', label: 'UTC+10 (Sydney)' }, 35 + { value: 'Pacific/Noumea', label: 'UTC+11 (Noumea)' }, 36 + { value: 'Pacific/Auckland', label: 'UTC+12 (Auckland)' } 37 + ]; 38 + 39 + onMount(() => { 40 + if (!cardData.timezone) { 41 + try { 42 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 43 + } catch { 44 + item.cardData.timezone = 'UTC'; 45 + } 46 + } 47 + }); 48 + 49 + function useLocalTimezone() { 50 + try { 51 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 52 + } catch { 53 + item.cardData.timezone = 'UTC'; 54 + } 55 + } 56 + </script> 57 + 58 + <div class="flex flex-col gap-4"> 59 + <div class="flex flex-col gap-2"> 60 + <Label>Timezone</Label> 61 + <div class="flex gap-2"> 62 + <select 63 + value={cardData.timezone || 'UTC'} 64 + onchange={(e) => (item.cardData.timezone = e.currentTarget.value)} 65 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 flex-1 rounded-xl border px-3 py-2" 66 + > 67 + {#each timezoneOptions as tz (tz.value)} 68 + <option value={tz.value}>{tz.label}</option> 69 + {/each} 70 + </select> 71 + <Button size="sm" variant="ghost" onclick={useLocalTimezone}>Local</Button> 72 + </div> 73 + </div> 74 + </div>
+31
src/lib/cards/ClockCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import ClockCard from './ClockCard.svelte'; 3 + import ClockCardSettings from './ClockCardSettings.svelte'; 4 + 5 + export type ClockCardData = { 6 + timezone?: string; 7 + }; 8 + 9 + export const ClockCardDefinition = { 10 + type: 'clock', 11 + contentComponent: ClockCard, 12 + settingsComponent: ClockCardSettings, 13 + 14 + createNew: (card) => { 15 + card.w = 4; 16 + card.h = 2; 17 + card.mobileW = 8; 18 + card.mobileH = 3; 19 + card.cardData = { 20 + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone 21 + } as ClockCardData; 22 + }, 23 + 24 + allowSetColor: true, 25 + name: 'Clock', 26 + minW: 4, 27 + canHaveLabel: true, 28 + groups: ['Utilities'], 29 + keywords: ['time', 'timezone', 'watch'], 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="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>` 31 + } as CardDefinition & { type: 'clock' };
+185
src/lib/cards/CountdownCard/CountdownCard.svelte
··· 1 + <script lang="ts"> 2 + import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import type { CountdownCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + let cardData = $derived(item.cardData as CountdownCardData); 10 + 11 + let now = $state(new Date()); 12 + 13 + onMount(() => { 14 + const interval = setInterval(() => { 15 + now = new Date(); 16 + }, 1000); 17 + return () => clearInterval(interval); 18 + }); 19 + 20 + // Countdown to target date 21 + let eventDiff = $derived.by(() => { 22 + if (!cardData.targetDate) return null; 23 + const target = new Date(cardData.targetDate); 24 + return Math.max(0, target.getTime() - now.getTime()); 25 + }); 26 + 27 + let eventDays = $derived(eventDiff !== null ? Math.floor(eventDiff / (1000 * 60 * 60 * 24)) : 0); 28 + let eventHours = $derived( 29 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0 30 + ); 31 + let eventMinutes = $derived( 32 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0 33 + ); 34 + let eventSeconds = $derived( 35 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60)) / 1000) : 0 36 + ); 37 + 38 + // Check if event is in the past (elapsed mode) 39 + let isEventPast = $derived.by(() => { 40 + if (!cardData.targetDate) return false; 41 + return now.getTime() > new Date(cardData.targetDate).getTime(); 42 + }); 43 + 44 + // Elapsed time since past event 45 + let elapsedDiff = $derived.by(() => { 46 + if (!isEventPast || !cardData.targetDate) return null; 47 + return now.getTime() - new Date(cardData.targetDate).getTime(); 48 + }); 49 + 50 + let elapsedYears = $derived( 51 + elapsedDiff !== null ? Math.floor(elapsedDiff / (1000 * 60 * 60 * 24 * 365)) : 0 52 + ); 53 + let elapsedDays = $derived( 54 + elapsedDiff !== null 55 + ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24)) 56 + : 0 57 + ); 58 + let elapsedHours = $derived( 59 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0 60 + ); 61 + let elapsedMinutes = $derived( 62 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0 63 + ); 64 + let elapsedSeconds = $derived( 65 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60)) / 1000) : 0 66 + ); 67 + </script> 68 + 69 + <div class="@container flex h-full w-full flex-col items-center justify-center p-4"> 70 + {#if isEventPast && elapsedDiff !== null} 71 + <!-- Elapsed time since past event --> 72 + <NumberFlowGroup> 73 + <div 74 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8" 75 + style="font-variant-numeric: tabular-nums;" 76 + > 77 + {#if elapsedYears > 0} 78 + <div class="flex flex-col items-center"> 79 + <NumberFlow 80 + value={elapsedYears} 81 + trend={1} 82 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 83 + /> 84 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 85 + >{elapsedYears === 1 ? 'year' : 'years'}</span 86 + > 87 + </div> 88 + {/if} 89 + {#if elapsedYears > 0 || elapsedDays > 0} 90 + <div class="flex flex-col items-center"> 91 + <NumberFlow 92 + value={elapsedDays} 93 + trend={1} 94 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 95 + /> 96 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 97 + >{elapsedDays === 1 ? 'day' : 'days'}</span 98 + > 99 + </div> 100 + {/if} 101 + <div class="flex flex-col items-center"> 102 + <NumberFlow 103 + value={elapsedHours} 104 + trend={1} 105 + format={{ minimumIntegerDigits: 2 }} 106 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 107 + /> 108 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span> 109 + </div> 110 + <div class="flex flex-col items-center"> 111 + <NumberFlow 112 + value={elapsedMinutes} 113 + trend={1} 114 + format={{ minimumIntegerDigits: 2 }} 115 + digits={{ 1: { max: 5 } }} 116 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 117 + /> 118 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span> 119 + </div> 120 + <div class="flex flex-col items-center"> 121 + <NumberFlow 122 + value={elapsedSeconds} 123 + trend={1} 124 + format={{ minimumIntegerDigits: 2 }} 125 + digits={{ 1: { max: 5 } }} 126 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 127 + /> 128 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span> 129 + </div> 130 + </div> 131 + </NumberFlowGroup> 132 + {:else if eventDiff !== null} 133 + <!-- Countdown to future event --> 134 + <NumberFlowGroup> 135 + <div 136 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8" 137 + style="font-variant-numeric: tabular-nums;" 138 + > 139 + {#if eventDays > 0} 140 + <div class="flex flex-col items-center"> 141 + <NumberFlow 142 + value={eventDays} 143 + trend={-1} 144 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 145 + /> 146 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 147 + >{eventDays === 1 ? 'day' : 'days'}</span 148 + > 149 + </div> 150 + {/if} 151 + <div class="flex flex-col items-center"> 152 + <NumberFlow 153 + value={eventHours} 154 + trend={-1} 155 + format={{ minimumIntegerDigits: 2 }} 156 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 157 + /> 158 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span> 159 + </div> 160 + <div class="flex flex-col items-center"> 161 + <NumberFlow 162 + value={eventMinutes} 163 + trend={-1} 164 + format={{ minimumIntegerDigits: 2 }} 165 + digits={{ 1: { max: 5 } }} 166 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 167 + /> 168 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span> 169 + </div> 170 + <div class="flex flex-col items-center"> 171 + <NumberFlow 172 + value={eventSeconds} 173 + trend={-1} 174 + format={{ minimumIntegerDigits: 2 }} 175 + digits={{ 1: { max: 5 } }} 176 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 177 + /> 178 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span> 179 + </div> 180 + </div> 181 + </NumberFlowGroup> 182 + {:else} 183 + <div class="text-base-500 text-sm">Set a target date in settings</div> 184 + {/if} 185 + </div>
+44
src/lib/cards/CountdownCard/CountdownCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Input, Label } from '@foxui/core'; 4 + import type { CountdownCardData } from './index'; 5 + 6 + let { item }: { item: Item; onclose: () => void } = $props(); 7 + 8 + let cardData = $derived(item.cardData as CountdownCardData); 9 + 10 + let targetDateValue = $derived.by(() => { 11 + if (!cardData.targetDate) return ''; 12 + return new Date(cardData.targetDate).toISOString().split('T')[0]; 13 + }); 14 + 15 + let targetTimeValue = $derived.by(() => { 16 + if (!cardData.targetDate) return '12:00'; 17 + return new Date(cardData.targetDate).toTimeString().slice(0, 5); 18 + }); 19 + 20 + function updateTargetDate(dateStr: string, timeStr: string) { 21 + if (!dateStr) return; 22 + item.cardData.targetDate = new Date(`${dateStr}T${timeStr}`).toISOString(); 23 + } 24 + </script> 25 + 26 + <div class="flex flex-col gap-4"> 27 + <div class="flex flex-col gap-2"> 28 + <Label>Target Date & Time</Label> 29 + <div class="flex gap-2"> 30 + <Input 31 + type="date" 32 + value={targetDateValue} 33 + onchange={(e) => updateTargetDate(e.currentTarget.value, targetTimeValue)} 34 + class="flex-1" 35 + /> 36 + <Input 37 + type="time" 38 + value={targetTimeValue} 39 + onchange={(e) => updateTargetDate(targetDateValue, e.currentTarget.value)} 40 + class="w-28" 41 + /> 42 + </div> 43 + </div> 44 + </div>
+29
src/lib/cards/CountdownCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CountdownCard from './CountdownCard.svelte'; 3 + import CountdownCardSettings from './CountdownCardSettings.svelte'; 4 + 5 + export type CountdownCardData = { 6 + targetDate?: string; 7 + }; 8 + 9 + export const CountdownCardDefinition = { 10 + type: 'countdown', 11 + contentComponent: CountdownCard, 12 + settingsComponent: CountdownCardSettings, 13 + 14 + createNew: (card) => { 15 + card.w = 4; 16 + card.h = 2; 17 + card.mobileW = 8; 18 + card.mobileH = 3; 19 + card.cardData = {} as CountdownCardData; 20 + }, 21 + 22 + allowSetColor: true, 23 + name: 'Countdown', 24 + minW: 4, 25 + canHaveLabel: true, 26 + groups: ['Utilities'], 27 + keywords: ['timer', 'event', 'date', 'countdown'], 28 + 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 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z M19.5 4.5l-1.5 1.5M4.5 4.5l1.5 1.5M12 2.25V3.75M9 2.25h6" /></svg>` 29 + } as CardDefinition & { type: 'countdown' };
+5 -2
src/lib/cards/DrawCard/index.ts
··· 7 7 name: 'Drawing', 8 8 contentComponent: DrawCard, 9 9 editingContentComponent: EditingDrawCard, 10 - sidebarButtonText: 'Draw', 11 10 defaultColor: 'base', 12 11 allowSetColor: true, 13 12 minW: 2, ··· 23 22 strokeWidth: 1, 24 23 locked: true 25 24 }; 26 - } 25 + }, 26 + 27 + keywords: ['paint', 'sketch', 'doodle', 'canvas', 'art'], 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>` 27 30 } as CardDefinition & { type: 'draw' };
+4 -1
src/lib/cards/EmbedCard/index.ts
··· 19 19 // change: (item) => { 20 20 // return item; 21 21 // }, 22 - name: 'Embed Card' 22 + name: 'Embed', 23 + keywords: ['iframe', 'widget', 'html', 'website'], 24 + groups: ['Media'], 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="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>` 23 26 } as CardDefinition & { type: 'embed' };
+5 -3
src/lib/cards/EventCard/index.ts
··· 42 42 type: 'event', 43 43 contentComponent: EventCard, 44 44 creationModalComponent: CreateEventCardModal, 45 - sidebarButtonText: 'Event', 46 - 47 45 createNew: (card) => { 48 46 card.w = 4; 49 47 card.h = 4; ··· 112 110 113 111 urlHandlerPriority: 5, 114 112 115 - name: 'Event Card' 113 + name: 'Event', 114 + 115 + keywords: ['calendar', 'meetup', 'schedule', 'date', 'rsvp'], 116 + groups: ['Social'], 117 + 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>` 116 118 } as CardDefinition & { type: 'event' };
+6 -2
src/lib/cards/FluidTextCard/index.ts
··· 20 20 }, 21 21 creationModalComponent: CreateFluidTextCardModal, 22 22 settingsComponent: FluidTextCardSettings, 23 - sidebarButtonText: 'Fluid Text', 24 23 defaultColor: 'transparent', 25 24 allowSetColor: true, 26 - minW: 2 25 + minW: 2, 26 + 27 + keywords: ['animated', 'big text', 'headline', 'display'], 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>` 27 31 } as CardDefinition & { type: 'fluid-text' };
+103
src/lib/cards/FriendsCard/FriendsCard.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import { getAdditionalUserData, getCanEdit, getIsMobile } from '$lib/website/context'; 5 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 6 + import type { FriendsProfile } from '.'; 7 + import type { Did } from '@atcute/lexicons'; 8 + import { Avatar } from '@foxui/core'; 9 + 10 + let { item }: ContentComponentProps = $props(); 11 + 12 + const isMobile = getIsMobile(); 13 + const canEdit = getCanEdit(); 14 + const additionalData = getAdditionalUserData(); 15 + 16 + let dids: string[] = $derived(item.cardData.friends ?? []); 17 + 18 + let serverProfiles: FriendsProfile[] = $derived( 19 + (additionalData[item.cardType] as FriendsProfile[]) ?? [] 20 + ); 21 + 22 + let clientProfiles: FriendsProfile[] = $state([]); 23 + 24 + let profiles = $derived.by(() => { 25 + if (serverProfiles.length > 0) { 26 + return dids 27 + .map((did) => serverProfiles.find((p) => p.did === did)) 28 + .filter((p): p is FriendsProfile => !!p); 29 + } 30 + return dids 31 + .map((did) => clientProfiles.find((p) => p.did === did)) 32 + .filter((p): p is FriendsProfile => !!p); 33 + }); 34 + 35 + onMount(() => { 36 + if (serverProfiles.length === 0 && dids.length > 0) { 37 + loadProfiles(); 38 + } 39 + }); 40 + 41 + async function loadProfiles() { 42 + const results = await Promise.all( 43 + dids.map((did) => getBlentoOrBskyProfile({ did: did as Did }).catch(() => undefined)) 44 + ); 45 + clientProfiles = results.filter( 46 + (p): p is FriendsProfile => !!p && p.handle !== 'handle.invalid' 47 + ); 48 + } 49 + 50 + // Reload when dids change in editing mode 51 + $effect(() => { 52 + if (canEdit() && dids.length > 0) { 53 + loadProfiles(); 54 + } 55 + }); 56 + 57 + let sizeClass = $derived.by(() => { 58 + const w = isMobile() ? item.mobileW / 2 : item.w; 59 + if (w < 3) return 'sm'; 60 + if (w < 5) return 'md'; 61 + return 'lg'; 62 + }); 63 + 64 + function getLink(profile: FriendsProfile): string { 65 + if (profile.hasBlento && profile.handle && profile.handle !== 'handle.invalid') { 66 + return `/${profile.handle}`; 67 + } 68 + if (profile.handle && profile.handle !== 'handle.invalid') { 69 + return `https://bsky.app/profile/${profile.handle}`; 70 + } 71 + return `https://bsky.app/profile/${profile.did}`; 72 + } 73 + </script> 74 + 75 + <div class="flex h-full w-full items-center justify-center overflow-hidden px-2"> 76 + {#if dids.length === 0} 77 + {#if canEdit()} 78 + <span class="text-base-400 dark:text-base-500 accent:text-accent-300 text-sm"> 79 + Add friends in settings 80 + </span> 81 + {/if} 82 + {:else} 83 + {@const olX = sizeClass === 'sm' ? 12 : sizeClass === 'md' ? 20 : 24} 84 + {@const olY = sizeClass === 'sm' ? 8 : sizeClass === 'md' ? 12 : 16} 85 + <div class=""> 86 + <div class="flex flex-wrap items-center justify-center" style="padding: {olY}px 0 0 {olX}px;"> 87 + {#each profiles as profile (profile.did)} 88 + <a 89 + href={getLink(profile)} 90 + class="accent:ring-accent-500 relative block rounded-full ring-2 ring-white transition-transform hover:scale-110 dark:ring-neutral-900" 91 + style="margin: -{olY}px 0 0 -{olX}px;" 92 + > 93 + <Avatar 94 + src={profile.avatar} 95 + alt={profile.handle} 96 + class={sizeClass === 'sm' ? 'size-12' : sizeClass === 'md' ? 'size-16' : 'size-20'} 97 + /> 98 + </a> 99 + {/each} 100 + </div> 101 + </div> 102 + {/if} 103 + </div>
+94
src/lib/cards/FriendsCard/FriendsCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import type { Item } from '$lib/types'; 4 + import type { SettingsComponentProps } from '../types'; 5 + import type { AppBskyActorDefs } from '@atcute/bluesky'; 6 + import type { Did } from '@atcute/lexicons'; 7 + import type { FriendsProfile } from '.'; 8 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 9 + import HandleInput from '$lib/atproto/UI/HandleInput.svelte'; 10 + import { Avatar, Button } from '@foxui/core'; 11 + 12 + let { item = $bindable<Item>() }: SettingsComponentProps = $props(); 13 + 14 + let handleValue = $state(''); 15 + let inputRef: HTMLInputElement | null = $state(null); 16 + let profiles: FriendsProfile[] = $state([]); 17 + 18 + let dids: string[] = $derived(item.cardData.friends ?? []); 19 + 20 + onMount(() => { 21 + loadProfiles(); 22 + }); 23 + 24 + async function loadProfiles() { 25 + const results = await Promise.all( 26 + dids.map((did) => getBlentoOrBskyProfile({ did: did as Did }).catch(() => undefined)) 27 + ); 28 + profiles = results.filter((p): p is FriendsProfile => !!p && p.handle !== 'handle.invalid'); 29 + } 30 + 31 + function addFriend(actor: AppBskyActorDefs.ProfileViewBasic) { 32 + if (!item.cardData.friends) item.cardData.friends = []; 33 + if (item.cardData.friends.includes(actor.did)) return; 34 + item.cardData.friends = [...item.cardData.friends, actor.did]; 35 + profiles = [ 36 + ...profiles, 37 + { 38 + did: actor.did, 39 + handle: actor.handle, 40 + displayName: actor.displayName || actor.handle, 41 + avatar: actor.avatar, 42 + hasBlento: false 43 + } as FriendsProfile 44 + ]; 45 + requestAnimationFrame(() => { 46 + handleValue = ''; 47 + if (inputRef) inputRef.value = ''; 48 + }); 49 + } 50 + 51 + function removeFriend(did: string) { 52 + item.cardData.friends = item.cardData.friends.filter((d: string) => d !== did); 53 + profiles = profiles.filter((p) => p.did !== did); 54 + } 55 + 56 + function getProfile(did: string): FriendsProfile | undefined { 57 + return profiles.find((p) => p.did === did); 58 + } 59 + </script> 60 + 61 + <div class="flex flex-col gap-3"> 62 + <HandleInput bind:value={handleValue} onselected={addFriend} bind:ref={inputRef} /> 63 + 64 + {#if dids.length > 0} 65 + <div class="flex flex-col gap-1.5"> 66 + {#each dids as did (did)} 67 + {@const profile = getProfile(did)} 68 + <div class="flex items-center gap-2"> 69 + <Avatar src={profile?.avatar} alt={profile?.handle ?? did} class="size-6 rounded-full" /> 70 + <span class="min-w-0 flex-1 truncate text-sm"> 71 + {profile?.handle ?? did} 72 + </span> 73 + <Button 74 + variant="ghost" 75 + size="icon" 76 + class="size-6 min-w-6" 77 + onclick={() => removeFriend(did)} 78 + > 79 + <svg 80 + xmlns="http://www.w3.org/2000/svg" 81 + fill="none" 82 + viewBox="0 0 24 24" 83 + stroke-width="2" 84 + stroke="currentColor" 85 + class="size-3.5" 86 + > 87 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 88 + </svg> 89 + </Button> 90 + </div> 91 + {/each} 92 + </div> 93 + {/if} 94 + </div>
+42
src/lib/cards/FriendsCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import type { Did } from '@atcute/lexicons'; 3 + import { getBlentoOrBskyProfile } from '$lib/atproto/methods'; 4 + import FriendsCard from './FriendsCard.svelte'; 5 + import FriendsCardSettings from './FriendsCardSettings.svelte'; 6 + 7 + export type FriendsProfile = Awaited<ReturnType<typeof getBlentoOrBskyProfile>>; 8 + 9 + export const FriendsCardDefinition = { 10 + type: 'friends', 11 + contentComponent: FriendsCard, 12 + settingsComponent: FriendsCardSettings, 13 + createNew: (card) => { 14 + card.w = 4; 15 + card.h = 2; 16 + card.mobileW = 8; 17 + card.mobileH = 4; 18 + card.cardData.friends = []; 19 + }, 20 + loadData: async (items) => { 21 + const allDids = new Set<Did>(); 22 + for (const item of items) { 23 + for (const did of item.cardData.friends ?? []) { 24 + allDids.add(did as Did); 25 + } 26 + } 27 + if (allDids.size === 0) return []; 28 + 29 + const profiles = await Promise.all( 30 + Array.from(allDids).map((did) => getBlentoOrBskyProfile({ did }).catch(() => undefined)) 31 + ); 32 + return profiles.filter((p) => p && p.handle !== 'handle.invalid'); 33 + }, 34 + allowSetColor: true, 35 + defaultColor: 'base', 36 + minW: 2, 37 + minH: 2, 38 + name: 'Friends', 39 + groups: ['Social'], 40 + keywords: ['friends', 'avatars', 'people', 'community', 'blentos'], 41 + 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 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" /></svg>` 42 + } as CardDefinition & { type: 'friends' };
+5 -2
src/lib/cards/GIFCard/index.ts
··· 21 21 card.mobileH = 4; 22 22 }, 23 23 settingsComponent: GifCardSettings, 24 - sidebarButtonText: 'GIF', 25 24 defaultColor: 'transparent', 26 25 allowSetColor: false, 27 26 minW: 1, ··· 45 44 return null; 46 45 }, 47 46 urlHandlerPriority: 5, 48 - name: 'GIF' 47 + name: 'GIF', 48 + 49 + keywords: ['animation', 'giphy', 'meme', 'tenor'], 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>` 49 52 } as CardDefinition & { type: 'gif' };
+6 -2
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 5 5 export const DinoGameCardDefinition = { 6 6 type: 'dino-game', 7 7 contentComponent: DinoGameCard as unknown as Component<ContentComponentProps>, 8 - sidebarButtonText: 'Dino Game', 9 8 allowSetColor: true, 10 9 createNew: (card) => { 11 10 card.w = 4; ··· 14 13 card.mobileH = 6; 15 14 card.cardData = {}; 16 15 }, 17 - canHaveLabel: true 16 + canHaveLabel: true, 17 + 18 + keywords: ['chrome', 'dinosaur', 'runner', 'fun'], 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>` 18 22 } as CardDefinition & { type: 'dino-game' };
+7 -2
src/lib/cards/GameCards/TetrisCard/index.ts
··· 8 8 export const TetrisCardDefinition = { 9 9 type: 'tetris', 10 10 contentComponent: TetrisCard as unknown as Component<ContentComponentProps>, 11 - sidebarButtonText: 'Tetris', 12 11 allowSetColor: true, 13 12 defaultColor: 'accent', 14 13 createNew: (card) => { ··· 19 18 card.cardData = {}; 20 19 }, 21 20 maxH: 10, 22 - canHaveLabel: true 21 + canHaveLabel: true, 22 + 23 + keywords: ['blocks', 'puzzle', 'game', 'fun'], 24 + groups: ['Games'], 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>` 23 28 } as CardDefinition & { type: 'tetris' };
+70
src/lib/cards/GitHubProfileCard/CreateGitHubProfileCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + </script> 9 + 10 + <Modal open={true} closeButton={false}> 11 + <form 12 + onsubmit={() => { 13 + let input = item.cardData.href?.trim(); 14 + if (!input) return; 15 + 16 + let username: string | undefined; 17 + 18 + // Try parsing as URL first 19 + try { 20 + const parsed = new URL(input); 21 + if (/^(www\.)?github\.com$/.test(parsed.hostname)) { 22 + const segments = parsed.pathname.split('/').filter(Boolean); 23 + if ( 24 + segments.length === 1 && 25 + /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(segments[0]) 26 + ) { 27 + username = segments[0]; 28 + } 29 + } 30 + } catch { 31 + // Not a URL, try as plain username 32 + if (/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input)) { 33 + username = input; 34 + } 35 + } 36 + 37 + if (!username) { 38 + errorMessage = 'Please enter a valid GitHub username or profile URL'; 39 + return; 40 + } 41 + 42 + item.cardData.user = username; 43 + item.cardData.href = `https://github.com/${username}`; 44 + 45 + item.w = 6; 46 + item.mobileW = 8; 47 + item.h = 3; 48 + item.mobileH = 6; 49 + 50 + oncreate?.(); 51 + }} 52 + class="flex flex-col gap-2" 53 + > 54 + <Subheading>Enter a GitHub username or profile URL</Subheading> 55 + <Input 56 + bind:value={item.cardData.href} 57 + placeholder="username or https://github.com/username" 58 + class="mt-4" 59 + /> 60 + 61 + {#if errorMessage} 62 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 63 + {/if} 64 + 65 + <div class="mt-4 flex justify-end gap-2"> 66 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 67 + <Button type="submit">Create</Button> 68 + </div> 69 + </form> 70 + </Modal>
+7 -1
src/lib/cards/GitHubProfileCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 + import CreateGitHubProfileCardModal from './CreateGitHubProfileCardModal.svelte'; 2 3 import type GithubContributionsGraph from './GithubContributionsGraph.svelte'; 3 4 import GitHubProfileCard from './GitHubProfileCard.svelte'; 4 5 import type { GitHubContributionsData } from './types'; ··· 8 9 export const GithubProfileCardDefitition = { 9 10 type: 'githubProfile', 10 11 contentComponent: GitHubProfileCard, 12 + creationModalComponent: CreateGitHubProfileCardModal, 11 13 12 14 loadData: async (items) => { 13 15 const githubData: Record<string, GithubContributionsGraph> = {}; ··· 50 52 51 53 return item; 52 54 }, 53 - name: 'Github Profile' 55 + name: 'Github Profile', 56 + 57 + keywords: ['developer', 'code', 'repos', 'contributions'], 58 + groups: ['Social'], 59 + 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>` 54 60 } as CardDefinition & { type: 'githubProfile' }; 55 61 56 62 function getGitHubUsername(url: string | undefined): string | undefined {
+4 -2
src/lib/cards/GuestbookCard/index.ts
··· 7 7 type: 'guestbook', 8 8 contentComponent: GuestbookCard, 9 9 creationModalComponent: CreateGuestbookCardModal, 10 - sidebarButtonText: 'Guestbook', 11 10 createNew: (card) => { 12 11 card.w = 4; 13 12 card.h = 6; ··· 60 59 61 60 return results; 62 61 }, 63 - name: 'Guestbook' 62 + name: 'Guestbook', 63 + keywords: ['comments', 'visitors', 'message', 'sign'], 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>` 64 66 } as CardDefinition & { type: 'guestbook' };
+20 -2
src/lib/cards/ImageCard/index.ts
··· 42 42 }, 43 43 urlHandlerPriority: 3, 44 44 45 - name: 'Image Card', 45 + name: 'Image', 46 + 47 + canHaveLabel: true, 48 + 49 + keywords: ['photo', 'picture', 'upload', 'png', 'jpg'], 50 + groups: ['Core'], 46 51 47 - canHaveLabel: true 52 + icon: `<svg 53 + xmlns="http://www.w3.org/2000/svg" 54 + fill="none" 55 + viewBox="0 0 24 24" 56 + stroke-width="2" 57 + stroke="currentColor" 58 + class="size-4" 59 + > 60 + <path 61 + stroke-linecap="round" 62 + stroke-linejoin="round" 63 + 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" 64 + /> 65 + </svg>` 48 66 } as CardDefinition & { type: 'image' };
+7 -2
src/lib/cards/LatestBlueskyPostCard/index.ts
··· 12 12 card.h = 4; 13 13 card.mobileH = 8; 14 14 }, 15 - sidebarButtonText: 'Latest Bluesky Post', 16 15 loadData: async (items, { did }) => { 17 16 const authorFeed = await getAuthorFeed({ did, filter: 'posts_no_replies', limit: 2 }); 18 17 19 18 return JSON.parse(JSON.stringify(authorFeed)); 20 19 }, 21 - minW: 4 20 + minW: 4, 21 + 22 + name: 'Latest Bluesky Post', 23 + 24 + keywords: ['bsky', 'atproto', 'recent', 'feed'], 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>` 22 27 } 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>
+23 -2
src/lib/cards/LinkCard/index.ts
··· 1 1 import { checkAndUploadImage, validateLink } from '$lib/helper'; 2 2 import type { CardDefinition } from '../types'; 3 + import CreateLinkCardModal from './CreateLinkCardModal.svelte'; 3 4 import EditingLinkCard from './EditingLinkCard.svelte'; 4 5 import LinkCard from './LinkCard.svelte'; 5 6 import LinkCardSettings from './LinkCardSettings.svelte'; ··· 13 14 }, 14 15 settingsComponent: LinkCardSettings, 15 16 16 - name: 'Link Card', 17 + creationModalComponent: CreateLinkCardModal, 18 + 19 + name: 'Link', 17 20 canChange: (item) => Boolean(validateLink(item.cardData?.href)), 18 21 change: (item) => { 19 22 const href = validateLink(item.cardData?.href); ··· 36 39 await checkAndUploadImage(item.cardData, 'favicon'); 37 40 return item; 38 41 }, 39 - urlHandlerPriority: 0 42 + urlHandlerPriority: 0, 43 + 44 + keywords: ['url', 'website', 'href', 'webpage'], 45 + groups: ['Core'], 46 + 47 + icon: `<svg 48 + xmlns="http://www.w3.org/2000/svg" 49 + fill="none" 50 + viewBox="-2 -2 28 28" 51 + stroke-width="2" 52 + stroke="currentColor" 53 + class="size-4" 54 + > 55 + <path 56 + stroke-linecap="round" 57 + stroke-linejoin="round" 58 + 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" 59 + /> 60 + </svg>` 40 61 } as CardDefinition & { type: 'link' };
+4 -2
src/lib/cards/LivestreamCard/index.ts
··· 6 6 export const LivestreamCardDefitition = { 7 7 type: 'latestLivestream', 8 8 contentComponent: LivestreamCard, 9 - sidebarButtonText: 'stream.place info', 10 9 createNew: (card) => { 11 10 card.w = 4; 12 11 card.h = 4; ··· 81 80 82 81 urlHandlerPriority: 5, 83 82 84 - name: 'stream.place Card' 83 + name: 'Latest Livestream (stream.place)', 84 + keywords: ['stream', 'live', 'broadcast', 'video'], 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>` 85 87 } as CardDefinition & { type: 'latestLivestream' }; 86 88 87 89 export const LivestreamEmbedCardDefitition = {
+11 -2
src/lib/cards/MapCard/index.ts
··· 6 6 export const MapCardDefinition = { 7 7 type: 'mapLocation', 8 8 contentComponent: MapCard, 9 - sidebarButtonText: 'Map', 10 9 createNew: (item) => { 11 10 item.w = 4; 12 11 item.h = 4; ··· 17 16 creationModalComponent: CreateMapCardModal, 18 17 allowSetColor: false, 19 18 canHaveLabel: true, 20 - settingsComponent: MapCardSettings 19 + settingsComponent: MapCardSettings, 20 + 21 + keywords: ['location', 'place', 'address', 'geo'], 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 + ` 21 30 } as CardDefinition & { type: 'mapLocation' }; 22 31 23 32 export function getZoomLevel(type: string | undefined): number {
-2
src/lib/cards/Model3DCard/index.ts
··· 7 7 type: 'model3d', 8 8 contentComponent: Model3DCard, 9 9 creationModalComponent: CreateModel3DCardModal, 10 - sidebarButtonText: '3D Model', 11 - 12 10 createNew: (card) => { 13 11 card.w = 4; 14 12 card.h = 4;
+8 -1
src/lib/cards/PhotoGalleryCard/PhotoGalleryCard.svelte
··· 49 49 }); 50 50 51 51 let images = $derived( 52 - feed 52 + (feed 53 53 ?.toSorted((a: PhotoItem, b: PhotoItem) => { 54 54 return (a.value.position ?? 0) - (b.value.position ?? 0); 55 55 }) ··· 63 63 position: i.value.position ?? 0 64 64 }; 65 65 }) 66 + .filter((i) => i.src !== undefined) || []) as { 67 + src: string; 68 + name: string; 69 + width: number; 70 + height: number; 71 + position: number; 72 + }[] 66 73 ); 67 74 68 75 let isMobile = getIsMobile();
+1 -1
src/lib/cards/PhotoGalleryCard/index.ts
··· 68 68 69 69 return itemsData; 70 70 }, 71 + keywords: ['album', 'photos', 'slideshow', 'images', 'carousel'], 71 72 minW: 4 72 - //sidebarButtonText: 'Photo Gallery' 73 73 } as CardDefinition & { type: 'photoGallery' };
+37 -23
src/lib/cards/PopfeedReviews/PopfeedReviewsCard.svelte
··· 30 30 </script> 31 31 32 32 <div class="z-10 flex h-full gap-4 overflow-x-scroll p-4"> 33 - {#each feed ?? [] as review (review.uri)} 34 - {#if review.value.rating !== undefined && review.value.posterUrl} 35 - <a 36 - rel="noopener noreferrer" 37 - target="_blank" 38 - class="flex" 39 - href="https://popfeed.social/review/{review.uri}" 40 - > 41 - <div 42 - class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1" 33 + {#if feed && feed.length > 0} 34 + {#each feed as review (review.uri)} 35 + {#if review.value.rating !== undefined && review.value.posterUrl} 36 + <a 37 + rel="noopener noreferrer" 38 + target="_blank" 39 + class="flex" 40 + href="https://popfeed.social/review/{review.uri}" 43 41 > 44 - <img 45 - src={review.value.posterUrl} 46 - alt="" 47 - class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover" 48 - /> 49 - 50 42 <div 51 - class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent" 52 - ></div> 43 + class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1" 44 + > 45 + <img 46 + src={review.value.posterUrl} 47 + alt="" 48 + class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover" 49 + /> 53 50 54 - <Rating class="z-10 text-lg" rating={review.value.rating} /> 55 - </div> 56 - </a> 57 - {/if} 58 - {/each} 51 + <div 52 + class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent" 53 + ></div> 54 + 55 + <Rating class="z-10 text-lg" rating={review.value.rating} /> 56 + </div> 57 + </a> 58 + {/if} 59 + {/each} 60 + {:else if feed} 61 + <div 62 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full w-full items-center justify-center text-center text-sm" 63 + > 64 + No reviews yet. 65 + </div> 66 + {:else} 67 + <div 68 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full w-full items-center justify-center text-center text-sm" 69 + > 70 + Loading reviews... 71 + </div> 72 + {/if} 59 73 </div>
+6 -2
src/lib/cards/PopfeedReviews/index.ts
··· 17 17 return data; 18 18 }, 19 19 minH: 3, 20 - sidebarButtonText: 'Popfeed Reviews', 21 - canHaveLabel: true 20 + canHaveLabel: true, 21 + 22 + keywords: ['movies', 'tv', 'film', 'reviews', 'ratings', 'popfeed'], 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>` 22 26 } as CardDefinition & { type: 'recentPopfeedReviews' };
+18 -1
src/lib/cards/SectionCard/index.ts
··· 24 24 }, 25 25 26 26 defaultColor: 'transparent', 27 + minW: COLUMNS, 27 28 maxH: 1, 28 29 canResize: false, 29 - settingsComponent: SectionCardSettings 30 + settingsComponent: SectionCardSettings, 31 + 32 + name: 'Heading', 33 + keywords: ['title', 'section', 'header', 'divider'], 34 + groups: ['Core'], 35 + 36 + icon: `<svg 37 + xmlns="http://www.w3.org/2000/svg" 38 + viewBox="0 0 24 24" 39 + fill="none" 40 + stroke="currentColor" 41 + stroke-width="2" 42 + stroke-linecap="round" 43 + stroke-linejoin="round" 44 + class="size-4" 45 + ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 46 + >` 30 47 } as CardDefinition & { type: 'section' }; 31 48 32 49 export const textAlignClasses: Record<string, string> = {
+1
src/lib/cards/SpecialCards/UpdatedBlentos/index.ts
··· 8 8 export const UpdatedBlentosCardDefitition = { 9 9 type: 'updatedBlentos', 10 10 contentComponent: UpdatedBlentosCard, 11 + keywords: ['feed', 'updates', 'recent', 'activity'], 11 12 loadData: async (items, { cache }) => { 12 13 try { 13 14 const response = await fetch(
+5 -3
src/lib/cards/SpotifyCard/index.ts
··· 8 8 type: cardType, 9 9 contentComponent: SpotifyCard, 10 10 creationModalComponent: CreateSpotifyCardModal, 11 - sidebarButtonText: 'Spotify Embed', 12 - 13 11 createNew: (item) => { 14 12 item.cardType = cardType; 15 13 item.cardData = {}; ··· 40 38 name: 'Spotify Embed', 41 39 canResize: true, 42 40 minW: 4, 43 - minH: 5 41 + minH: 5, 42 + 43 + keywords: ['music', 'song', 'playlist', 'album', 'podcast'], 44 + groups: ['Media'], 45 + 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>` 44 46 } as CardDefinition & { type: typeof cardType }; 45 47 46 48 // Match Spotify album and playlist URLs
+34 -8
src/lib/cards/StandardSiteDocumentListCard/StandardSiteDocumentListCard.svelte
··· 27 27 </script> 28 28 29 29 <div class="flex h-full flex-col gap-10 overflow-y-scroll p-8"> 30 - {#each feed ?? [] as document (document.uri)} 31 - <BlogEntry 32 - title={document.value.title} 33 - description={document.value.description} 34 - date={document.value.publishedAt} 35 - href={document.value.href} 36 - /> 37 - {/each} 30 + {#if feed && feed.length > 0} 31 + {#each feed as document (document.uri)} 32 + <BlogEntry 33 + title={document.value.title} 34 + description={document.value.description} 35 + date={document.value.publishedAt} 36 + href={document.value.href} 37 + /> 38 + {/each} 39 + {:else if feed} 40 + <div 41 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full flex-col items-center justify-center gap-2 text-center text-sm" 42 + > 43 + <span>No blog posts found.</span> 44 + <span> 45 + Create some on <a 46 + href="https://leaflet.pub" 47 + target="_blank" 48 + rel="noopener noreferrer" 49 + class="underline">Leaflet</a 50 + > 51 + or 52 + <a href="https://pckt.pub" target="_blank" rel="noopener noreferrer" class="underline" 53 + >Pckt</a 54 + > 55 + </span> 56 + </div> 57 + {:else} 58 + <div 59 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 60 + > 61 + Loading blog posts... 62 + </div> 63 + {/if} 38 64 </div>
+5 -1
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 42 42 return records; 43 43 }, 44 44 45 - sidebarButtonText: 'site.standard.document list' 45 + name: 'Blog Posts', 46 + 47 + keywords: ['articles', 'writing', 'blog', 'posts', 'frontpage'], 48 + groups: ['Content'], 49 + 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>` 46 50 } as CardDefinition & { type: 'site.standard.document list' };
+6 -3
src/lib/cards/StatusphereCard/index.ts
··· 23 23 24 24 return data[0]; 25 25 }, 26 - sidebarButtonText: 'Statusphere', 27 - 28 26 upload: async (item) => { 29 27 if (item.cardData.hasUpdate) { 30 28 await putRecord({ ··· 47 45 item.cardData.label = item.cardData.title; 48 46 } 49 47 }, 50 - canHaveLabel: true 48 + canHaveLabel: true, 49 + 50 + name: 'Emoji', 51 + keywords: ['status', 'mood', 'reaction', 'statusphere'], 52 + groups: ['Media'], 53 + 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>` 51 54 } as CardDefinition & { type: 'statusphere' }; 52 55 53 56 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+22 -8
src/lib/cards/TealFMPlaysCard/TealFMPlaysCard.svelte
··· 85 85 {/snippet} 86 86 87 87 <div class="z-10 flex h-full w-full flex-col gap-4 overflow-y-scroll p-4"> 88 - {#each feed ?? [] as play (play.uri)} 89 - {#if play.value.originUrl} 90 - <a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full"> 88 + {#if feed && feed.length > 0} 89 + {#each feed as play (play.uri)} 90 + {#if play.value.originUrl} 91 + <a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full"> 92 + {@render musicItem(play)} 93 + </a> 94 + {:else} 91 95 {@render musicItem(play)} 92 - </a> 93 - {:else} 94 - {@render musicItem(play)} 95 - {/if} 96 - {/each} 96 + {/if} 97 + {/each} 98 + {:else if feed} 99 + <div 100 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 101 + > 102 + No recent plays found. 103 + </div> 104 + {:else} 105 + <div 106 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 107 + > 108 + Loading plays... 109 + </div> 110 + {/if} 97 111 </div>
+7 -2
src/lib/cards/TealFMPlaysCard/index.ts
··· 21 21 return data; 22 22 }, 23 23 minW: 4, 24 - sidebarButtonText: 'teal.fm Plays', 25 - canHaveLabel: true 24 + canHaveLabel: true, 25 + 26 + keywords: ['music', 'scrobble', 'listening', 'songs'], 27 + name: 'Teal.fm Plays', 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>` 26 31 } as CardDefinition & { type: 'recentTealFMPlays' };
+17 -1
src/lib/cards/TextCard/index.ts
··· 14 14 }; 15 15 }, 16 16 17 - settingsComponent: TextCardSettings 17 + settingsComponent: TextCardSettings, 18 + 19 + name: 'Text', 20 + 21 + keywords: ['paragraph', 'note', 'write', 'content', 'description', 'bio'], 22 + groups: ['Core'], 23 + 24 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" 25 + ><path 26 + fill="none" 27 + stroke="currentColor" 28 + stroke-linecap="round" 29 + stroke-linejoin="round" 30 + stroke-width="2" 31 + 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" 32 + /></svg 33 + >` 18 34 } as CardDefinition & { type: 'text' }; 19 35 20 36 export const textAlignClasses: Record<string, string> = {
+16 -3
src/lib/cards/TimerCard/index.ts
··· 17 17 type: 'timer', 18 18 contentComponent: TimerCard, 19 19 settingsComponent: TimerCardSettings, 20 - sidebarButtonText: 'Timer', 21 20 22 21 createNew: (card) => { 23 22 card.w = 4; ··· 30 29 } as TimerCardData; 31 30 }, 32 31 32 + keywords: ['stopwatch', 'clock', 'time'], 33 33 allowSetColor: true, 34 - name: 'Timer Card', 35 34 minW: 4, 36 - canHaveLabel: true 35 + canHaveLabel: true, 36 + 37 + migrate: (item) => { 38 + const data = item.cardData as TimerCardData; 39 + if (data.mode === 'event') { 40 + item.cardType = 'countdown'; 41 + item.cardData = { targetDate: data.targetDate }; 42 + } else { 43 + item.cardType = 'clock'; 44 + item.cardData = { timezone: data.timezone }; 45 + } 46 + if (data.label) { 47 + item.cardData.label = data.label; 48 + } 49 + } 37 50 } as CardDefinition & { type: 'timer' };
+4 -2
src/lib/cards/VCardCard/index.ts
··· 120 120 card.cardData.displayName = displayName; 121 121 }, 122 122 123 - sidebarButtonText: 'vCard', 124 123 allowSetColor: true, 125 - name: 'vCard Card' 124 + name: 'vCard Card', 125 + keywords: ['contact', 'phone', 'email', 'address', 'business 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>` 126 128 } as CardDefinition & { type: 'vcard' };
-90
src/lib/cards/VideoCard/VideoCard.svelte
··· 1 - <script lang="ts"> 2 - import { getDidContext } from '$lib/website/context'; 3 - import { getBlobURL } from '$lib/atproto'; 4 - import { onMount } from 'svelte'; 5 - import type { ContentComponentProps } from '../types'; 6 - 7 - let { item = $bindable() }: ContentComponentProps = $props(); 8 - 9 - const did = getDidContext(); 10 - 11 - let element: HTMLVideoElement | undefined = $state(); 12 - 13 - onMount(async () => { 14 - const el = element; 15 - if (!el) return; 16 - 17 - el.muted = true; 18 - 19 - // If we already have an objectUrl (preview before upload), use it directly 20 - if (item.cardData.objectUrl) { 21 - el.src = item.cardData.objectUrl; 22 - el.play().catch((e) => { 23 - console.error('Video play error:', e); 24 - }); 25 - return; 26 - } 27 - 28 - // Fetch the video blob from the PDS 29 - if (item.cardData.video?.video && typeof item.cardData.video.video === 'object') { 30 - try { 31 - const blobUrl = await getBlobURL({ did, blob: item.cardData.video.video }); 32 - const res = await fetch(blobUrl); 33 - if (!res.ok) throw new Error(res.statusText); 34 - const blob = await res.blob(); 35 - const url = URL.createObjectURL(blob); 36 - el.src = url; 37 - el.play().catch((e) => { 38 - console.error('Video play error:', e); 39 - }); 40 - } catch (e) { 41 - console.error('Failed to load video:', e); 42 - } 43 - } 44 - }); 45 - </script> 46 - 47 - {#key item.cardData.video || item.cardData.objectUrl} 48 - <video 49 - bind:this={element} 50 - muted 51 - loop 52 - autoplay 53 - playsinline 54 - class={[ 55 - 'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out', 56 - item.cardData.href ? 'group-hover:scale-102' : '' 57 - ]} 58 - ></video> 59 - {/key} 60 - {#if item.cardData.href} 61 - <a 62 - href={item.cardData.href} 63 - class="absolute inset-0 h-full w-full" 64 - target="_blank" 65 - rel="noopener noreferrer" 66 - > 67 - <span class="sr-only"> 68 - {item.cardData.hrefText ?? 'Learn more'} 69 - </span> 70 - 71 - <div 72 - class="bg-base-800/30 border-base-900/30 absolute top-2 right-2 rounded-full border p-1 text-white opacity-50 backdrop-blur-lg group-focus-within:opacity-100 group-hover:opacity-100" 73 - > 74 - <svg 75 - xmlns="http://www.w3.org/2000/svg" 76 - fill="none" 77 - viewBox="0 0 24 24" 78 - stroke-width="2.5" 79 - stroke="currentColor" 80 - class="size-4" 81 - > 82 - <path 83 - stroke-linecap="round" 84 - stroke-linejoin="round" 85 - d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25" 86 - /> 87 - </svg> 88 - </div> 89 - </a> 90 - {/if}
-54
src/lib/cards/VideoCard/VideoCardSettings.svelte
··· 1 - <script lang="ts"> 2 - import { validateLink } from '$lib/helper'; 3 - import type { Item } from '$lib/types'; 4 - import { Button, Input, toast } from '@foxui/core'; 5 - 6 - let { item, onclose }: { item: Item; onclose: () => void } = $props(); 7 - 8 - let linkValue = $derived( 9 - item.cardData.href?.replace('https://', '').replace('http://', '') ?? '' 10 - ); 11 - 12 - function updateLink() { 13 - if (!linkValue.trim()) { 14 - item.cardData.href = ''; 15 - item.cardData.domain = ''; 16 - } 17 - 18 - let link = validateLink(linkValue); 19 - if (!link) { 20 - toast.error('Invalid link'); 21 - return; 22 - } 23 - 24 - item.cardData.href = link; 25 - item.cardData.domain = new URL(link).hostname; 26 - 27 - onclose?.(); 28 - } 29 - </script> 30 - 31 - <Input 32 - spellcheck={false} 33 - type="url" 34 - bind:value={linkValue} 35 - onkeydown={(event) => { 36 - if (event.code === 'Enter') { 37 - updateLink(); 38 - event.preventDefault(); 39 - } 40 - }} 41 - placeholder="Enter link" 42 - /> 43 - <Button onclick={updateLink} size="icon" 44 - ><svg 45 - xmlns="http://www.w3.org/2000/svg" 46 - fill="none" 47 - viewBox="0 0 24 24" 48 - stroke-width="1.5" 49 - stroke="currentColor" 50 - class="size-6" 51 - > 52 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 53 - </svg> 54 - </Button>
-63
src/lib/cards/VideoCard/index.ts
··· 1 - import { uploadBlob } from '$lib/atproto'; 2 - import type { CardDefinition } from '../types'; 3 - import VideoCard from './VideoCard.svelte'; 4 - import VideoCardSettings from './VideoCardSettings.svelte'; 5 - 6 - async function getAspectRatio(videoBlob: Blob): Promise<{ width: number; height: number }> { 7 - return new Promise((resolve, reject) => { 8 - const video = document.createElement('video'); 9 - video.preload = 'metadata'; 10 - 11 - video.onloadedmetadata = () => { 12 - URL.revokeObjectURL(video.src); 13 - resolve({ 14 - width: video.videoWidth, 15 - height: video.videoHeight 16 - }); 17 - }; 18 - 19 - video.onerror = () => { 20 - URL.revokeObjectURL(video.src); 21 - reject(new Error('Failed to load video metadata')); 22 - }; 23 - 24 - video.src = URL.createObjectURL(videoBlob); 25 - }); 26 - } 27 - 28 - export const VideoCardDefinition = { 29 - type: 'video', 30 - contentComponent: VideoCard, 31 - createNew: (card) => { 32 - card.cardType = 'video'; 33 - card.cardData = { 34 - video: null, 35 - href: '' 36 - }; 37 - }, 38 - upload: async (item) => { 39 - if (item.cardData.blob) { 40 - const blob = item.cardData.blob; 41 - const aspectRatio = await getAspectRatio(blob); 42 - const uploadedBlob = await uploadBlob({ blob }); 43 - 44 - item.cardData.video = { 45 - $type: 'app.bsky.embed.video', 46 - video: uploadedBlob, 47 - aspectRatio 48 - }; 49 - 50 - delete item.cardData.blob; 51 - } 52 - 53 - if (item.cardData.objectUrl) { 54 - URL.revokeObjectURL(item.cardData.objectUrl); 55 - delete item.cardData.objectUrl; 56 - } 57 - 58 - return item; 59 - }, 60 - settingsComponent: VideoCardSettings, 61 - 62 - name: 'Video Card' 63 - } as CardDefinition & { type: 'video' };
+52
src/lib/cards/YoutubeVideoCard/CreateYoutubeCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { matcher } from './index'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let errorMessage = $state(''); 9 + </script> 10 + 11 + <Modal open={true} closeButton={false}> 12 + <form 13 + onsubmit={() => { 14 + const url = item.cardData.href?.trim(); 15 + if (!url) return; 16 + 17 + const id = matcher(url); 18 + if (!id) { 19 + errorMessage = 'Please enter a valid YouTube URL'; 20 + return; 21 + } 22 + 23 + item.cardData.youtubeId = id; 24 + item.cardData.poster = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; 25 + item.cardData.showInline = true; 26 + 27 + item.w = 4; 28 + item.mobileW = 8; 29 + item.h = 3; 30 + item.mobileH = 5; 31 + 32 + oncreate?.(); 33 + }} 34 + class="flex flex-col gap-2" 35 + > 36 + <Subheading>Enter a YouTube URL</Subheading> 37 + <Input 38 + bind:value={item.cardData.href} 39 + placeholder="https://youtube.com/watch?v=..." 40 + class="mt-4" 41 + /> 42 + 43 + {#if errorMessage} 44 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 45 + {/if} 46 + 47 + <div class="mt-4 flex justify-end gap-2"> 48 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 49 + <Button type="submit">Create</Button> 50 + </div> 51 + </form> 52 + </Modal>
+13 -1
src/lib/cards/YoutubeVideoCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 + import CreateYoutubeCardModal from './CreateYoutubeCardModal.svelte'; 2 3 import YoutubeCard from './YoutubeCard.svelte'; 3 4 import YoutubeCardSettings from './YoutubeCardSettings.svelte'; 4 5 ··· 6 7 type: 'youtubeVideo', 7 8 contentComponent: YoutubeCard, 8 9 settingsComponent: YoutubeCardSettings, 10 + creationModalComponent: CreateYoutubeCardModal, 9 11 createNew: (card) => { 10 12 card.cardType = 'youtubeVideo'; 11 13 card.cardData = {}; ··· 51 53 52 54 return item; 53 55 }, 54 - name: 'Youtube Video' 56 + name: 'Youtube Video', 57 + 58 + keywords: ['video', 'yt', 'stream', 'watch'], 59 + groups: ['Media'], 60 + 61 + icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-3" viewBox="0 0 256 180" 62 + ><path 63 + fill="currentColor" 64 + 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" 65 + /><path fill="currentColor" class="invert" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg 66 + >` 55 67 } as CardDefinition & { type: 'youtubeVideo' }; 56 68 57 69 // Thanks to eleventy-plugin-youtube-embed
+8 -4
src/lib/cards/index.ts
··· 15 15 import { UpdatedBlentosCardDefitition } from './SpecialCards/UpdatedBlentos'; 16 16 import { TextCardDefinition } from './TextCard'; 17 17 import type { CardDefinition } from './types'; 18 - import { VideoCardDefinition } from './VideoCard'; 19 18 import { YoutubeCardDefinition } from './YoutubeVideoCard'; 20 19 import { BlueskyProfileCardDefinition } from './BlueskyProfileCard'; 21 20 import { GithubProfileCardDefitition } from './GitHubProfileCard'; ··· 30 29 import { VCardCardDefinition } from './VCardCard'; 31 30 import { DrawCardDefinition } from './DrawCard'; 32 31 import { TimerCardDefinition } from './TimerCard'; 32 + import { ClockCardDefinition } from './ClockCard'; 33 + import { CountdownCardDefinition } from './CountdownCard'; 33 34 import { SpotifyCardDefinition } from './SpotifyCard'; 34 35 import { ButtonCardDefinition } from './ButtonCard'; 35 36 import { GuestbookCardDefinition } from './GuestbookCard'; 37 + import { FriendsCardDefinition } from './FriendsCard'; 36 38 // import { Model3DCardDefinition } from './Model3DCard'; 37 39 38 40 export const AllCardDefinitions = [ 39 41 GuestbookCardDefinition, 40 42 ButtonCardDefinition, 41 43 ImageCardDefinition, 42 - VideoCardDefinition, 43 44 TextCardDefinition, 44 45 LinkCardDefinition, 45 46 BigSocialCardDefinition, ··· 49 50 LatestBlueskyPostCardDefinition, 50 51 LivestreamCardDefitition, 51 52 LivestreamEmbedCardDefitition, 52 - EmbedCardDefinition, 53 + // EmbedCardDefinition, 53 54 MapCardDefinition, 54 55 ATProtoCollectionsCardDefinition, 55 56 SectionCardDefinition, ··· 69 70 VCardCardDefinition, 70 71 DrawCardDefinition, 71 72 TimerCardDefinition, 72 - SpotifyCardDefinition 73 + ClockCardDefinition, 74 + CountdownCardDefinition, 75 + SpotifyCardDefinition, 73 76 // Model3DCardDefinition 77 + FriendsCardDefinition 74 78 ] as const; 75 79 76 80 export const CardDefinitionsByType = AllCardDefinitions.reduce(
+6 -7
src/lib/cards/types.ts
··· 13 13 onclose: () => void; 14 14 }; 15 15 16 - export type SidebarComponentProps = { 17 - onclick: () => void; 18 - }; 19 - 20 16 export type ContentComponentProps = { 21 17 item: Item; 22 18 isEditing?: boolean; ··· 32 28 creationModalComponent?: Component<CreationModalComponentProps>; 33 29 34 30 upload?: (item: Item) => Promise<Item>; // optionally upload some other data needed for this card 35 - 36 - // has to be set for a card to appear in the sidebar 37 - sidebarButtonText?: string; 38 31 39 32 // if this component exists, a settings button with a popover will be shown containing this component 40 33 settingsComponent?: Component<SettingsComponentProps>; ··· 73 66 canHaveLabel?: boolean; 74 67 75 68 migrate?: (item: Item) => void; 69 + 70 + groups?: string[]; 71 + 72 + keywords?: string[]; 73 + 74 + icon?: string; 76 75 };
+192
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 + import { isTyping } from '$lib/helper'; 6 + 7 + const CardDefGroups = [ 8 + 'Core', 9 + ...Array.from( 10 + new Set( 11 + AllCardDefinitions.map((cardDef) => cardDef.groups) 12 + .flat() 13 + .filter((g) => g) 14 + ) 15 + ) 16 + .sort() 17 + .filter((g) => g !== 'Core') 18 + ]; 19 + 20 + let { 21 + open = $bindable(false), 22 + onselect, 23 + onlink 24 + }: { 25 + open: boolean; 26 + onselect: (cardDef: CardDefinition) => void; 27 + onlink?: (url: string, cardDef: CardDefinition) => void; 28 + } = $props(); 29 + 30 + let searchValue = $state(''); 31 + 32 + let normalizedUrl = $derived.by(() => { 33 + if (!searchValue || searchValue.length < 8) return ''; 34 + try { 35 + const val = searchValue.trim(); 36 + const urlStr = val.startsWith('http') ? val : `https://${val}`; 37 + const url = new URL(urlStr); 38 + if (!url.hostname.includes('.')) return ''; 39 + return urlStr; 40 + } catch { 41 + return ''; 42 + } 43 + }); 44 + 45 + let urlMatchingCards = $derived.by(() => { 46 + if (!normalizedUrl) return []; 47 + return AllCardDefinitions.filter((d) => d.onUrlHandler) 48 + .filter((d) => { 49 + try { 50 + const testItem = { cardData: {} }; 51 + return d.onUrlHandler!(normalizedUrl, testItem as any); 52 + } catch { 53 + return false; 54 + } 55 + }) 56 + .toSorted((a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)); 57 + }); 58 + 59 + function selectUrl(cardDef: CardDefinition) { 60 + const url = normalizedUrl; 61 + open = false; 62 + searchValue = ''; 63 + onlink?.(url, cardDef); 64 + } 65 + 66 + function commandFilter(value: string, search: string, keywords?: string[]): number { 67 + if (value.startsWith('url:')) return 1; 68 + const s = search.toLowerCase(); 69 + for (const t of [value, ...(keywords ?? [])]) { 70 + if (t.toLowerCase().includes(s)) return 1; 71 + } 72 + return 0; 73 + } 74 + 75 + function handleKeydown(e: KeyboardEvent) { 76 + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { 77 + e.preventDefault(); 78 + open = true; 79 + } 80 + if (e.key === '+' && !isTyping()) { 81 + e.preventDefault(); 82 + open = true; 83 + } 84 + } 85 + </script> 86 + 87 + <svelte:document onkeydown={handleKeydown} /> 88 + 89 + <Dialog.Root bind:open> 90 + <Dialog.Portal> 91 + <Dialog.Overlay 92 + 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" 93 + /> 94 + <Dialog.Content 95 + 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" 96 + > 97 + <Dialog.Title class="sr-only">Command Menu</Dialog.Title> 98 + <Dialog.Description class="sr-only"> 99 + This is the command menu. Use the arrow keys to navigate and press ⌘K to open the search 100 + bar. 101 + </Dialog.Description> 102 + <Command.Root 103 + filter={commandFilter} 104 + 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" 105 + > 106 + <Command.Input 107 + 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" 108 + placeholder="Search for a card or paste a link..." 109 + oninput={(e) => { 110 + searchValue = e.currentTarget.value; 111 + }} 112 + /> 113 + 114 + <Command.List 115 + 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" 116 + > 117 + <Command.Viewport> 118 + <Command.Empty 119 + class="text-base-900 dark:text-base-100 flex w-full items-center justify-center pt-8 pb-6 text-sm" 120 + > 121 + No results found. 122 + </Command.Empty> 123 + 124 + {#if urlMatchingCards.length > 0} 125 + <Command.Group> 126 + <Command.GroupHeading 127 + class="text-base-600 dark:text-base-400 px-3 pt-3 pb-2 text-xs" 128 + > 129 + Add from link 130 + </Command.GroupHeading> 131 + <Command.GroupItems> 132 + {#each urlMatchingCards as cardDef (cardDef.type)} 133 + <Command.Item 134 + value="url:{cardDef.type}" 135 + onSelect={() => { 136 + selectUrl(cardDef); 137 + }} 138 + 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" 139 + > 140 + {#if cardDef.icon} 141 + <div class="text-base-700 dark:text-base-300"> 142 + {@html cardDef.icon} 143 + </div> 144 + {/if} 145 + {cardDef.name} 146 + </Command.Item> 147 + {/each} 148 + </Command.GroupItems> 149 + </Command.Group> 150 + <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" /> 151 + {/if} 152 + 153 + {#each CardDefGroups as group, index (group)} 154 + {#if group && AllCardDefinitions.some((cardDef) => cardDef.groups?.includes(group))} 155 + <Command.Group> 156 + <Command.GroupHeading 157 + class="text-base-600 dark:text-base-400 px-3 pt-4 pb-2 text-xs" 158 + > 159 + {group} 160 + </Command.GroupHeading> 161 + <Command.GroupItems> 162 + {#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef (cardDef.type)} 163 + <Command.Item 164 + onSelect={() => { 165 + open = false; 166 + searchValue = ''; 167 + onselect(cardDef); 168 + }} 169 + 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" 170 + keywords={[group, cardDef.type, ...(cardDef.keywords || [])]} 171 + > 172 + {#if cardDef.icon} 173 + <div class="text-base-700 dark:text-base-300"> 174 + {@html cardDef.icon} 175 + </div> 176 + {/if} 177 + {cardDef.name} 178 + </Command.Item> 179 + {/each} 180 + </Command.GroupItems> 181 + </Command.Group> 182 + {#if index < CardDefGroups.length - 1} 183 + <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" /> 184 + {/if} 185 + {/if} 186 + {/each} 187 + </Command.Viewport> 188 + </Command.List> 189 + </Command.Root> 190 + </Dialog.Content> 191 + </Dialog.Portal> 192 + </Dialog.Root>
+24
src/lib/helper.ts
··· 57 57 const pushDownCascade = (target: Item, blocker: Item) => { 58 58 // Keep x fixed always when pushing down 59 59 const fixedX = mobile ? target.mobileX : target.x; 60 + const prevY = mobile ? target.mobileY : target.y; 60 61 61 62 // We need target to move just below `blocker` 62 63 const desiredY = mobile ? blocker.mobileY + blocker.mobileH : blocker.y + blocker.h; 63 64 if (!mobile && target.y < desiredY) target.y = desiredY; 64 65 if (mobile && target.mobileY < desiredY) target.mobileY = desiredY; 66 + 67 + const newY = mobile ? target.mobileY : target.y; 68 + const targetH = mobile ? target.mobileH : target.h; 69 + 70 + // fall trough fix 71 + if (newY > prevY) { 72 + const prevBottom = prevY + targetH; 73 + const newBottom = newY + targetH; 74 + for (const it of items) { 75 + if (it === target || it === movedItem || it === blocker) continue; 76 + const itY = mobile ? it.mobileY : it.y; 77 + const itH = mobile ? it.mobileH : it.h; 78 + const itBottom = itY + itH; 79 + if (itBottom <= prevBottom || itY >= newBottom) continue; 80 + // horizontal overlap check 81 + const hOverlap = mobile 82 + ? target.mobileX < it.mobileX + it.mobileW && target.mobileX + target.mobileW > it.mobileX 83 + : target.x < it.x + it.w && target.x + target.w > it.x; 84 + if (hOverlap) { 85 + pushDownCascade(it, target); 86 + } 87 + } 88 + } 65 89 66 90 // Now resolve any collisions that creates by pushing those items down first 67 91 // Repeat until target is clean.
+3
src/lib/types.ts
··· 55 55 // theme colors 56 56 accentColor?: string; 57 57 baseColor?: string; 58 + 59 + // layout mirroring: 0/undefined=never edited, 1=desktop only, 2=mobile only, 3=both 60 + editedOn?: number; 58 61 }; 59 62 }; 60 63 profile: AppBskyActorDefs.ProfileViewDetailed;
+257 -144
src/lib/website/EditBar.svelte
··· 1 1 <script lang="ts"> 2 2 import { dev } from '$app/environment'; 3 3 import { user } from '$lib/atproto'; 4 - import type { WebsiteData } from '$lib/types'; 4 + import { COLUMNS } from '$lib'; 5 + import type { Item, WebsiteData } from '$lib/types'; 6 + import { CardDefinitionsByType } from '$lib/cards'; 5 7 import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core'; 8 + import { ColorSelect } from '@foxui/colors'; 6 9 7 10 let { 8 11 data, 9 - linkValue = $bindable(), 10 - newCard, 11 - addLink, 12 12 13 13 showingMobileView = $bindable(), 14 14 isSaving = $bindable(), ··· 17 17 save, 18 18 19 19 handleImageInputChange, 20 - handleVideoInputChange 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 21 33 }: { 22 34 data: WebsiteData; 23 - linkValue: string; 24 - newCard: (type: string) => void; 25 - addLink: (url: string) => void; 26 35 27 36 showingMobileView: boolean; 28 37 ··· 33 42 34 43 handleImageInputChange: (evt: Event) => void; 35 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; 36 57 } = $props(); 37 58 38 59 let linkPopoverOpen = $state(false); 39 - 40 60 let imageInputRef: HTMLInputElement | undefined = $state(); 41 61 let videoInputRef: HTMLInputElement | undefined = $state(); 42 62 ··· 52 72 await navigator.clipboard.writeText(url); 53 73 toast.success('Link copied to clipboard!'); 54 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); 55 127 </script> 56 128 57 129 <input ··· 59 131 accept="image/*" 60 132 onchange={handleImageInputChange} 61 133 class="hidden" 134 + id="image-input" 62 135 multiple 63 136 bind:this={imageInputRef} 64 137 /> ··· 68 141 accept="video/*" 69 142 onchange={handleVideoInputChange} 70 143 class="hidden" 144 + id="video-input" 71 145 multiple 72 146 bind:this={videoInputRef} 73 147 /> 74 148 75 149 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 76 150 <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 - ]} 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" 81 152 > 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> 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> 102 258 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 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?.()} 120 299 > 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 300 + <svg 163 301 xmlns="http://www.w3.org/2000/svg" 164 302 fill="none" 165 303 viewBox="0 0 24 24" 166 - stroke-width="2" 304 + stroke-width="1.5" 167 305 stroke="currentColor" 168 - class="size-6" 306 + class="size-5" 169 307 > 170 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 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 + /> 171 313 </svg> 172 314 </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 315 <Button 200 316 size="iconLg" 201 317 variant="ghost" 202 318 class="backdrop-blur-none" 203 - onclick={() => { 204 - videoInputRef?.click(); 205 - }} 319 + onclick={() => ondeselect?.()} 206 320 > 207 321 <svg 208 322 xmlns="http://www.w3.org/2000/svg" 209 323 fill="none" 210 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 + <div class="flex items-center gap-2"> 335 + <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 336 + <svg 337 + xmlns="http://www.w3.org/2000/svg" 338 + fill="none" 339 + viewBox="0 0 24 24" 211 340 stroke-width="1.5" 212 341 stroke="currentColor" 213 342 > 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 - /> 343 + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> 219 344 </svg> 220 345 </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"> 346 + </div> 347 + {/if} 348 + <div class={['flex items-center gap-2', showMobileEditControls ? 'hidden' : '']}> 236 349 <Toggle 237 350 class="hidden bg-transparent backdrop-blur-none lg:block dark:bg-transparent" 238 351 bind:pressed={showingMobileView}
+572 -52
src/lib/website/EditableWebsite.svelte
··· 7 7 compactItems, 8 8 createEmptyCard, 9 9 findValidPosition, 10 + fixAllCollisions, 10 11 fixCollisions, 11 12 getHideProfileSection, 12 13 getProfilePosition, ··· 24 25 import EditingCard from '../cards/Card/EditingCard.svelte'; 25 26 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 26 27 import { tick, type Component } from 'svelte'; 27 - import type { CreationModalComponentProps } from '../cards/types'; 28 + import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 28 29 import { dev } from '$app/environment'; 29 - import { setIsMobile } from './context'; 30 + import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; 30 31 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; 31 32 import Context from './Context.svelte'; 32 33 import Head from './Head.svelte'; ··· 35 36 import EditBar from './EditBar.svelte'; 36 37 import SaveModal from './SaveModal.svelte'; 37 38 import FloatingEditButton from './FloatingEditButton.svelte'; 38 - import { user } from '$lib/atproto'; 39 + import { user, resolveHandle, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; 40 + import * as TID from '@atcute/tid'; 39 41 import { launchConfetti } from '@foxui/visual'; 40 42 import Controls from './Controls.svelte'; 43 + import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 44 + import { shouldMirror, mirrorLayout } from './layout-mirror'; 45 + import { SvelteMap } from 'svelte/reactivity'; 41 46 42 47 let { 43 48 data ··· 47 52 48 53 // Check if floating login button will be visible (to hide MadeWithBlento) 49 54 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 55 54 56 function updateTheme(newAccent: string, newBase: string) { 55 57 data.publication.preferences ??= {}; ··· 124 126 125 127 setIsMobile(() => isMobile); 126 128 129 + // svelte-ignore state_referenced_locally 130 + let editedOn = $state(data.publication.preferences?.editedOn ?? 0); 131 + 132 + function onLayoutChanged() { 133 + // Set the bit for the current layout: desktop=1, mobile=2 134 + editedOn = editedOn | (isMobile ? 2 : 1); 135 + if (shouldMirror(editedOn)) { 136 + mirrorLayout(items, isMobile); 137 + } 138 + } 139 + 140 + const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; 141 + setIsCoarse(() => isCoarse); 142 + 143 + let selectedCardId: string | null = $state(null); 144 + let selectedCard = $derived( 145 + selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null 146 + ); 147 + 148 + setSelectedCardId(() => selectedCardId); 149 + setSelectCard((id: string | null) => { 150 + selectedCardId = id; 151 + }); 152 + 127 153 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); 128 154 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h); 129 155 ··· 140 166 } 141 167 142 168 function newCard(type: string = 'link', cardData?: any) { 169 + selectedCardId = null; 170 + 143 171 // close sidebar if open 144 172 const popover = document.getElementById('mobile-menu'); 145 173 if (popover) { ··· 178 206 compactItems(items, false); 179 207 compactItems(items, true); 180 208 209 + onLayoutChanged(); 210 + 181 211 newItem = {}; 182 212 183 213 await tick(); ··· 202 232 await checkAndUploadImage(data.publication, 'icon'); 203 233 } 204 234 235 + // Persist layout editing state 236 + data.publication.preferences ??= {}; 237 + data.publication.preferences.editedOn = editedOn; 238 + 205 239 await savePage(data, items, publication); 206 240 207 241 publication = JSON.stringify(data.publication); ··· 225 259 } 226 260 } 227 261 228 - const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.sidebarButtonText); 262 + const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.name); 229 263 230 - let debugPoint = $state({ x: 0, y: 0 }); 264 + function addAllCardTypes() { 265 + const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games']; 266 + const grouped = new SvelteMap<string, CardDefinition[]>(); 231 267 232 - function getDragXY( 233 - e: DragEvent & { 234 - currentTarget: EventTarget & HTMLDivElement; 268 + for (const def of AllCardDefinitions) { 269 + if (!def.name) continue; 270 + const group = def.groups?.[0] ?? 'Other'; 271 + if (!grouped.has(group)) grouped.set(group, []); 272 + grouped.get(group)!.push(def); 235 273 } 274 + 275 + // Sort groups by predefined order, unknowns at end 276 + const sortedGroups = [...grouped.keys()].sort((a, b) => { 277 + const ai = groupOrder.indexOf(a); 278 + const bi = groupOrder.indexOf(b); 279 + return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); 280 + }); 281 + 282 + // Sample data for cards that would otherwise render empty 283 + const sampleData: Record<string, Record<string, unknown>> = { 284 + text: { text: 'The quick brown fox jumps over the lazy dog. This is a sample text card.' }, 285 + link: { 286 + href: 'https://bsky.app', 287 + title: 'Bluesky', 288 + domain: 'bsky.app', 289 + description: 'Social networking that gives you choice', 290 + hasFetched: true 291 + }, 292 + image: { 293 + image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600', 294 + alt: 'Mountain landscape' 295 + }, 296 + button: { text: 'Visit Bluesky', href: 'https://bsky.app' }, 297 + bigsocial: { platform: 'bluesky', href: 'https://bsky.app', color: '0085ff' }, 298 + blueskyPost: { 299 + uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jt64kgkbbs2y', 300 + href: 'https://bsky.app/profile/bsky.app/post/3jt64kgkbbs2y' 301 + }, 302 + blueskyProfile: { 303 + handle: 'bsky.app', 304 + displayName: 'Bluesky', 305 + avatar: 306 + 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4pcnbaq53m@jpeg' 307 + }, 308 + blueskyMedia: {}, 309 + latestPost: {}, 310 + youtubeVideo: { 311 + youtubeId: 'dQw4w9WgXcQ', 312 + poster: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 313 + href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 314 + showInline: true 315 + }, 316 + 'spotify-list-embed': { 317 + spotifyType: 'album', 318 + spotifyId: '4aawyAB9vmqN3uQ7FjRGTy', 319 + href: 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy' 320 + }, 321 + latestLivestream: {}, 322 + livestreamEmbed: { 323 + href: 'https://stream.place/', 324 + embed: 'https://stream.place/embed/' 325 + }, 326 + mapLocation: { lat: 48.8584, lon: 2.2945, zoom: 13, name: 'Eiffel Tower, Paris' }, 327 + gif: { url: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.mp4', alt: 'Cat typing' }, 328 + event: { 329 + uri: 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mcsoqzy7gm2q' 330 + }, 331 + guestbook: { label: 'Guestbook' }, 332 + githubProfile: { user: 'sveltejs', href: 'https://github.com/sveltejs' }, 333 + photoGallery: { 334 + galleryUri: 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w' 335 + }, 336 + atprotocollections: {}, 337 + publicationList: {}, 338 + recentPopfeedReviews: {}, 339 + recentTealFMPlays: {}, 340 + statusphere: { emoji: '✨' }, 341 + vcard: {}, 342 + 'fluid-text': { text: 'Hello World' }, 343 + draw: { strokesJson: '[]', viewBox: '', strokeWidth: 1, locked: true }, 344 + clock: {}, 345 + countdown: { targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() }, 346 + timer: {}, 347 + 'dino-game': {}, 348 + tetris: {}, 349 + updatedBlentos: {} 350 + }; 351 + 352 + // Labels for cards that support canHaveLabel 353 + const sampleLabels: Record<string, string> = { 354 + image: 'Mountain Landscape', 355 + mapLocation: 'Eiffel Tower', 356 + gif: 'Cat Typing', 357 + bigsocial: 'Bluesky', 358 + guestbook: 'Guestbook', 359 + statusphere: 'My Status', 360 + recentPopfeedReviews: 'My Reviews', 361 + recentTealFMPlays: 'Recently Played', 362 + clock: 'Local Time', 363 + countdown: 'Launch Day', 364 + timer: 'Timer', 365 + 'dino-game': 'Dino Game', 366 + tetris: 'Tetris', 367 + blueskyMedia: 'Bluesky Media' 368 + }; 369 + 370 + const newItems: Item[] = []; 371 + let cursorY = 0; 372 + let mobileCursorY = 0; 373 + 374 + for (const group of sortedGroups) { 375 + const defs = grouped.get(group)!; 376 + 377 + // Add a section heading for the group 378 + const heading = createEmptyCard(data.page); 379 + heading.cardType = 'section'; 380 + heading.cardData = { text: group, verticalAlign: 'bottom', textSize: 1 }; 381 + heading.w = COLUMNS; 382 + heading.h = 1; 383 + heading.x = 0; 384 + heading.y = cursorY; 385 + heading.mobileW = COLUMNS; 386 + heading.mobileH = 2; 387 + heading.mobileX = 0; 388 + heading.mobileY = mobileCursorY; 389 + newItems.push(heading); 390 + cursorY += 1; 391 + mobileCursorY += 2; 392 + 393 + // Place cards in rows 394 + let rowX = 0; 395 + let rowMaxH = 0; 396 + let mobileRowX = 0; 397 + let mobileRowMaxH = 0; 398 + 399 + for (const def of defs) { 400 + if (def.type === 'section' || def.type === 'embed') continue; 401 + 402 + const item = createEmptyCard(data.page); 403 + item.cardType = def.type; 404 + item.cardData = {}; 405 + def.createNew?.(item); 406 + 407 + // Merge in sample data (without overwriting createNew defaults) 408 + const extra = sampleData[def.type]; 409 + if (extra) { 410 + item.cardData = { ...item.cardData, ...extra }; 411 + } 412 + 413 + // Set item-level color for cards that need it 414 + if (def.type === 'button') { 415 + item.color = 'transparent'; 416 + } 417 + 418 + // Add label if card supports it 419 + const label = sampleLabels[def.type]; 420 + if (label && def.canHaveLabel) { 421 + item.cardData.label = label; 422 + } 423 + 424 + // Desktop layout 425 + if (rowX + item.w > COLUMNS) { 426 + cursorY += rowMaxH; 427 + rowX = 0; 428 + rowMaxH = 0; 429 + } 430 + item.x = rowX; 431 + item.y = cursorY; 432 + rowX += item.w; 433 + rowMaxH = Math.max(rowMaxH, item.h); 434 + 435 + // Mobile layout 436 + if (mobileRowX + item.mobileW > COLUMNS) { 437 + mobileCursorY += mobileRowMaxH; 438 + mobileRowX = 0; 439 + mobileRowMaxH = 0; 440 + } 441 + item.mobileX = mobileRowX; 442 + item.mobileY = mobileCursorY; 443 + mobileRowX += item.mobileW; 444 + mobileRowMaxH = Math.max(mobileRowMaxH, item.mobileH); 445 + 446 + newItems.push(item); 447 + } 448 + 449 + // Move cursor past last row 450 + cursorY += rowMaxH; 451 + mobileCursorY += mobileRowMaxH; 452 + } 453 + 454 + items = newItems; 455 + onLayoutChanged(); 456 + } 457 + 458 + let copyInput = $state(''); 459 + let isCopying = $state(false); 460 + 461 + async function copyPageFrom() { 462 + const input = copyInput.trim(); 463 + if (!input) return; 464 + 465 + isCopying = true; 466 + try { 467 + // Parse "handle" or "handle/page" 468 + const parts = input.split('/'); 469 + const handle = parts[0]; 470 + const pageName = parts[1] || 'self'; 471 + 472 + const did = await resolveHandle({ handle: handle as `${string}.${string}` }); 473 + if (!did) throw new Error('Could not resolve handle'); 474 + 475 + const records = await listRecords({ did, collection: 'app.blento.card' }); 476 + const targetPage = 'blento.' + pageName; 477 + 478 + const copiedCards: Item[] = records 479 + .map((r) => ({ ...r.value }) as Item) 480 + .filter((card) => { 481 + // v0/v1 cards without page field belong to blento.self 482 + if (!card.page) return targetPage === 'blento.self'; 483 + return card.page === targetPage; 484 + }) 485 + .map((card) => { 486 + // Apply v0→v1 migration (coords were halved in old format) 487 + if (!card.version) { 488 + card.x *= 2; 489 + card.y *= 2; 490 + card.h *= 2; 491 + card.w *= 2; 492 + card.mobileX *= 2; 493 + card.mobileY *= 2; 494 + card.mobileH *= 2; 495 + card.mobileW *= 2; 496 + card.version = 1; 497 + } 498 + 499 + // Convert blob refs to CDN URLs using source DID 500 + if (card.cardData) { 501 + for (const key of Object.keys(card.cardData)) { 502 + const val = card.cardData[key]; 503 + if (val && typeof val === 'object' && val.$type === 'blob') { 504 + const url = getCDNImageBlobUrl({ did, blob: val }); 505 + if (url) card.cardData[key] = url; 506 + } 507 + } 508 + } 509 + 510 + // Regenerate ID and assign to current page 511 + card.id = TID.now(); 512 + card.page = data.page; 513 + return card; 514 + }); 515 + 516 + if (copiedCards.length === 0) { 517 + toast.error('No cards found on that page'); 518 + return; 519 + } 520 + 521 + fixAllCollisions(copiedCards); 522 + fixAllCollisions(copiedCards, true); 523 + compactItems(copiedCards); 524 + compactItems(copiedCards, true); 525 + 526 + items = copiedCards; 527 + onLayoutChanged(); 528 + toast.success(`Copied ${copiedCards.length} cards from ${handle}`); 529 + } catch (e) { 530 + console.error('Failed to copy page:', e); 531 + toast.error('Failed to copy page'); 532 + } finally { 533 + isCopying = false; 534 + } 535 + } 536 + 537 + let debugPoint = $state({ x: 0, y: 0 }); 538 + 539 + function getGridPosition( 540 + clientX: number, 541 + clientY: number 236 542 ): 237 543 | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } 238 544 | undefined { 239 545 if (!container || !activeDragElement.item) return; 240 546 241 547 // 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; 548 + const x = clientX + activeDragElement.mouseDeltaX; 549 + const y = clientY + activeDragElement.mouseDeltaY; 244 550 245 551 const rect = container.getBoundingClientRect(); 246 552 const currentMargin = isMobile ? mobileMargin : margin; ··· 362 668 return { x: gridX, y: gridY, swapWithId, placement }; 363 669 } 364 670 671 + function getDragXY( 672 + e: DragEvent & { 673 + currentTarget: EventTarget & HTMLDivElement; 674 + } 675 + ) { 676 + return getGridPosition(e.clientX, e.clientY); 677 + } 678 + 679 + // Touch drag system (instant drag on selected card) 680 + let touchDragActive = $state(false); 681 + 682 + function touchStart(e: TouchEvent) { 683 + if (!selectedCardId || !container) return; 684 + const touch = e.touches[0]; 685 + if (!touch) return; 686 + 687 + // Check if the touch is on the selected card element 688 + const target = (e.target as HTMLElement)?.closest?.('.card'); 689 + if (!target || target.id !== selectedCardId) return; 690 + 691 + const item = items.find((i) => i.id === selectedCardId); 692 + if (!item || item.cardData?.locked) return; 693 + 694 + // Start dragging immediately 695 + touchDragActive = true; 696 + 697 + const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; 698 + if (!cardEl) return; 699 + 700 + activeDragElement.element = cardEl; 701 + activeDragElement.w = item.w; 702 + activeDragElement.h = item.h; 703 + activeDragElement.item = item; 704 + 705 + // Store original positions of all items 706 + activeDragElement.originalPositions = new Map(); 707 + for (const it of items) { 708 + activeDragElement.originalPositions.set(it.id, { 709 + x: it.x, 710 + y: it.y, 711 + mobileX: it.mobileX, 712 + mobileY: it.mobileY 713 + }); 714 + } 715 + 716 + const rect = cardEl.getBoundingClientRect(); 717 + activeDragElement.mouseDeltaX = rect.left - touch.clientX; 718 + activeDragElement.mouseDeltaY = rect.top - touch.clientY; 719 + } 720 + 721 + function touchMove(e: TouchEvent) { 722 + if (!touchDragActive) return; 723 + 724 + const touch = e.touches[0]; 725 + if (!touch) return; 726 + 727 + e.preventDefault(); 728 + 729 + const result = getGridPosition(touch.clientX, touch.clientY); 730 + if (!result || !activeDragElement.item) return; 731 + 732 + const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id); 733 + 734 + // Reset all items to original positions first 735 + for (const it of items) { 736 + const origPos = activeDragElement.originalPositions.get(it.id); 737 + if (origPos && it !== activeDragElement.item) { 738 + if (isMobile) { 739 + it.mobileX = origPos.mobileX; 740 + it.mobileY = origPos.mobileY; 741 + } else { 742 + it.x = origPos.x; 743 + it.y = origPos.y; 744 + } 745 + } 746 + } 747 + 748 + // Update dragged item position 749 + if (isMobile) { 750 + activeDragElement.item.mobileX = result.x; 751 + activeDragElement.item.mobileY = result.y; 752 + } else { 753 + activeDragElement.item.x = result.x; 754 + activeDragElement.item.y = result.y; 755 + } 756 + 757 + // Handle horizontal swap 758 + if (result.swapWithId && draggedOrigPos) { 759 + const swapTarget = items.find((it) => it.id === result.swapWithId); 760 + if (swapTarget) { 761 + if (isMobile) { 762 + swapTarget.mobileX = draggedOrigPos.mobileX; 763 + swapTarget.mobileY = draggedOrigPos.mobileY; 764 + } else { 765 + swapTarget.x = draggedOrigPos.x; 766 + swapTarget.y = draggedOrigPos.y; 767 + } 768 + } 769 + } 770 + 771 + fixCollisions(items, activeDragElement.item, isMobile); 772 + 773 + // Auto-scroll near edges 774 + const scrollZone = 100; 775 + const scrollSpeed = 10; 776 + const viewportHeight = window.innerHeight; 777 + 778 + if (touch.clientY < scrollZone) { 779 + const intensity = 1 - touch.clientY / scrollZone; 780 + window.scrollBy(0, -scrollSpeed * intensity); 781 + } else if (touch.clientY > viewportHeight - scrollZone) { 782 + const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; 783 + window.scrollBy(0, scrollSpeed * intensity); 784 + } 785 + } 786 + 787 + function touchEnd() { 788 + if (touchDragActive && activeDragElement.item) { 789 + // Finalize position 790 + fixCollisions(items, activeDragElement.item, isMobile); 791 + onLayoutChanged(); 792 + 793 + activeDragElement.x = -1; 794 + activeDragElement.y = -1; 795 + activeDragElement.element = null; 796 + activeDragElement.item = null; 797 + activeDragElement.lastTargetId = null; 798 + activeDragElement.lastPlacement = null; 799 + } 800 + 801 + touchDragActive = false; 802 + } 803 + 804 + // Only register non-passive touchmove when actively dragging 805 + $effect(() => { 806 + const el = container; 807 + if (!touchDragActive || !el) return; 808 + 809 + el.addEventListener('touchmove', touchMove, { passive: false }); 810 + return () => { 811 + el.removeEventListener('touchmove', touchMove); 812 + }; 813 + }); 814 + 365 815 let linkValue = $state(''); 366 816 367 - function addLink(url: string) { 817 + function addLink(url: string, specificCardDef?: CardDefinition) { 368 818 let link = validateLink(url); 369 819 if (!link) { 370 820 toast.error('invalid link'); ··· 372 822 } 373 823 let item = createEmptyCard(data.page); 374 824 825 + if (specificCardDef?.onUrlHandler?.(link, item)) { 826 + item.cardType = specificCardDef.type; 827 + newItem.item = item; 828 + saveNewItem(); 829 + toast(specificCardDef.name + ' added!'); 830 + return; 831 + } 832 + 375 833 for (const cardDef of AllCardDefinitions.toSorted( 376 834 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 377 835 )) { ··· 384 842 break; 385 843 } 386 844 } 387 - 388 - if (linkValue === url) { 389 - linkValue = ''; 390 - } 391 845 } 392 846 393 847 function getImageDimensions(src: string): Promise<{ width: number; height: number }> { ··· 489 943 compactItems(items, false); 490 944 compactItems(items, true); 491 945 } 946 + 947 + onLayoutChanged(); 492 948 493 949 await tick(); 494 950 ··· 625 1081 compactItems(items, false); 626 1082 compactItems(items, true); 627 1083 1084 + onLayoutChanged(); 1085 + 628 1086 await tick(); 629 1087 630 1088 scrollToItem(item, isMobile, container); ··· 644 1102 target.value = ''; 645 1103 } 646 1104 647 - // $inspect(items); 1105 + let showCardCommand = $state(false); 648 1106 </script> 649 1107 650 1108 <svelte:body ··· 676 1134 <Account {data} /> 677 1135 678 1136 <Context {data}> 679 - {#if !dev} 680 - <div 681 - class="bg-base-200 dark:bg-base-800 fixed inset-0 z-50 inline-flex h-full w-full items-center justify-center p-4 text-center lg:hidden" 682 - > 683 - Editing on mobile is not supported yet. Please use a desktop browser. 684 - </div> 685 - {/if} 1137 + <CardCommand 1138 + bind:open={showCardCommand} 1139 + onselect={(cardDef: CardDefinition) => { 1140 + if (cardDef.type === 'image') { 1141 + const input = document.getElementById('image-input') as HTMLInputElement; 1142 + if (input) { 1143 + input.click(); 1144 + return; 1145 + } 1146 + } else if (cardDef.type === 'video') { 1147 + const input = document.getElementById('video-input') as HTMLInputElement; 1148 + if (input) { 1149 + input.click(); 1150 + return; 1151 + } 1152 + } else { 1153 + newCard(cardDef.type); 1154 + } 1155 + }} 1156 + onlink={(url, cardDef) => { 1157 + addLink(url, cardDef); 1158 + }} 1159 + /> 686 1160 687 1161 <Controls bind:data /> 688 1162 ··· 732 1206 ]} 733 1207 > 734 1208 <div class="pointer-events-none"></div> 735 - <!-- svelte-ignore a11y_no_static_element_interactions --> 1209 + <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 736 1210 <div 737 1211 bind:this={container} 1212 + onclick={(e) => { 1213 + // Deselect when tapping empty grid space 1214 + if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { 1215 + selectedCardId = null; 1216 + } 1217 + }} 1218 + ontouchstart={touchStart} 1219 + ontouchend={touchEnd} 738 1220 ondragover={(e) => { 739 1221 e.preventDefault(); 740 1222 ··· 809 1291 }} 810 1292 ondragend={async (e) => { 811 1293 e.preventDefault(); 812 - const cell = getDragXY(e); 813 - if (!cell) return; 814 - 815 - if (activeDragElement.item) { 816 - if (isMobile) { 817 - activeDragElement.item.mobileX = cell.x; 818 - activeDragElement.item.mobileY = cell.y; 819 - } else { 820 - activeDragElement.item.x = cell.x; 821 - activeDragElement.item.y = cell.y; 822 - } 823 - 824 - // Fix collisions and compact items after drag ends 825 - fixCollisions(items, activeDragElement.item, isMobile); 826 - } 1294 + // safari fix 827 1295 activeDragElement.x = -1; 828 1296 activeDragElement.y = -1; 829 1297 activeDragElement.element = null; ··· 845 1313 items = items.filter((it) => it !== item); 846 1314 compactItems(items, false); 847 1315 compactItems(items, true); 1316 + onLayoutChanged(); 848 1317 }} 849 1318 onsetsize={(newW: number, newH: number) => { 850 1319 if (isMobile) { ··· 856 1325 } 857 1326 858 1327 fixCollisions(items, item, isMobile); 1328 + onLayoutChanged(); 859 1329 }} 860 1330 ondragstart={(e: DragEvent) => { 861 1331 const target = e.currentTarget as HTMLDivElement; ··· 863 1333 activeDragElement.w = item.w; 864 1334 activeDragElement.h = item.h; 865 1335 activeDragElement.item = item; 1336 + // fix for div shadow during drag and drop 1337 + const transparent = document.createElement('div'); 1338 + transparent.style.position = 'fixed'; 1339 + transparent.style.top = '-1000px'; 1340 + transparent.style.width = '1px'; 1341 + transparent.style.height = '1px'; 1342 + document.body.appendChild(transparent); 1343 + e.dataTransfer?.setDragImage(transparent, 0, 0); 1344 + requestAnimationFrame(() => transparent.remove()); 866 1345 867 1346 // Store original positions of all items 868 1347 activeDragElement.originalPositions = new Map(); ··· 890 1369 </div> 891 1370 </div> 892 1371 893 - <Sidebar mobileOnly mobileClasses="lg:block p-4 gap-4"> 894 - <div class="flex flex-col gap-2"> 895 - {#each sidebarItems as cardDef (cardDef.type)} 896 - <Button onclick={() => newCard(cardDef.type)} variant="ghost" class="w-full justify-start" 897 - >{cardDef.sidebarButtonText}</Button 898 - > 899 - {/each} 900 - </div> 901 - </Sidebar> 902 - 903 1372 <EditBar 904 1373 {data} 905 1374 bind:linkValue ··· 911 1380 {save} 912 1381 {handleImageInputChange} 913 1382 {handleVideoInputChange} 1383 + showCardCommand={() => { 1384 + showCardCommand = true; 1385 + }} 1386 + {selectedCard} 1387 + {isMobile} 1388 + {isCoarse} 1389 + ondeselect={() => { 1390 + selectedCardId = null; 1391 + }} 1392 + ondelete={() => { 1393 + if (selectedCard) { 1394 + items = items.filter((it) => it.id !== selectedCardId); 1395 + compactItems(items, false); 1396 + compactItems(items, true); 1397 + onLayoutChanged(); 1398 + selectedCardId = null; 1399 + } 1400 + }} 1401 + onsetsize={(w: number, h: number) => { 1402 + if (selectedCard) { 1403 + if (isMobile) { 1404 + selectedCard.mobileW = w; 1405 + selectedCard.mobileH = h; 1406 + } else { 1407 + selectedCard.w = w; 1408 + selectedCard.h = h; 1409 + } 1410 + fixCollisions(items, selectedCard, isMobile); 1411 + onLayoutChanged(); 1412 + } 1413 + }} 914 1414 /> 915 1415 916 1416 <Toaster /> 917 1417 918 1418 <FloatingEditButton {data} /> 1419 + 1420 + {#if dev} 1421 + <div 1422 + class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 flex items-center gap-2 rounded px-2 py-1 font-mono text-xs" 1423 + > 1424 + <span>editedOn: {editedOn}</span> 1425 + <button class="underline" onclick={addAllCardTypes}>+ all cards</button> 1426 + <input 1427 + bind:value={copyInput} 1428 + placeholder="handle/page" 1429 + class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5" 1430 + onkeydown={(e) => { 1431 + if (e.key === 'Enter') copyPageFrom(); 1432 + }} 1433 + /> 1434 + <button class="underline" onclick={copyPageFrom} disabled={isCopying}> 1435 + {isCopying ? 'copying...' : 'copy'} 1436 + </button> 1437 + </div> 1438 + {/if} 919 1439 </Context>
+3
src/lib/website/context.ts
··· 7 7 export const [getCanEdit, setCanEdit] = createContext<() => boolean>(); 8 8 export const [getAdditionalUserData, setAdditionalUserData] = 9 9 createContext<Record<string, unknown>>(); 10 + export const [getIsCoarse, setIsCoarse] = createContext<() => boolean>(); 11 + export const [getSelectedCardId, setSelectedCardId] = createContext<() => string | null>(); 12 + export const [getSelectCard, setSelectCard] = createContext<(id: string | null) => void>();
+72
src/lib/website/layout-mirror.ts
··· 1 + import { COLUMNS } from '$lib'; 2 + import { CardDefinitionsByType } from '$lib/cards'; 3 + import { clamp, findValidPosition, 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 + 27 + if (fromMobile) { 28 + // Mobile → Desktop: halve both dimensions, then clamp to card def constraints 29 + // (constraints are in desktop units) 30 + item.w = clamp(snapEven(item.mobileW / 2), def?.minW ?? 2, def?.maxW ?? COLUMNS); 31 + item.h = clamp(Math.round(item.mobileH / 2), def?.minH ?? 1, def?.maxH ?? Infinity); 32 + } else { 33 + // Desktop → Mobile: double both dimensions 34 + // (don't apply card def constraints — they're in desktop units) 35 + item.mobileW = Math.min(item.w * 2, COLUMNS); 36 + item.mobileH = Math.max(item.h * 2, 2); 37 + } 38 + } 39 + 40 + /** 41 + * Mirror the full layout from one view to the other. 42 + * Copies sizes proportionally and maps positions, then resolves collisions. 43 + * Mutates items in-place. 44 + */ 45 + export function mirrorLayout(items: Item[], fromMobile: boolean): void { 46 + // Mirror sizes first 47 + for (const item of items) { 48 + mirrorItemSize(item, fromMobile); 49 + } 50 + 51 + if (fromMobile) { 52 + // Mobile → Desktop: reflow items to use the full grid width. 53 + // Sort by mobile position so items are placed in reading order. 54 + const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 55 + 56 + // Place each item into the first available spot on the desktop grid 57 + const placed: Item[] = []; 58 + for (const item of sorted) { 59 + item.x = 0; 60 + item.y = 0; 61 + findValidPosition(item, placed, false); 62 + placed.push(item); 63 + } 64 + } else { 65 + // Desktop → Mobile: proportional positions 66 + for (const item of items) { 67 + item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 68 + item.mobileY = Math.max(0, Math.round(item.y * 2)); 69 + } 70 + fixAllCollisions(items, true); 71 + } 72 + }
+1
wrangler.jsonc
··· 44 44 "id": "d6ff203259de48538d332b0a5df258a7" 45 45 } 46 46 ] 47 + 47 48 /** 48 49 * Service Bindings (communicate between multiple Workers) 49 50 * https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings