diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index 80b9d6a..fe98c81 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -49,7 +49,7 @@ export const AllCardDefinitions = [ LatestBlueskyPostCardDefinition, LivestreamCardDefitition, LivestreamEmbedCardDefitition, - EmbedCardDefinition, + // EmbedCardDefinition, MapCardDefinition, ATProtoCollectionsCardDefinition, SectionCardDefinition, diff --git a/src/lib/website/EditableWebsite.svelte b/src/lib/website/EditableWebsite.svelte index abfd441..5e3e9f5 100644 --- a/src/lib/website/EditableWebsite.svelte +++ b/src/lib/website/EditableWebsite.svelte @@ -7,6 +7,7 @@ compactItems, createEmptyCard, findValidPosition, + fixAllCollisions, fixCollisions, getHideProfileSection, getProfilePosition, @@ -35,7 +36,8 @@ import EditBar from './EditBar.svelte'; import SaveModal from './SaveModal.svelte'; import FloatingEditButton from './FloatingEditButton.svelte'; - import { user } from '$lib/atproto'; + import { user, resolveHandle, listRecords, getCDNImageBlobUrl } from '$lib/atproto'; + import * as TID from '@atcute/tid'; import { launchConfetti } from '@foxui/visual'; import Controls from './Controls.svelte'; import CardCommand from '$lib/components/card-command/CardCommand.svelte'; @@ -258,6 +260,279 @@ const sidebarItems = AllCardDefinitions.filter((cardDef) => cardDef.name); + function addAllCardTypes() { + const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games']; + const grouped = new Map(); + + for (const def of AllCardDefinitions) { + if (!def.name) continue; + const group = def.groups?.[0] ?? 'Other'; + if (!grouped.has(group)) grouped.set(group, []); + grouped.get(group)!.push(def); + } + + // Sort groups by predefined order, unknowns at end + const sortedGroups = [...grouped.keys()].sort((a, b) => { + const ai = groupOrder.indexOf(a); + const bi = groupOrder.indexOf(b); + return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi); + }); + + // Sample data for cards that would otherwise render empty + const sampleData: Record> = { + text: { text: 'The quick brown fox jumps over the lazy dog. This is a sample text card.' }, + link: { + href: 'https://bsky.app', + title: 'Bluesky', + domain: 'bsky.app', + description: 'Social networking that gives you choice', + hasFetched: true + }, + image: { + image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=600', + alt: 'Mountain landscape' + }, + button: { text: 'Visit Bluesky', href: 'https://bsky.app' }, + bigsocial: { platform: 'bluesky', href: 'https://bsky.app', color: '0085ff' }, + blueskyPost: { + uri: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3jt64kgkbbs2y', + href: 'https://bsky.app/profile/bsky.app/post/3jt64kgkbbs2y' + }, + blueskyProfile: { + handle: 'bsky.app', + displayName: 'Bluesky', + avatar: + 'https://cdn.bsky.app/img/avatar/plain/did:plc:z72i7hdynmk6r22z27h6tvur/bafkreihagr2cmvl2jt4mgx3sppwe2it3fwolkrbtjrhcnwjk4pcnbaq53m@jpeg' + }, + blueskyMedia: {}, + latestPost: {}, + youtubeVideo: { + youtubeId: 'dQw4w9WgXcQ', + poster: 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', + href: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + showInline: true + }, + 'spotify-list-embed': { + spotifyType: 'album', + spotifyId: '4aawyAB9vmqN3uQ7FjRGTy', + href: 'https://open.spotify.com/album/4aawyAB9vmqN3uQ7FjRGTy' + }, + latestLivestream: {}, + livestreamEmbed: { + href: 'https://stream.place/', + embed: 'https://stream.place/embed/' + }, + mapLocation: { lat: 48.8584, lon: 2.2945, zoom: 13, name: 'Eiffel Tower, Paris' }, + gif: { url: 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.mp4', alt: 'Cat typing' }, + event: { + uri: 'at://did:plc:257wekqxg4hyapkq6k47igmp/community.lexicon.calendar.event/3mcsoqzy7gm2q' + }, + guestbook: { label: 'Guestbook' }, + githubProfile: { user: 'sveltejs', href: 'https://github.com/sveltejs' }, + photoGallery: { + galleryUri: 'at://did:plc:tas6hj2xjrqben5653v5kohk/social.grain.gallery/3mclhsljs6h2w' + }, + atprotocollections: {}, + publicationList: {}, + recentPopfeedReviews: {}, + recentTealFMPlays: {}, + statusphere: { emoji: '✨' }, + vcard: {}, + 'fluid-text': { text: 'Hello World' }, + draw: { strokesJson: '[]', viewBox: '', strokeWidth: 1, locked: true }, + clock: {}, + countdown: { targetDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() }, + timer: {}, + 'dino-game': {}, + tetris: {}, + updatedBlentos: {} + }; + + // Labels for cards that support canHaveLabel + const sampleLabels: Record = { + image: 'Mountain Landscape', + mapLocation: 'Eiffel Tower', + gif: 'Cat Typing', + bigsocial: 'Bluesky', + guestbook: 'Guestbook', + statusphere: 'My Status', + recentPopfeedReviews: 'My Reviews', + recentTealFMPlays: 'Recently Played', + clock: 'Local Time', + countdown: 'Launch Day', + timer: 'Timer', + 'dino-game': 'Dino Game', + tetris: 'Tetris', + blueskyMedia: 'Bluesky Media' + }; + + const newItems: Item[] = []; + let cursorY = 0; + let mobileCursorY = 0; + + for (const group of sortedGroups) { + const defs = grouped.get(group)!; + + // Add a section heading for the group + const heading = createEmptyCard(data.page); + heading.cardType = 'section'; + heading.cardData = { text: group, verticalAlign: 'bottom', textSize: 1 }; + heading.w = COLUMNS; + heading.h = 1; + heading.x = 0; + heading.y = cursorY; + heading.mobileW = COLUMNS; + heading.mobileH = 2; + heading.mobileX = 0; + heading.mobileY = mobileCursorY; + newItems.push(heading); + cursorY += 1; + mobileCursorY += 2; + + // Place cards in rows + let rowX = 0; + let rowMaxH = 0; + let mobileRowX = 0; + let mobileRowMaxH = 0; + + for (const def of defs) { + if (def.type === 'section' || def.type === 'embed') continue; + + const item = createEmptyCard(data.page); + item.cardType = def.type; + item.cardData = {}; + def.createNew?.(item); + + // Merge in sample data (without overwriting createNew defaults) + const extra = sampleData[def.type]; + if (extra) { + item.cardData = { ...item.cardData, ...extra }; + } + + // Set item-level color for cards that need it + if (def.type === 'button') { + item.color = 'transparent'; + } + + // Add label if card supports it + const label = sampleLabels[def.type]; + if (label && def.canHaveLabel) { + item.cardData.label = label; + } + + // Desktop layout + if (rowX + item.w > COLUMNS) { + cursorY += rowMaxH; + rowX = 0; + rowMaxH = 0; + } + item.x = rowX; + item.y = cursorY; + rowX += item.w; + rowMaxH = Math.max(rowMaxH, item.h); + + // Mobile layout + if (mobileRowX + item.mobileW > COLUMNS) { + mobileCursorY += mobileRowMaxH; + mobileRowX = 0; + mobileRowMaxH = 0; + } + item.mobileX = mobileRowX; + item.mobileY = mobileCursorY; + mobileRowX += item.mobileW; + mobileRowMaxH = Math.max(mobileRowMaxH, item.mobileH); + + newItems.push(item); + } + + // Move cursor past last row + cursorY += rowMaxH; + mobileCursorY += mobileRowMaxH; + } + + items = newItems; + onLayoutChanged(); + } + + let copyInput = $state(''); + let isCopying = $state(false); + + async function copyPageFrom() { + const input = copyInput.trim(); + if (!input) return; + + isCopying = true; + try { + // Parse "handle" or "handle/page" + const parts = input.split('/'); + const handle = parts[0]; + const pageName = parts[1] || 'self'; + + const did = await resolveHandle({ handle: handle as `${string}.${string}` }); + if (!did) throw new Error('Could not resolve handle'); + + const records = await listRecords({ did, collection: 'app.blento.card' }); + const targetPage = 'blento.' + pageName; + + const copiedCards: Item[] = records + .map((r) => ({ ...r.value }) as Item) + .filter((card) => { + // v0/v1 cards without page field belong to blento.self + if (!card.page) return targetPage === 'blento.self'; + return card.page === targetPage; + }) + .map((card) => { + // Apply v0→v1 migration (coords were halved in old format) + if (!card.version) { + card.x *= 2; + card.y *= 2; + card.h *= 2; + card.w *= 2; + card.mobileX *= 2; + card.mobileY *= 2; + card.mobileH *= 2; + card.mobileW *= 2; + card.version = 1; + } + + // Convert blob refs to CDN URLs using source DID + if (card.cardData) { + for (const key of Object.keys(card.cardData)) { + const val = card.cardData[key]; + if (val && typeof val === 'object' && val.$type === 'blob') { + const url = getCDNImageBlobUrl({ did, blob: val }); + if (url) card.cardData[key] = url; + } + } + } + + // Regenerate ID and assign to current page + card.id = TID.now(); + card.page = data.page; + return card; + }); + + if (copiedCards.length === 0) { + toast.error('No cards found on that page'); + return; + } + + fixAllCollisions(copiedCards); + fixAllCollisions(copiedCards, true); + compactItems(copiedCards); + compactItems(copiedCards, true); + + items = copiedCards; + onLayoutChanged(); + toast.success(`Copied ${copiedCards.length} cards from ${handle}`); + } catch (e) { + console.error('Failed to copy page:', e); + toast.error('Failed to copy page'); + } finally { + isCopying = false; + } + } + let debugPoint = $state({ x: 0, y: 0 }); function getGridPosition( @@ -1152,9 +1427,21 @@ {#if dev}
- editedOn: {editedOn} + editedOn: {editedOn} + + { + if (e.key === 'Enter') copyPageFrom(); + }} + /> +
{/if}