your personal website on atproto - mirror blento.app

Merge pull request #188 from flo-bit/grain-photo-gallery

grain photo gallery

authored by

Florian and committed by
GitHub
1bc6705d 0923710d

+260 -344
+62
src/lib/cards/media/PhotoGalleryCard/CreateGrainGalleryCardModal.svelte
···
··· 1 + <script lang="ts"> 2 + import { Alert, Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../../types'; 4 + import { parseGrainGalleryUrl } from './helpers'; 5 + import { resolveHandle } from '$lib/atproto'; 6 + import type { Handle } from '@atcute/lexicons'; 7 + 8 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 9 + 10 + let isValidating = $state(false); 11 + let errorMessage = $state(''); 12 + 13 + async function checkUrl() { 14 + errorMessage = ''; 15 + isValidating = true; 16 + 17 + try { 18 + const parsed = parseGrainGalleryUrl(item.cardData.href); 19 + if (!parsed) { 20 + errorMessage = 'Please enter a valid grain.social gallery URL'; 21 + return false; 22 + } 23 + 24 + const did = await resolveHandle({ handle: parsed.handle as Handle }); 25 + if (!did) { 26 + errorMessage = 'Could not resolve handle'; 27 + return false; 28 + } 29 + 30 + item.cardData.galleryUri = `at://${did}/social.grain.gallery/${parsed.rkey}`; 31 + return true; 32 + } finally { 33 + isValidating = false; 34 + } 35 + } 36 + </script> 37 + 38 + <Modal open={true} closeButton={false}> 39 + <form 40 + onsubmit={async () => { 41 + if (await checkUrl()) oncreate(); 42 + }} 43 + class="flex flex-col gap-2" 44 + > 45 + <Subheading>Enter a grain.social gallery URL</Subheading> 46 + <Input 47 + bind:value={item.cardData.href} 48 + placeholder="https://grain.social/profile/handle/gallery/..." 49 + /> 50 + 51 + {#if errorMessage} 52 + <Alert type="error" title="Invalid URL"><span>{errorMessage}</span></Alert> 53 + {/if} 54 + 55 + <div class="mt-4 flex justify-end gap-2"> 56 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 57 + <Button type="submit" disabled={isValidating}> 58 + {isValidating ? 'Creating...' : 'Create'} 59 + </Button> 60 + </div> 61 + </form> 62 + </Modal>
+11 -18
src/lib/cards/media/PhotoGalleryCard/PhotoGalleryCard.svelte
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 import { onMount } from 'svelte'; 4 - import { 5 - getAdditionalUserData, 6 - getDidContext, 7 - getHandleContext, 8 - getIsMobile 9 - } from '$lib/website/context'; 10 - import { CardDefinitionsByType } from '../..'; 11 import { getCDNImageBlobUrl, parseUri } from '$lib/atproto'; 12 13 import { ImageMasonry } from '@foxui/visual'; 14 15 interface PhotoItem { 16 uri: string; ··· 29 (data[item.cardType] as Record<string, PhotoItem[]> | undefined)?.[item.cardData.galleryUri] 30 ); 31 32 - let did = getDidContext(); 33 - let handle = getHandleContext(); 34 - 35 onMount(async () => { 36 if (!feed) { 37 - feed = ( 38 - (await CardDefinitionsByType[item.cardType]?.loadData?.([item], { 39 - did, 40 - handle 41 - })) as Record<string, PhotoItem[]> | undefined 42 - )?.[item.cardData.galleryUri]; 43 44 data[item.cardType] = feed; 45 } ··· 52 }) 53 .map((i: PhotoItem) => { 54 const item = parseUri(i.uri); 55 return { 56 - src: getCDNImageBlobUrl({ did: item?.repo, blob: i.value.photo }), 57 name: '', 58 width: i.value.aspectRatio.width, 59 height: i.value.aspectRatio.height, 60 - position: i.value.position ?? 0 61 }; 62 }) 63 .filter((i) => i.src !== undefined) || []) as { ··· 66 width: number; 67 height: number; 68 position: number; 69 }[] 70 ); 71
··· 1 <script lang="ts"> 2 import type { Item } from '$lib/types'; 3 import { onMount } from 'svelte'; 4 + import { getAdditionalUserData, getIsMobile } from '$lib/website/context'; 5 import { getCDNImageBlobUrl, parseUri } from '$lib/atproto'; 6 + import { loadGrainGalleryData } from './helpers'; 7 8 import { ImageMasonry } from '@foxui/visual'; 9 + import { openImageViewer } from '$lib/components/image-viewer/imageViewer.svelte'; 10 11 interface PhotoItem { 12 uri: string; ··· 25 (data[item.cardType] as Record<string, PhotoItem[]> | undefined)?.[item.cardData.galleryUri] 26 ); 27 28 onMount(async () => { 29 if (!feed) { 30 + feed = ((await loadGrainGalleryData([item])) as Record<string, PhotoItem[]> | undefined)?.[ 31 + item.cardData.galleryUri 32 + ]; 33 34 data[item.cardType] = feed; 35 } ··· 42 }) 43 .map((i: PhotoItem) => { 44 const item = parseUri(i.uri); 45 + const src = getCDNImageBlobUrl({ did: item?.repo, blob: i.value.photo }); 46 return { 47 + src, 48 name: '', 49 width: i.value.aspectRatio.width, 50 height: i.value.aspectRatio.height, 51 + position: i.value.position ?? 0, 52 + onclick: src ? () => openImageViewer(src) : undefined 53 }; 54 }) 55 .filter((i) => i.src !== undefined) || []) as { ··· 58 width: number; 59 height: number; 60 position: number; 61 + onclick?: () => void; 62 }[] 63 ); 64
+78
src/lib/cards/media/PhotoGalleryCard/helpers.ts
···
··· 1 + import { getRecord, listRecords, parseUri, resolveHandle } from '$lib/atproto'; 2 + import type { Did, Handle } from '@atcute/lexicons'; 3 + import { isDid } from '@atcute/lexicons/syntax'; 4 + 5 + interface GalleryItem { 6 + value: { 7 + gallery: string; 8 + item: string; 9 + position?: number; 10 + }; 11 + } 12 + 13 + // Parse grain.social gallery URLs 14 + // https://grain.social/profile/atproto.boston/gallery/3megtiuwqs62w 15 + export function parseGrainGalleryUrl(url: string): { handle: string; rkey: string } | null { 16 + const match = url.match(/grain\.social\/profile\/([^/]+)\/gallery\/([A-Za-z0-9]+)/); 17 + if (!match) return null; 18 + return { handle: match[1], rkey: match[2] }; 19 + } 20 + 21 + export async function loadGrainGalleryData(items: { cardData: Record<string, unknown> }[]) { 22 + const itemsData: Record<string, unknown[]> = {}; 23 + 24 + const galleryItems: Record<string, GalleryItem[] | undefined> = { 25 + 'social.grain.gallery.item': undefined 26 + }; 27 + 28 + for (const item of items) { 29 + if (!item.cardData.galleryUri) continue; 30 + 31 + const galleryUri = item.cardData.galleryUri as string; 32 + const parsedUri = parseUri(galleryUri); 33 + 34 + if (parsedUri?.collection === 'social.grain.gallery') { 35 + let repo = parsedUri.repo; 36 + 37 + // Resolve handle to DID if needed 38 + if (!isDid(repo)) { 39 + const did = await resolveHandle({ handle: repo as Handle }); 40 + if (!did) continue; 41 + repo = did; 42 + } 43 + 44 + // Construct DID-based URI for filtering (PDS records use DID-based URIs) 45 + const didBasedGalleryUri = `at://${repo}/social.grain.gallery/${parsedUri.rkey}`; 46 + 47 + const itemCollection = 'social.grain.gallery.item'; 48 + 49 + if (!galleryItems[itemCollection]) { 50 + galleryItems[itemCollection] = (await listRecords({ 51 + did: repo as Did, 52 + collection: itemCollection 53 + })) as unknown as GalleryItem[]; 54 + } 55 + 56 + const galleryItemsList = galleryItems['social.grain.gallery.item']; 57 + if (!galleryItemsList) continue; 58 + 59 + const images = galleryItemsList 60 + .filter((i) => i.value.gallery === didBasedGalleryUri) 61 + .map(async (i) => { 62 + const itemData = parseUri(i.value.item); 63 + if (!itemData) return null; 64 + const record = await getRecord({ 65 + did: itemData.repo as Did, 66 + collection: itemData.collection!, 67 + rkey: itemData.rkey 68 + }); 69 + return { ...record, value: { ...record.value, ...i.value } }; 70 + }); 71 + 72 + // Store under original key so the component can look it up 73 + itemsData[galleryUri] = await Promise.all(images); 74 + } 75 + } 76 + 77 + return itemsData; 78 + }
+28 -53
src/lib/cards/media/PhotoGalleryCard/index.ts
··· 1 import type { CardDefinition } from '../../types'; 2 - import { getRecord, listRecords, parseUri } from '$lib/atproto'; 3 import PhotoGalleryCard from './PhotoGalleryCard.svelte'; 4 - import type { Did } from '@atcute/lexicons'; 5 6 - interface GalleryItem { 7 - value: { 8 - gallery: string; 9 - item: string; 10 - position?: number; 11 - }; 12 - } 13 14 export const PhotoGalleryCardDefinition = { 15 - type: 'photoGallery', 16 contentComponent: PhotoGalleryCard, 17 createNew: (card) => { 18 - // random grain.social url for testing 19 - card.cardData.galleryUri = 20 - 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w'; 21 - 22 card.w = 4; 23 card.mobileW = 8; 24 card.h = 3; 25 card.mobileH = 6; 26 }, 27 - loadData: async (items) => { 28 - const itemsData: Record<string, unknown[]> = {}; 29 30 - const galleryItems: Record<string, GalleryItem[] | undefined> = { 31 - 'social.grain.gallery.item': undefined 32 - }; 33 34 - for (const item of items) { 35 - if (!item.cardData.galleryUri) continue; 36 37 - const parsedUri = parseUri(item.cardData.galleryUri); 38 39 - if (parsedUri?.collection === 'social.grain.gallery') { 40 - const itemCollection = 'social.grain.gallery.item'; 41 42 - if (!galleryItems[itemCollection]) { 43 - galleryItems[itemCollection] = (await listRecords({ 44 - did: parsedUri.repo as Did, 45 - collection: itemCollection 46 - })) as unknown as GalleryItem[]; 47 - } 48 - 49 - const galleryItemsList = galleryItems['social.grain.gallery.item']; 50 - if (!galleryItemsList) continue; 51 52 - const images = galleryItemsList 53 - .filter((i) => i.value.gallery === item.cardData.galleryUri) 54 - .map(async (i) => { 55 - const itemData = parseUri(i.value.item); 56 - if (!itemData) return null; 57 - const record = await getRecord({ 58 - did: itemData.repo as Did, 59 - collection: itemData.collection!, 60 - rkey: itemData.rkey 61 - }); 62 - return { ...record, value: { ...record.value, ...i.value } }; 63 - }); 64 65 - itemsData[item.cardData.galleryUri] = await Promise.all(images); 66 - } 67 - } 68 69 - return itemsData; 70 - }, 71 - keywords: ['album', 'photos', 'slideshow', 'images', 'carousel'], 72 - minW: 4 73 - } as CardDefinition & { type: 'photoGallery' };
··· 1 import type { CardDefinition } from '../../types'; 2 import PhotoGalleryCard from './PhotoGalleryCard.svelte'; 3 + import CreateGrainGalleryCardModal from './CreateGrainGalleryCardModal.svelte'; 4 + import { parseGrainGalleryUrl, loadGrainGalleryData } from './helpers'; 5 + 6 + export { parseGrainGalleryUrl, loadGrainGalleryData }; 7 8 + const cardType = 'grain-gallery'; 9 10 export const PhotoGalleryCardDefinition = { 11 + type: cardType, 12 contentComponent: PhotoGalleryCard, 13 + creationModalComponent: CreateGrainGalleryCardModal, 14 createNew: (card) => { 15 + card.cardData = {}; 16 card.w = 4; 17 card.mobileW = 8; 18 card.h = 3; 19 card.mobileH = 6; 20 }, 21 22 + onUrlHandler: (url, item) => { 23 + const parsed = parseGrainGalleryUrl(url); 24 + if (!parsed) return null; 25 26 + // Store with handle — loadData will resolve to DID 27 + item.cardData.galleryUri = `at://${parsed.handle}/social.grain.gallery/${parsed.rkey}`; 28 29 + item.w = 4; 30 + item.mobileW = 8; 31 + item.h = 3; 32 + item.mobileH = 6; 33 34 + return item; 35 + }, 36 37 + urlHandlerPriority: 2, 38 39 + loadData: async (items) => loadGrainGalleryData(items), 40 41 + canHaveLabel: true, 42 43 + name: 'Grain Gallery', 44 + keywords: ['grain', 'gallery', 'album', 'photos', 'slideshow', 'images', 'carousel'], 45 + groups: ['Media'], 46 + minW: 4, 47 + 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="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.909M3.75 21h16.5A2.25 2.25 0 0 0 22.5 18.75V5.25A2.25 2.25 0 0 0 20.25 3H3.75A2.25 2.25 0 0 0 1.5 5.25v13.5A2.25 2.25 0 0 0 3.75 21ZM16.5 8.25a1.125 1.125 0 1 1-2.25 0 1.125 1.125 0 0 1 2.25 0Z" /></svg>` 48 + } as CardDefinition & { type: typeof cardType };
+40
src/lib/components/image-viewer/ImageViewerModal.svelte
···
··· 1 + <script lang="ts"> 2 + import { fade } from 'svelte/transition'; 3 + 4 + let { open = $bindable(false), src = '' }: { open: boolean; src: string } = $props(); 5 + 6 + function close() { 7 + open = false; 8 + } 9 + 10 + function onkeydown(e: KeyboardEvent) { 11 + if (e.key === 'Escape' && open) { 12 + close(); 13 + } 14 + } 15 + </script> 16 + 17 + <svelte:window {onkeydown} /> 18 + 19 + {#if open} 20 + <!-- svelte-ignore a11y_interactive_supports_focus --> 21 + <div 22 + class="fixed inset-0 z-50 flex items-center justify-center bg-black/90" 23 + transition:fade={{ duration: 150 }} 24 + onclick={close} 25 + onkeydown={(e) => { 26 + if (e.key === 'Escape') close(); 27 + }} 28 + role="dialog" 29 + aria-modal="true" 30 + > 31 + <!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 32 + <img 33 + {src} 34 + alt="" 35 + class="max-h-[90vh] max-w-[90vw] object-contain" 36 + onclick={(e) => e.stopPropagation()} 37 + onkeydown={(e) => e.stopPropagation()} 38 + /> 39 + </div> 40 + {/if}
+23
src/lib/components/image-viewer/ImageViewerProvider.svelte
···
··· 1 + <script lang="ts"> 2 + import { onMount, onDestroy } from 'svelte'; 3 + import ImageViewerModal from './ImageViewerModal.svelte'; 4 + import { registerImageViewer, unregisterImageViewer } from './imageViewer.svelte'; 5 + 6 + let open = $state(false); 7 + let src = $state(''); 8 + 9 + function show(newSrc: string) { 10 + src = newSrc; 11 + open = true; 12 + } 13 + 14 + onMount(() => { 15 + registerImageViewer(show); 16 + }); 17 + 18 + onDestroy(() => { 19 + unregisterImageViewer(); 20 + }); 21 + </script> 22 + 23 + <ImageViewerModal bind:open {src} />
+14
src/lib/components/image-viewer/imageViewer.svelte.ts
···
··· 1 + // Global state for fullscreen image viewer 2 + let openFn: ((src: string) => void) | null = null; 3 + 4 + export function registerImageViewer(fn: (src: string) => void) { 5 + openFn = fn; 6 + } 7 + 8 + export function unregisterImageViewer() { 9 + openFn = null; 10 + } 11 + 12 + export function openImageViewer(src: string) { 13 + openFn?.(src); 14 + }
+2 -273
src/lib/website/EditableWebsite.svelte
··· 34 import { launchConfetti } from '@foxui/visual'; 35 import Controls from './Controls.svelte'; 36 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 37 import { SvelteMap } from 'svelte/reactivity'; 38 import { 39 fixCollisions, ··· 244 } 245 } 246 247 - function addAllCardTypes() { 248 - const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games']; 249 - const grouped = new SvelteMap<string, CardDefinition[]>(); 250 - 251 - for (const def of AllCardDefinitions) { 252 - if (!def.name) continue; 253 - const group = def.groups?.[0] ?? 'Other'; 254 - if (!grouped.has(group)) grouped.set(group, []); 255 - grouped.get(group)!.push(def); 256 - } 257 - 258 - // Sort groups by predefined order, unknowns at end 259 - const sortedGroups = [...grouped.keys()].sort((a, b) => { 260 - const ai = groupOrder.indexOf(a); 261 - const bi = groupOrder.indexOf(b); 262 - return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); 263 - }); 264 - 265 - // Sample data for cards that would otherwise render empty 266 - const sampleData: Record<string, Record<string, unknown>> = { 267 - text: { text: 'The quick brown fox jumps over the lazy dog. This is a sample text card.' }, 268 - link: { 269 - href: 'https://bsky.app', 270 - title: 'Bluesky', 271 - domain: 'bsky.app', 272 - description: 'Social networking that gives you choice', 273 - hasFetched: true 274 - }, 275 - image: { 276 - image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600', 277 - alt: 'Mountain landscape' 278 - }, 279 - button: { text: 'Visit Bluesky', href: 'https://bsky.app' }, 280 - bigsocial: { platform: 'bluesky', href: 'https://bsky.app', color: '0085ff' }, 281 - blueskyPost: { 282 - uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jt64kgkbbs2y', 283 - href: 'https://bsky.app/profile/bsky.app/post/3jt64kgkbbs2y' 284 - }, 285 - blueskyProfile: { 286 - handle: 'bsky.app', 287 - displayName: 'Bluesky', 288 - avatar: 289 - 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4pcnbaq53m@jpeg' 290 - }, 291 - blueskyMedia: {}, 292 - latestPost: {}, 293 - youtubeVideo: { 294 - youtubeId: 'dQw4w9WgXcQ', 295 - poster: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 296 - href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 297 - showInline: true 298 - }, 299 - 'spotify-list-embed': { 300 - spotifyType: 'album', 301 - spotifyId: '4aawyAB9vmqN3uQ7FjRGTy', 302 - href: 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy' 303 - }, 304 - latestLivestream: {}, 305 - livestreamEmbed: { 306 - href: 'https://stream.place/', 307 - embed: 'https://stream.place/embed/' 308 - }, 309 - mapLocation: { lat: 48.8584, lon: 2.2945, zoom: 13, name: 'Eiffel Tower, Paris' }, 310 - gif: { url: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.mp4', alt: 'Cat typing' }, 311 - event: { 312 - uri: 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mcsoqzy7gm2q' 313 - }, 314 - guestbook: { label: 'Guestbook' }, 315 - githubProfile: { user: 'sveltejs', href: 'https://github.com/sveltejs' }, 316 - photoGallery: { 317 - galleryUri: 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w' 318 - }, 319 - atprotocollections: {}, 320 - publicationList: {}, 321 - recentPopfeedReviews: {}, 322 - recentTealFMPlays: {}, 323 - statusphere: { emoji: '✨' }, 324 - vcard: {}, 325 - 'fluid-text': { text: 'Hello World' }, 326 - draw: { strokesJson: '[]', viewBox: '', strokeWidth: 1, locked: true }, 327 - clock: {}, 328 - countdown: { targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() }, 329 - timer: {}, 330 - 'dino-game': {}, 331 - tetris: {}, 332 - updatedBlentos: {} 333 - }; 334 - 335 - // Labels for cards that support canHaveLabel 336 - const sampleLabels: Record<string, string> = { 337 - image: 'Mountain Landscape', 338 - mapLocation: 'Eiffel Tower', 339 - gif: 'Cat Typing', 340 - bigsocial: 'Bluesky', 341 - guestbook: 'Guestbook', 342 - statusphere: 'My Status', 343 - recentPopfeedReviews: 'My Reviews', 344 - recentTealFMPlays: 'Recently Played', 345 - clock: 'Local Time', 346 - countdown: 'Launch Day', 347 - timer: 'Timer', 348 - 'dino-game': 'Dino Game', 349 - tetris: 'Tetris', 350 - blueskyMedia: 'Bluesky Media' 351 - }; 352 - 353 - const newItems: Item[] = []; 354 - let cursorY = 0; 355 - let mobileCursorY = 0; 356 - 357 - for (const group of sortedGroups) { 358 - const defs = grouped.get(group)!; 359 - 360 - // Add a section heading for the group 361 - const heading = createEmptyCard(data.page); 362 - heading.cardType = 'section'; 363 - heading.cardData = { text: group, verticalAlign: 'bottom', textSize: 1 }; 364 - heading.w = COLUMNS; 365 - heading.h = 1; 366 - heading.x = 0; 367 - heading.y = cursorY; 368 - heading.mobileW = COLUMNS; 369 - heading.mobileH = 2; 370 - heading.mobileX = 0; 371 - heading.mobileY = mobileCursorY; 372 - newItems.push(heading); 373 - cursorY += 1; 374 - mobileCursorY += 2; 375 - 376 - // Place cards in rows 377 - let rowX = 0; 378 - let rowMaxH = 0; 379 - let mobileRowX = 0; 380 - let mobileRowMaxH = 0; 381 - 382 - for (const def of defs) { 383 - if (def.type === 'section' || def.type === 'embed') continue; 384 - 385 - const item = createEmptyCard(data.page); 386 - item.cardType = def.type; 387 - item.cardData = {}; 388 - def.createNew?.(item); 389 - 390 - // Merge in sample data (without overwriting createNew defaults) 391 - const extra = sampleData[def.type]; 392 - if (extra) { 393 - item.cardData = { ...item.cardData, ...extra }; 394 - } 395 - 396 - // Set item-level color for cards that need it 397 - if (def.type === 'button') { 398 - item.color = 'transparent'; 399 - } 400 - 401 - // Add label if card supports it 402 - const label = sampleLabels[def.type]; 403 - if (label && def.canHaveLabel) { 404 - item.cardData.label = label; 405 - } 406 - 407 - // Desktop layout 408 - if (rowX + item.w > COLUMNS) { 409 - cursorY += rowMaxH; 410 - rowX = 0; 411 - rowMaxH = 0; 412 - } 413 - item.x = rowX; 414 - item.y = cursorY; 415 - rowX += item.w; 416 - rowMaxH = Math.max(rowMaxH, item.h); 417 - 418 - // Mobile layout 419 - if (mobileRowX + item.mobileW > COLUMNS) { 420 - mobileCursorY += mobileRowMaxH; 421 - mobileRowX = 0; 422 - mobileRowMaxH = 0; 423 - } 424 - item.mobileX = mobileRowX; 425 - item.mobileY = mobileCursorY; 426 - mobileRowX += item.mobileW; 427 - mobileRowMaxH = Math.max(mobileRowMaxH, item.mobileH); 428 - 429 - newItems.push(item); 430 - } 431 - 432 - // Move cursor past last row 433 - cursorY += rowMaxH; 434 - mobileCursorY += mobileRowMaxH; 435 - } 436 - 437 - items = newItems; 438 - onLayoutChanged(); 439 - } 440 - 441 - let copyInput = $state(''); 442 - let isCopying = $state(false); 443 - 444 - async function copyPageFrom() { 445 - const input = copyInput.trim(); 446 - if (!input) return; 447 - 448 - isCopying = true; 449 - try { 450 - // Parse "handle" or "handle/page" 451 - const parts = input.split('/'); 452 - const handle = parts[0]; 453 - const pageName = parts[1] || 'self'; 454 - 455 - const did = await resolveHandle({ handle: handle as `${string}.${string}` }); 456 - if (!did) throw new Error('Could not resolve handle'); 457 - 458 - const records = await listRecords({ did, collection: 'app.blento.card' }); 459 - const targetPage = 'blento.' + pageName; 460 - 461 - const copiedCards: Item[] = records 462 - .map((r) => ({ ...r.value }) as Item) 463 - .filter((card) => { 464 - // v0/v1 cards without page field belong to blento.self 465 - if (!card.page) return targetPage === 'blento.self'; 466 - return card.page === targetPage; 467 - }) 468 - .map((card) => { 469 - // Apply v0→v1 migration (coords were halved in old format) 470 - if (!card.version) { 471 - card.x *= 2; 472 - card.y *= 2; 473 - card.h *= 2; 474 - card.w *= 2; 475 - card.mobileX *= 2; 476 - card.mobileY *= 2; 477 - card.mobileH *= 2; 478 - card.mobileW *= 2; 479 - card.version = 1; 480 - } 481 - 482 - // Convert blob refs to CDN URLs using source DID 483 - if (card.cardData) { 484 - for (const key of Object.keys(card.cardData)) { 485 - const val = card.cardData[key]; 486 - if (val && typeof val === 'object' && val.$type === 'blob') { 487 - const url = getCDNImageBlobUrl({ did, blob: val }); 488 - if (url) card.cardData[key] = url; 489 - } 490 - } 491 - } 492 - 493 - // Regenerate ID and assign to current page 494 - card.id = TID.now(); 495 - card.page = data.page; 496 - return card; 497 - }); 498 - 499 - if (copiedCards.length === 0) { 500 - toast.error('No cards found on that page'); 501 - return; 502 - } 503 - 504 - fixAllCollisions(copiedCards, false); 505 - fixAllCollisions(copiedCards, true); 506 - compactItems(copiedCards, false); 507 - compactItems(copiedCards, true); 508 - 509 - items = copiedCards; 510 - onLayoutChanged(); 511 - toast.success(`Copied ${copiedCards.length} cards from ${handle}`); 512 - } catch (e) { 513 - console.error('Failed to copy page:', e); 514 - toast.error('Failed to copy page'); 515 - } finally { 516 - isCopying = false; 517 - } 518 - } 519 - 520 let linkValue = $state(''); 521 522 function addLink(url: string, specificCardDef?: CardDefinition) { ··· 769 <Account {data} /> 770 771 <Context {data} isEditing={true}> 772 <CardCommand 773 bind:open={showCardCommand} 774 onselect={(cardDef: CardDefinition) => {
··· 34 import { launchConfetti } from '@foxui/visual'; 35 import Controls from './Controls.svelte'; 36 import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 37 + import ImageViewerProvider from '$lib/components/image-viewer/ImageViewerProvider.svelte'; 38 import { SvelteMap } from 'svelte/reactivity'; 39 import { 40 fixCollisions, ··· 245 } 246 } 247 248 let linkValue = $state(''); 249 250 function addLink(url: string, specificCardDef?: CardDefinition) { ··· 497 <Account {data} /> 498 499 <Context {data} isEditing={true}> 500 + <ImageViewerProvider /> 501 <CardCommand 502 bind:open={showCardCommand} 503 onselect={(cardDef: CardDefinition) => {
+2
src/lib/website/Website.svelte
··· 18 import Head from './Head.svelte'; 19 import type { Did, Handle } from '@atcute/lexicons'; 20 import QRModalProvider from '$lib/components/qr/QRModalProvider.svelte'; 21 import EmptyState from './EmptyState.svelte'; 22 import FloatingEditButton from './FloatingEditButton.svelte'; 23 import { user } from '$lib/atproto'; ··· 67 68 <Context {data}> 69 <QRModalProvider /> 70 <div class="@container/wrapper relative w-full"> 71 {#if !getHideProfileSection(data)} 72 <Profile {data} hideBlento={showFloatingButton} />
··· 18 import Head from './Head.svelte'; 19 import type { Did, Handle } from '@atcute/lexicons'; 20 import QRModalProvider from '$lib/components/qr/QRModalProvider.svelte'; 21 + import ImageViewerProvider from '$lib/components/image-viewer/ImageViewerProvider.svelte'; 22 import EmptyState from './EmptyState.svelte'; 23 import FloatingEditButton from './FloatingEditButton.svelte'; 24 import { user } from '$lib/atproto'; ··· 68 69 <Context {data}> 70 <QRModalProvider /> 71 + <ImageViewerProvider /> 72 <div class="@container/wrapper relative w-full"> 73 {#if !getHideProfileSection(data)} 74 <Profile {data} hideBlento={showFloatingButton} />