your personal website on atproto - mirror blento.app

Merge pull request #1 from flo-bit/next

Next update

authored by polijn and committed by GitHub e296e5d6 0ac11b47

+589 -411
+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 }
+1 -3
src/lib/cards/ATProtoCollectionsCard/index.ts
··· 19 19 item.w = 4; 20 20 item.mobileW = 8; 21 21 }, 22 - sidebarButtonText: 'Atmosphere Collections', 23 - 24 22 name: 'ATProto Collections', 25 23 24 + keywords: ['bluesky', 'records', 'pds', 'data'], 26 25 groups: ['Social'], 27 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>` 28 - 29 27 } as CardDefinition & { type: 'atprotocollections' };
+10 -1
src/lib/cards/BigSocialCard/index.ts
··· 53 53 urlHandlerPriority: 1, 54 54 canHaveLabel: true, 55 55 56 + keywords: [ 57 + 'twitter', 58 + 'instagram', 59 + 'tiktok', 60 + 'youtube', 61 + 'github', 62 + 'discord', 63 + 'linkedin', 64 + 'mastodon' 65 + ], 56 66 groups: ['Social'], 57 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>` 58 - 59 68 } as CardDefinition & { type: 'bigsocial' }; 60 69 61 70 import {
+1 -2
src/lib/cards/BlueskyMediaCard/index.ts
··· 7 7 contentComponent: BlueskyMediaCard, 8 8 createNew: () => {}, 9 9 creationModalComponent: CreateBlueskyMediaCardModal, 10 - sidebarButtonText: 'Bluesky Media', 11 10 canHaveLabel: true, 12 11 12 + keywords: ['bsky', 'atproto', 'media', 'feed'], 13 13 groups: ['Media'], 14 14 15 15 name: 'Video/Image from Bluesky', 16 16 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0 1 18 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0 1 18 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 0 1 6 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" /></svg>` 17 - 18 17 } as CardDefinition & { type: 'blueskyMedia' };
+1 -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; ··· 65 64 minW: 4, 66 65 name: 'Bluesky Post', 67 66 67 + keywords: ['skeet', 'bsky', 'atproto', 'post'], 68 68 groups: ['Social'], 69 69 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /></svg>` 70 - 71 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' };
+1 -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' ··· 29 27 maxW: 8, 30 28 maxH: 4, 31 29 30 + keywords: ['cta', 'action', 'click', 'link'], 32 31 groups: ['Utilities'], 33 32 name: 'Button', 34 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>` 35 - 36 34 };
+1 -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, ··· 25 24 }; 26 25 }, 27 26 27 + keywords: ['paint', 'sketch', 'doodle', 'canvas', 'art'], 28 28 groups: ['Visual'], 29 29 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /></svg>` 30 - 31 30 } as CardDefinition & { type: 'draw' };
+1 -1
src/lib/cards/EmbedCard/index.ts
··· 20 20 // return item; 21 21 // }, 22 22 name: 'Embed', 23 + keywords: ['iframe', 'widget', 'html', 'website'], 23 24 groups: ['Media'], 24 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>` 25 - 26 26 } as CardDefinition & { type: 'embed' };
+1 -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; ··· 114 112 115 113 name: 'Event', 116 114 115 + keywords: ['calendar', 'meetup', 'schedule', 'date', 'rsvp'], 117 116 groups: ['Social'], 118 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>` 119 - 120 118 } as CardDefinition & { type: 'event' };
+1 -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 25 minW: 2, 27 26 27 + keywords: ['animated', 'big text', 'headline', 'display'], 28 28 groups: ['Visual'], 29 29 name: 'Fluid Text', 30 30 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></svg>` 31 - 32 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' };
+1 -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, ··· 47 46 urlHandlerPriority: 5, 48 47 name: 'GIF', 49 48 49 + keywords: ['animation', 'giphy', 'meme', 'tenor'], 50 50 groups: ['Media'], 51 51 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 8.25v7.5m-6-3.75h3v3.75m-3-7.5h3M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" /></svg>` 52 - 53 52 } as CardDefinition & { type: 'gif' };
+1 -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; ··· 16 15 }, 17 16 canHaveLabel: true, 18 17 18 + keywords: ['chrome', 'dinosaur', 'runner', 'fun'], 19 19 groups: ['Games'], 20 20 name: 'Dino Game', 21 21 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.491 48.491 0 0 1-4.163-.3c-1.228-.158-2.33.895-2.33 2.134v0c0 1.26 1.09 2.22 2.34 2.14a48.089 48.089 0 0 1 3.27-.108c.43 0 .78.348.78.78v0c0 .22-.09.422-.234.577a8.398 8.398 0 0 0-2.07 4.238c-.19 1.14.513 2.163 1.578 2.428a2.07 2.07 0 0 0 2.478-1.41c.203-.636.37-1.294.524-1.947.128-.537.612-.898 1.16-.84 1.378.15 2.782.18 4.17.076 1.156-.087 2.03-1.09 1.883-2.24a8.52 8.52 0 0 0-1.568-3.7A2.01 2.01 0 0 1 18 8.053v0c0-1.064.82-1.98 1.88-2.08A48.678 48.678 0 0 0 24 5.328v0" /></svg>` 22 - 23 22 } as CardDefinition & { type: 'dino-game' };
+1 -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) => { ··· 21 20 maxH: 10, 22 21 canHaveLabel: true, 23 22 23 + keywords: ['blocks', 'puzzle', 'game', 'fun'], 24 24 groups: ['Games'], 25 25 26 26 name: 'Tetris', 27 27 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14 4h-4v4H6v4h4v4h4v-4h4V8h-4V4Z" /></svg>` 28 - 29 28 } as CardDefinition & { type: 'tetris' };
+1 -1
src/lib/cards/GitHubProfileCard/index.ts
··· 54 54 }, 55 55 name: 'Github Profile', 56 56 57 + keywords: ['developer', 'code', 'repos', 'contributions'], 57 58 groups: ['Social'], 58 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>` 59 - 60 60 } as CardDefinition & { type: 'githubProfile' }; 61 61 62 62 function getGitHubUsername(url: string | undefined): string | undefined {
+1 -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; ··· 61 60 return results; 62 61 }, 63 62 name: 'Guestbook', 63 + keywords: ['comments', 'visitors', 'message', 'sign'], 64 64 groups: ['Social'], 65 65 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>` 66 - 67 66 } as CardDefinition & { type: 'guestbook' };
+1
src/lib/cards/ImageCard/index.ts
··· 46 46 47 47 canHaveLabel: true, 48 48 49 + keywords: ['photo', 'picture', 'upload', 'png', 'jpg'], 49 50 groups: ['Core'], 50 51 51 52 icon: `<svg
+1 -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 ··· 22 21 23 22 name: 'Latest Bluesky Post', 24 23 24 + keywords: ['bsky', 'atproto', 'recent', 'feed'], 25 25 groups: ['Social'], 26 26 icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M6.335 3.836a47.2 47.2 0 0 1 5.354 4.94c.088.093.165.18.232.26a18 18 0 0 1 .232-.26 47.2 47.2 0 0 1 5.355-4.94C18.882 2.687 21.46 1.37 22.553 2.483c.986 1.003.616 4.264.305 5.857-.567 2.902-2.018 4.274-3.703 4.542 2.348.386 4.678 1.96 3.13 5.602-1.97 4.636-7.065 1.763-9.795-.418a3 3 0 0 1-.18-.15 3 3 0 0 1-.18.15c-2.73 2.18-7.825 5.054-9.795.418-1.548-3.643.782-5.216 3.13-5.602C3.98 12.631 2.529 11.26 1.962 8.357c-.311-1.593-.681-4.854.305-5.857C3.361 1.37 5.94 2.687 6.335 3.836Z" /></svg>` 27 - 28 27 } as CardDefinition & { type: 'latestPost' };
+1
src/lib/cards/LinkCard/index.ts
··· 41 41 }, 42 42 urlHandlerPriority: 0, 43 43 44 + keywords: ['url', 'website', 'href', 'webpage'], 44 45 groups: ['Core'], 45 46 46 47 icon: `<svg
+1 -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; ··· 82 81 urlHandlerPriority: 5, 83 82 84 83 name: 'Latest Livestream (stream.place)', 84 + keywords: ['stream', 'live', 'broadcast', 'video'], 85 85 groups: ['Media'], 86 86 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>` 87 - 88 87 } as CardDefinition & { type: 'latestLivestream' }; 89 88 90 89 export const LivestreamEmbedCardDefitition = {
+1 -1
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; ··· 19 18 canHaveLabel: true, 20 19 settingsComponent: MapCardSettings, 21 20 21 + keywords: ['location', 'place', 'address', 'geo'], 22 22 groups: ['Core'], 23 23 24 24 name: 'Map',
-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' };
+1 -2
src/lib/cards/PopfeedReviews/index.ts
··· 17 17 return data; 18 18 }, 19 19 minH: 3, 20 - sidebarButtonText: 'Popfeed Reviews', 21 20 canHaveLabel: true, 22 21 22 + keywords: ['movies', 'tv', 'film', 'reviews', 'ratings', 'popfeed'], 23 23 groups: ['Media'], 24 24 name: 'Movie and TV Reviews', 25 25 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>` 26 - 27 26 } as CardDefinition & { type: 'recentPopfeedReviews' };
+1
src/lib/cards/SectionCard/index.ts
··· 30 30 settingsComponent: SectionCardSettings, 31 31 32 32 name: 'Heading', 33 + keywords: ['title', 'section', 'header', 'divider'], 33 34 groups: ['Core'], 34 35 35 36 icon: `<svg
+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(
+1 -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 = {}; ··· 42 40 minW: 4, 43 41 minH: 5, 44 42 43 + keywords: ['music', 'song', 'playlist', 'album', 'podcast'], 45 44 groups: ['Media'], 46 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>` 47 - 48 46 } as CardDefinition & { type: typeof cardType }; 49 47 50 48 // Match Spotify album and playlist URLs
+1 -3
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 42 42 return records; 43 43 }, 44 44 45 - sidebarButtonText: 'site.standard.document list', 46 - 47 45 name: 'Blog Posts', 48 46 47 + keywords: ['articles', 'writing', 'blog', 'posts', 'frontpage'], 49 48 groups: ['Content'], 50 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>` 51 - 52 50 } as CardDefinition & { type: 'site.standard.document list' };
+1 -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({ ··· 50 48 canHaveLabel: true, 51 49 52 50 name: 'Emoji', 51 + keywords: ['status', 'mood', 'reaction', 'statusphere'], 53 52 groups: ['Media'], 54 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>` 55 - 56 54 } as CardDefinition & { type: 'statusphere' }; 57 55 58 56 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+1 -2
src/lib/cards/TealFMPlaysCard/index.ts
··· 21 21 return data; 22 22 }, 23 23 minW: 4, 24 - sidebarButtonText: 'teal.fm Plays', 25 24 canHaveLabel: true, 26 25 26 + keywords: ['music', 'scrobble', 'listening', 'songs'], 27 27 name: 'Teal.fm Plays', 28 28 29 29 groups: ['Media'], 30 30 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 31 - 32 31 } as CardDefinition & { type: 'recentTealFMPlays' };
+1
src/lib/cards/TextCard/index.ts
··· 18 18 19 19 name: 'Text', 20 20 21 + keywords: ['paragraph', 'note', 'write', 'content', 'description', 'bio'], 21 22 groups: ['Core'], 22 23 23 24 icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4"
+1 -1
src/lib/cards/TimerCard/index.ts
··· 29 29 } as TimerCardData; 30 30 }, 31 31 32 + keywords: ['stopwatch', 'clock', 'time'], 32 33 allowSetColor: true, 33 34 minW: 4, 34 35 canHaveLabel: true, ··· 46 47 item.cardData.label = data.label; 47 48 } 48 49 } 49 - 50 50 } as CardDefinition & { type: 'timer' };
+1 -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 124 name: 'vCard Card', 125 + keywords: ['contact', 'phone', 'email', 'address', 'business card'], 126 126 groups: ['Social'], 127 127 icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" /></svg>` 128 - 129 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>
-65
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', 63 - groups: ['Media'], 64 - 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>` 65 - } as CardDefinition & { type: 'video' };
+1
src/lib/cards/YoutubeVideoCard/index.ts
··· 55 55 }, 56 56 name: 'Youtube Video', 57 57 58 + keywords: ['video', 'yt', 'stream', 'watch'], 58 59 groups: ['Media'], 59 60 60 61 icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-3" viewBox="0 0 256 180"
+4 -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'; ··· 35 34 import { SpotifyCardDefinition } from './SpotifyCard'; 36 35 import { ButtonCardDefinition } from './ButtonCard'; 37 36 import { GuestbookCardDefinition } from './GuestbookCard'; 37 + import { FriendsCardDefinition } from './FriendsCard'; 38 38 // import { Model3DCardDefinition } from './Model3DCard'; 39 39 40 40 export const AllCardDefinitions = [ 41 41 GuestbookCardDefinition, 42 42 ButtonCardDefinition, 43 43 ImageCardDefinition, 44 - VideoCardDefinition, 45 44 TextCardDefinition, 46 45 LinkCardDefinition, 47 46 BigSocialCardDefinition, ··· 51 50 LatestBlueskyPostCardDefinition, 52 51 LivestreamCardDefitition, 53 52 LivestreamEmbedCardDefitition, 54 - EmbedCardDefinition, 53 + // EmbedCardDefinition, 55 54 MapCardDefinition, 56 55 ATProtoCollectionsCardDefinition, 57 56 SectionCardDefinition, ··· 73 72 TimerCardDefinition, 74 73 ClockCardDefinition, 75 74 CountdownCardDefinition, 76 - SpotifyCardDefinition 75 + SpotifyCardDefinition, 77 76 // Model3DCardDefinition 77 + FriendsCardDefinition 78 78 ] as const; 79 79 80 80 export const CardDefinitionsByType = AllCardDefinitions.reduce(
-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>;
-116
src/lib/website/EditBar.svelte
··· 331 331 </Button> 332 332 </div> 333 333 {:else} 334 - <!-- Normal add-card controls --> 335 334 <div class="flex items-center gap-2"> 336 - <Button 337 - size="iconLg" 338 - variant="ghost" 339 - class="backdrop-blur-none" 340 - onclick={() => { 341 - newCard('section'); 342 - }} 343 - > 344 - <svg 345 - xmlns="http://www.w3.org/2000/svg" 346 - viewBox="0 0 24 24" 347 - fill="none" 348 - stroke="currentColor" 349 - stroke-width="2" 350 - stroke-linecap="round" 351 - stroke-linejoin="round" 352 - ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 353 - > 354 - </Button> 355 - 356 - <Button 357 - size="iconLg" 358 - variant="ghost" 359 - class="backdrop-blur-none" 360 - onclick={() => { 361 - newCard('text'); 362 - }} 363 - > 364 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 365 - ><path 366 - fill="none" 367 - stroke="currentColor" 368 - stroke-linecap="round" 369 - stroke-linejoin="round" 370 - stroke-width="2" 371 - d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 372 - /></svg 373 - > 374 - </Button> 375 - 376 - <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 377 - {#snippet child({ props })} 378 - <Button 379 - size="iconLg" 380 - variant="ghost" 381 - class="backdrop-blur-none" 382 - onclick={() => { 383 - newCard('link'); 384 - }} 385 - {...props} 386 - > 387 - <svg 388 - xmlns="http://www.w3.org/2000/svg" 389 - fill="none" 390 - viewBox="-2 -2 28 28" 391 - stroke-width="2" 392 - stroke="currentColor" 393 - > 394 - <path 395 - stroke-linecap="round" 396 - stroke-linejoin="round" 397 - d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 398 - /> 399 - </svg> 400 - </Button> 401 - {/snippet} 402 - <Input 403 - spellcheck={false} 404 - type="url" 405 - bind:value={linkValue} 406 - onkeydown={(event) => { 407 - if (event.code === 'Enter') { 408 - addLink(linkValue); 409 - event.preventDefault(); 410 - } 411 - }} 412 - placeholder="Enter link" 413 - /> 414 - <Button onclick={() => addLink(linkValue)} size="icon" 415 - ><svg 416 - xmlns="http://www.w3.org/2000/svg" 417 - fill="none" 418 - viewBox="0 0 24 24" 419 - stroke-width="2" 420 - stroke="currentColor" 421 - class="size-6" 422 - > 423 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 424 - </svg> 425 - </Button> 426 - </Popover> 427 - 428 - <Button 429 - size="iconLg" 430 - variant="ghost" 431 - class="backdrop-blur-none" 432 - onclick={() => { 433 - imageInputRef?.click(); 434 - }} 435 - > 436 - <svg 437 - xmlns="http://www.w3.org/2000/svg" 438 - fill="none" 439 - viewBox="0 0 24 24" 440 - stroke-width="2" 441 - stroke="currentColor" 442 - > 443 - <path 444 - stroke-linecap="round" 445 - stroke-linejoin="round" 446 - d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 447 - /> 448 - </svg> 449 - </Button> 450 - 451 335 <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 452 336 <svg 453 337 xmlns="http://www.w3.org/2000/svg"
+292 -13
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, ··· 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'; 41 43 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 42 44 import { shouldMirror, mirrorLayout } from './layout-mirror'; 45 + import { SvelteMap } from 'svelte/reactivity'; 43 46 44 47 let { 45 48 data ··· 256 259 } 257 260 } 258 261 259 - const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.sidebarButtonText); 262 + const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.name); 263 + 264 + function addAllCardTypes() { 265 + const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games']; 266 + const grouped = new SvelteMap<string, CardDefinition[]>(); 267 + 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); 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 + } 260 536 261 537 let debugPoint = $state({ x: 0, y: 0 }); 262 538 ··· 858 1134 <Account {data} /> 859 1135 860 1136 <Context {data}> 861 - {#if !dev} 862 - <div 863 - 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" 864 - > 865 - Editing on mobile is not supported yet. Please use a desktop browser. 866 - </div> 867 - {/if} 868 - 869 1137 <CardCommand 870 1138 bind:open={showCardCommand} 871 1139 onselect={(cardDef: CardDefinition) => { ··· 939 1207 > 940 1208 <div class="pointer-events-none"></div> 941 1209 <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> 942 - <!-- svelte-ignore a11y_click_events_have_key_events --> 943 1210 <div 944 1211 bind:this={container} 945 1212 onclick={(e) => { ··· 1152 1419 1153 1420 {#if dev} 1154 1421 <div 1155 - class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 rounded px-2 py-1 font-mono text-xs" 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" 1156 1423 > 1157 - editedOn: {editedOn} 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> 1158 1437 </div> 1159 1438 {/if} 1160 1439 </Context>
+2 -8
src/lib/website/layout-mirror.ts
··· 51 51 if (fromMobile) { 52 52 // Mobile → Desktop: reflow items to use the full grid width. 53 53 // Sort by mobile position so items are placed in reading order. 54 - const sorted = items.toSorted( 55 - (a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX 56 - ); 54 + const sorted = items.toSorted((a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX); 57 55 58 56 // Place each item into the first available spot on the desktop grid 59 57 const placed: Item[] = []; ··· 66 64 } else { 67 65 // Desktop → Mobile: proportional positions 68 66 for (const item of items) { 69 - item.mobileX = clamp( 70 - Math.floor((item.x * 2) / 2) * 2, 71 - 0, 72 - COLUMNS - item.mobileW 73 - ); 67 + item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 74 68 item.mobileY = Math.max(0, Math.round(item.y * 2)); 75 69 } 76 70 fixAllCollisions(items, true);