Something went wrong. Try again.
your personal website on atproto - mirror blento.app
Something went wrong. Try again.
Svelte
at update-docs
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490<script lang="ts"> import { Button, Modal, toast, Toaster, Sidebar } from '@foxui/core'; import { COLUMNS, margin, mobileMargin } from '$lib'; import { checkAndUploadImage, clamp, compactItems, createEmptyCard, findValidPosition, fixAllCollisions, fixCollisions, getHideProfileSection, getProfilePosition, getName, isTyping, savePage, scrollToItem, setPositionOfNewItem, validateLink, getImage } from '../helper'; import EditableProfile from './EditableProfile.svelte'; import type { Item, WebsiteData } from '../types'; import { innerWidth } from 'svelte/reactivity/window'; import EditingCard from '../cards/_base/Card/EditingCard.svelte'; import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; import { tick, type Component } from 'svelte'; import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; import { dev } from '$app/environment'; import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context'; import BaseEditingCard from '../cards/_base/BaseCard/BaseEditingCard.svelte'; import Context from './Context.svelte'; import Head from './Head.svelte'; import Account from './Account.svelte'; import { SelectThemePopover } from '$lib/components/select-theme'; import EditBar from './EditBar.svelte'; import SaveModal from './SaveModal.svelte'; import FloatingEditButton from './FloatingEditButton.svelte'; 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'; import { shouldMirror, mirrorLayout } from './layout-mirror'; import { SvelteMap } from 'svelte/reactivity';
let { data }: { data: WebsiteData; } = $props();
// Check if floating login button will be visible (to hide MadeWithBlento) const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn);
function updateTheme(newAccent: string, newBase: string) { data.publication.preferences ??= {}; data.publication.preferences.accentColor = newAccent; data.publication.preferences.baseColor = newBase; data = { ...data }; }
let imageDragOver = $state(false);
// svelte-ignore state_referenced_locally let items: Item[] = $state(data.cards);
// svelte-ignore state_referenced_locally let publication = $state(JSON.stringify(data.publication));
// Track saved state for comparison // svelte-ignore state_referenced_locally let savedItems = $state(JSON.stringify(data.cards)); // svelte-ignore state_referenced_locally let savedPublication = $state(JSON.stringify(data.publication));
let hasUnsavedChanges = $state(false);
$effect(() => { if (!hasUnsavedChanges) { hasUnsavedChanges = JSON.stringify(items) !== savedItems || JSON.stringify(data.publication) !== savedPublication; } });
// Warn user before closing tab if there are unsaved changes $effect(() => { function handleBeforeUnload(e: BeforeUnloadEvent) { if (hasUnsavedChanges) { e.preventDefault(); return ''; } }
window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); });
let container: HTMLDivElement | undefined = $state();
let activeDragElement: { element: HTMLDivElement | null; item: Item | null; w: number; h: number; x: number; y: number; mouseDeltaX: number; mouseDeltaY: number; // For hysteresis - track last decision to prevent flickering lastTargetId: string | null; lastPlacement: 'above' | 'below' | null; // Store original positions to reset from during drag originalPositions: Map<string, { x: number; y: number; mobileX: number; mobileY: number }>; } = $state({ element: null, item: null, w: 0, h: 0, x: -1, y: -1, mouseDeltaX: 0, mouseDeltaY: 0, lastTargetId: null, lastPlacement: null, originalPositions: new Map() });
let showingMobileView = $state(false); let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024); let showMobileWarning = $state((innerWidth.current ?? 1000) < 1024);
setIsMobile(() => isMobile);
// svelte-ignore state_referenced_locally let editedOn = $state(data.publication.preferences?.editedOn ?? 0);
function onLayoutChanged() { // Set the bit for the current layout: desktop=1, mobile=2 editedOn = editedOn | (isMobile ? 2 : 1); if (shouldMirror(editedOn)) { mirrorLayout(items, isMobile); } }
const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches; setIsCoarse(() => isCoarse);
let selectedCardId: string | null = $state(null); let selectedCard = $derived( selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null );
setSelectedCardId(() => selectedCardId); setSelectCard((id: string | null) => { selectedCardId = id; });
const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y); const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h);
let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0));
function getViewportCenterGridY(): { gridY: number; isMobile: boolean } | undefined { if (!container) return undefined; const rect = container.getBoundingClientRect(); const currentMargin = isMobile ? mobileMargin : margin; const cellSize = (rect.width - currentMargin * 2) / COLUMNS; const viewportCenterY = window.innerHeight / 2; const gridY = (viewportCenterY - rect.top - currentMargin) / cellSize; return { gridY, isMobile }; }
function newCard(type: string = 'link', cardData?: any) { selectedCardId = null;
// close sidebar if open const popover = document.getElementById('mobile-menu'); if (popover) { popover.hidePopover(); }
let item = createEmptyCard(data.page); item.cardType = type;
item.cardData = cardData ?? {};
const cardDef = CardDefinitionsByType[type]; cardDef?.createNew?.(item);
newItem.item = item;
if (cardDef?.creationModalComponent) { newItem.modal = cardDef.creationModalComponent; } else { saveNewItem(); } }
function cleanupDialogArtifacts() { // bits-ui's body scroll lock and portal may not clean up fully when the // modal is unmounted instead of closed via the open prop. const restore = () => { document.body.style.removeProperty('overflow'); document.body.style.removeProperty('pointer-events'); document.body.style.removeProperty('padding-right'); document.body.style.removeProperty('margin-right'); // Remove any orphaned dialog overlay/content elements left by the portal for (const el of document.querySelectorAll('[data-dialog-overlay], [data-dialog-content]')) { el.remove(); } }; // Run immediately and again after bits-ui's 24ms scheduled cleanup restore(); setTimeout(restore, 50); }
async function saveNewItem() { if (!newItem.item) return; const item = newItem.item;
const viewportCenter = getViewportCenterGridY(); setPositionOfNewItem(item, items, viewportCenter);
items = [...items, item];
// Push overlapping items down, then compact to fill gaps fixCollisions(items, item, false, true); fixCollisions(items, item, true, true); compactItems(items, false); compactItems(items, true);
onLayoutChanged();
newItem = {};
await tick(); cleanupDialogArtifacts();
scrollToItem(item, isMobile, container); }
let isSaving = $state(false); let showSaveModal = $state(false); let saveSuccess = $state(false);
let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({});
async function save() { isSaving = true; saveSuccess = false; showSaveModal = true;
try { // Upload profile icon if changed if (data.publication?.icon) { await checkAndUploadImage(data.publication, 'icon'); }
// Persist layout editing state data.publication.preferences ??= {}; data.publication.preferences.editedOn = editedOn;
await savePage(data, items, publication);
publication = JSON.stringify(data.publication);
// Update saved state savedItems = JSON.stringify(items); savedPublication = JSON.stringify(data.publication);
saveSuccess = true;
launchConfetti();
// Refresh cached data await fetch('/' + data.handle + '/api/refresh'); } catch (error) { console.error(error); showSaveModal = false; toast.error('Error saving page!'); } finally { isSaving = false; } }
function addAllCardTypes() { const groupOrder = ['Core', 'Social', 'Media', 'Content', 'Visual', 'Utilities', 'Games']; const grouped = new SvelteMap<string, CardDefinition[]>();
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<string, Record<string, unknown>> = { 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<string, string> = { 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( clientX: number, clientY: number ): | { x: number; y: number; swapWithId: string | null; placement: 'above' | 'below' | null } | undefined { if (!container || !activeDragElement.item) return;
// x, y represent the top-left corner of the dragged card const x = clientX + activeDragElement.mouseDeltaX; const y = clientY + activeDragElement.mouseDeltaY;
const rect = container.getBoundingClientRect(); const currentMargin = isMobile ? mobileMargin : margin; const cellSize = (rect.width - currentMargin * 2) / COLUMNS;
// Get card dimensions based on current view mode const cardW = isMobile ? (activeDragElement.item?.mobileW ?? activeDragElement.w) : activeDragElement.w; const cardH = isMobile ? (activeDragElement.item?.mobileH ?? activeDragElement.h) : activeDragElement.h;
// Get dragged card's original position const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
const draggedOrigY = draggedOrigPos ? isMobile ? draggedOrigPos.mobileY : draggedOrigPos.y : 0;
// Calculate raw grid position based on top-left of dragged card let gridX = clamp(Math.round((x - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); gridX = Math.floor(gridX / 2) * 2;
let gridY = Math.max(Math.round((y - rect.top - currentMargin) / cellSize), 0);
if (isMobile) { gridX = Math.floor(gridX / 2) * 2; gridY = Math.floor(gridY / 2) * 2; }
// Find if we're hovering over another card (using ORIGINAL positions) const centerGridY = gridY + cardH / 2; const centerGridX = gridX + cardW / 2;
let swapWithId: string | null = null; let placement: 'above' | 'below' | null = null;
for (const other of items) { if (other === activeDragElement.item) continue;
// Use original positions for hit testing const origPos = activeDragElement.originalPositions.get(other.id); if (!origPos) continue;
const otherX = isMobile ? origPos.mobileX : origPos.x; const otherY = isMobile ? origPos.mobileY : origPos.y; const otherW = isMobile ? other.mobileW : other.w; const otherH = isMobile ? other.mobileH : other.h;
// Check if dragged card's center point is within this card's original bounds if ( centerGridX >= otherX && centerGridX < otherX + otherW && centerGridY >= otherY && centerGridY < otherY + otherH ) { // Check if this is a swap situation: // Cards have the same dimensions and are on the same row const canSwap = cardW === otherW && cardH === otherH && draggedOrigY === otherY;
if (canSwap) { // Swap positions swapWithId = other.id; gridX = otherX; gridY = otherY; placement = null;
activeDragElement.lastTargetId = other.id; activeDragElement.lastPlacement = null; } else { // Vertical placement (above/below) // Detect drag direction: if dragging up, always place above const isDraggingUp = gridY < draggedOrigY;
if (isDraggingUp) { // When dragging up, always place above placement = 'above'; } else { // When dragging down, use top/bottom half logic const midpointY = otherY + otherH / 2; const hysteresis = 0.3;
if (activeDragElement.lastTargetId === other.id && activeDragElement.lastPlacement) { if (activeDragElement.lastPlacement === 'above') { placement = centerGridY > midpointY + hysteresis ? 'below' : 'above'; } else { placement = centerGridY < midpointY - hysteresis ? 'above' : 'below'; } } else { placement = centerGridY < midpointY ? 'above' : 'below'; } }
activeDragElement.lastTargetId = other.id; activeDragElement.lastPlacement = placement;
if (placement === 'above') { gridY = otherY; } else { gridY = otherY + otherH; } } break; } }
// If we're not over any card, clear the tracking if (!swapWithId && !placement) { activeDragElement.lastTargetId = null; activeDragElement.lastPlacement = null; }
debugPoint.x = x - rect.left; debugPoint.y = y - rect.top + currentMargin;
return { x: gridX, y: gridY, swapWithId, placement }; }
function getDragXY( e: DragEvent & { currentTarget: EventTarget & HTMLDivElement; } ) { return getGridPosition(e.clientX, e.clientY); }
// Touch drag system (instant drag on selected card) let touchDragActive = $state(false);
function touchStart(e: TouchEvent) { if (!selectedCardId || !container) return; const touch = e.touches[0]; if (!touch) return;
// Check if the touch is on the selected card element const target = (e.target as HTMLElement)?.closest?.('.card'); if (!target || target.id !== selectedCardId) return;
const item = items.find((i) => i.id === selectedCardId); if (!item || item.cardData?.locked) return;
// Start dragging immediately touchDragActive = true;
const cardEl = container.querySelector(`#${CSS.escape(selectedCardId)}`) as HTMLDivElement; if (!cardEl) return;
activeDragElement.element = cardEl; activeDragElement.w = item.w; activeDragElement.h = item.h; activeDragElement.item = item;
// Store original positions of all items activeDragElement.originalPositions = new Map(); for (const it of items) { activeDragElement.originalPositions.set(it.id, { x: it.x, y: it.y, mobileX: it.mobileX, mobileY: it.mobileY }); }
const rect = cardEl.getBoundingClientRect(); activeDragElement.mouseDeltaX = rect.left - touch.clientX; activeDragElement.mouseDeltaY = rect.top - touch.clientY; }
function touchMove(e: TouchEvent) { if (!touchDragActive) return;
const touch = e.touches[0]; if (!touch) return;
e.preventDefault();
const result = getGridPosition(touch.clientX, touch.clientY); if (!result || !activeDragElement.item) return;
const draggedOrigPos = activeDragElement.originalPositions.get(activeDragElement.item.id);
// Reset all items to original positions first for (const it of items) { const origPos = activeDragElement.originalPositions.get(it.id); if (origPos && it !== activeDragElement.item) { if (isMobile) { it.mobileX = origPos.mobileX; it.mobileY = origPos.mobileY; } else { it.x = origPos.x; it.y = origPos.y; } } }
// Update dragged item position if (isMobile) { activeDragElement.item.mobileX = result.x; activeDragElement.item.mobileY = result.y; } else { activeDragElement.item.x = result.x; activeDragElement.item.y = result.y; }
// Handle horizontal swap if (result.swapWithId && draggedOrigPos) { const swapTarget = items.find((it) => it.id === result.swapWithId); if (swapTarget) { if (isMobile) { swapTarget.mobileX = draggedOrigPos.mobileX; swapTarget.mobileY = draggedOrigPos.mobileY; } else { swapTarget.x = draggedOrigPos.x; swapTarget.y = draggedOrigPos.y; } } }
fixCollisions(items, activeDragElement.item, isMobile);
// Auto-scroll near edges const scrollZone = 100; const scrollSpeed = 10; const viewportHeight = window.innerHeight;
if (touch.clientY < scrollZone) { const intensity = 1 - touch.clientY / scrollZone; window.scrollBy(0, -scrollSpeed * intensity); } else if (touch.clientY > viewportHeight - scrollZone) { const intensity = 1 - (viewportHeight - touch.clientY) / scrollZone; window.scrollBy(0, scrollSpeed * intensity); } }
function touchEnd() { if (touchDragActive && activeDragElement.item) { // Finalize position fixCollisions(items, activeDragElement.item, isMobile); onLayoutChanged();
activeDragElement.x = -1; activeDragElement.y = -1; activeDragElement.element = null; activeDragElement.item = null; activeDragElement.lastTargetId = null; activeDragElement.lastPlacement = null; }
touchDragActive = false; }
// Only register non-passive touchmove when actively dragging $effect(() => { const el = container; if (!touchDragActive || !el) return;
el.addEventListener('touchmove', touchMove, { passive: false }); return () => { el.removeEventListener('touchmove', touchMove); }; });
let linkValue = $state('');
function addLink(url: string, specificCardDef?: CardDefinition) { let link = validateLink(url); if (!link) { toast.error('invalid link'); return; } let item = createEmptyCard(data.page);
if (specificCardDef?.onUrlHandler?.(link, item)) { item.cardType = specificCardDef.type; newItem.item = item; saveNewItem(); toast(specificCardDef.name + ' added!'); return; }
for (const cardDef of AllCardDefinitions.toSorted( (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) )) { if (cardDef.onUrlHandler?.(link, item)) { item.cardType = cardDef.type;
newItem.item = item; saveNewItem(); toast(cardDef.name + ' added!'); break; } } }
function getImageDimensions(src: string): Promise<{ width: number; height: number }> { return new Promise((resolve) => { const img = new Image(); img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight }); img.onerror = () => resolve({ width: 1, height: 1 }); img.src = src; }); }
function getBestGridSize( imageWidth: number, imageHeight: number, candidates: [number, number][] ): [number, number] { const imageRatio = imageWidth / imageHeight; let best: [number, number] = candidates[0]; let bestDiff = Infinity;
for (const candidate of candidates) { const gridRatio = candidate[0] / candidate[1]; const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio)); if (diff < bestDiff) { bestDiff = diff; best = candidate; } }
return best; }
const desktopSizeCandidates: [number, number][] = [ [2, 2], [2, 4], [4, 2], [4, 4], [4, 6], [6, 4] ]; const mobileSizeCandidates: [number, number][] = [ [4, 4], [4, 6], [4, 8], [6, 4], [8, 4], [8, 6] ];
async function processImageFile(file: File, gridX?: number, gridY?: number) { const isGif = file.type === 'image/gif';
// Don't compress GIFs to preserve animation const objectUrl = URL.createObjectURL(file);
let item = createEmptyCard(data.page);
item.cardType = isGif ? 'gif' : 'image'; item.cardData = { image: { blob: file, objectUrl } };
// Size card based on image aspect ratio const { width, height } = await getImageDimensions(objectUrl); const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates); const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates); item.w = dw; item.h = dh; item.mobileW = mw; item.mobileH = mh;
// If grid position is provided (image dropped on grid) if (gridX !== undefined && gridY !== undefined) { if (isMobile) { item.mobileX = gridX; item.mobileY = gridY; // Derive desktop Y from mobile item.x = Math.floor((COLUMNS - item.w) / 2); item.x = Math.floor(item.x / 2) * 2; item.y = Math.max(0, Math.round(gridY / 2)); } else { item.x = gridX; item.y = gridY; // Derive mobile Y from desktop item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2); item.mobileX = Math.floor(item.mobileX / 2) * 2; item.mobileY = Math.max(0, Math.round(gridY * 2)); }
items = [...items, item]; fixCollisions(items, item, isMobile); fixCollisions(items, item, !isMobile); } else { const viewportCenter = getViewportCenterGridY(); setPositionOfNewItem(item, items, viewportCenter); items = [...items, item]; fixCollisions(items, item, false, true); fixCollisions(items, item, true, true); compactItems(items, false); compactItems(items, true); }
onLayoutChanged();
await tick();
scrollToItem(item, isMobile, container); }
function handleImageDragOver(event: DragEvent) { const dt = event.dataTransfer; if (!dt) return;
let hasImage = false; if (dt.items) { for (let i = 0; i < dt.items.length; i++) { const item = dt.items[i]; if (item && item.kind === 'file' && item.type.startsWith('image/')) { hasImage = true; break; } } } else if (dt.files) { for (let i = 0; i < dt.files.length; i++) { const file = dt.files[i]; if (file?.type.startsWith('image/')) { hasImage = true; break; } } }
if (hasImage) { event.preventDefault(); event.stopPropagation();
imageDragOver = true; } }
function handleImageDragLeave(event: DragEvent) { event.preventDefault(); event.stopPropagation(); imageDragOver = false; }
async function handleImageDrop(event: DragEvent) { event.preventDefault(); event.stopPropagation(); const dropX = event.clientX; const dropY = event.clientY; imageDragOver = false;
if (!event.dataTransfer?.files?.length) return;
const imageFiles = Array.from(event.dataTransfer.files).filter((f) => f?.type.startsWith('image/') ); if (imageFiles.length === 0) return;
// Calculate starting grid position from drop coordinates let gridX = 0; let gridY = 0; if (container) { const rect = container.getBoundingClientRect(); const currentMargin = isMobile ? mobileMargin : margin; const cellSize = (rect.width - currentMargin * 2) / COLUMNS; const cardW = isMobile ? 4 : 2;
gridX = clamp(Math.round((dropX - rect.left - currentMargin) / cellSize), 0, COLUMNS - cardW); gridX = Math.floor(gridX / 2) * 2;
gridY = Math.max(Math.round((dropY - rect.top - currentMargin) / cellSize), 0); if (isMobile) { gridY = Math.floor(gridY / 2) * 2; } }
for (let i = 0; i < imageFiles.length; i++) { // First image gets the drop position, rest use normal placement if (i === 0) { await processImageFile(imageFiles[i], gridX, gridY); } else { await processImageFile(imageFiles[i]); } } }
async function handleImageInputChange(event: Event) { const target = event.target as HTMLInputElement; if (!target.files || target.files.length < 1) return;
const files = Array.from(target.files);
if (files.length === 1) { // Single file: use default positioning await processImageFile(files[0]); } else { // Multiple files: place in grid pattern starting from first available position let gridX = 0; let gridY = maxHeight; const cardW = isMobile ? 4 : 2; const cardH = isMobile ? 4 : 2;
for (const file of files) { await processImageFile(file, gridX, gridY);
// Move to next cell position gridX += cardW; if (gridX + cardW > COLUMNS) { gridX = 0; gridY += cardH; } } }
// Reset the input so the same file can be selected again target.value = ''; }
async function processVideoFile(file: File) { const objectUrl = URL.createObjectURL(file);
let item = createEmptyCard(data.page);
item.cardType = 'video'; item.cardData = { blob: file, objectUrl };
const viewportCenter = getViewportCenterGridY(); setPositionOfNewItem(item, items, viewportCenter); items = [...items, item]; fixCollisions(items, item, false, true); fixCollisions(items, item, true, true); compactItems(items, false); compactItems(items, true);
onLayoutChanged();
await tick();
scrollToItem(item, isMobile, container); }
async function handleVideoInputChange(event: Event) { const target = event.target as HTMLInputElement; if (!target.files || target.files.length < 1) return;
const files = Array.from(target.files);
for (const file of files) { await processVideoFile(file); }
// Reset the input so the same file can be selected again target.value = ''; }
let showCardCommand = $state(false);</script>
<svelte:body onpaste={(event) => { if (isTyping()) return;
const text = event.clipboardData?.getData('text/plain'); const link = validateLink(text, false); if (!link) return;
addLink(link); }}/>
<svelte:window ondragover={handleImageDragOver} ondragleave={handleImageDragLeave} ondrop={handleImageDrop}/>
<Head favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar} title={getName(data)} image={'/' + data.handle + '/og.png'} accentColor={data.publication?.preferences?.accentColor} baseColor={data.publication?.preferences?.baseColor}/>
<Account {data} />
<Context {data} isEditing={true}> <CardCommand bind:open={showCardCommand} onselect={(cardDef: CardDefinition) => { if (cardDef.type === 'image') { const input = document.getElementById('image-input') as HTMLInputElement; if (input) { input.click(); return; } } else if (cardDef.type === 'video') { const input = document.getElementById('video-input') as HTMLInputElement; if (input) { input.click(); return; } } else { newCard(cardDef.type); } }} onlink={(url, cardDef) => { addLink(url, cardDef); }} />
<Controls bind:data />
{#if showingMobileView} <div class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full" ></div> {/if}
{#if newItem.modal && newItem.item} <newItem.modal oncreate={() => { saveNewItem(); }} bind:item={newItem.item} oncancel={async () => { newItem = {}; await tick(); cleanupDialogArtifacts(); }} /> {/if}
<SaveModal bind:open={showSaveModal} success={saveSuccess} handle={data.handle} page={data.page} />
<Modal open={showMobileWarning} closeButton={false}> <div class="flex flex-col items-center gap-4 text-center"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="text-accent-500 size-10" > <path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3" /> </svg> <p class="text-base-700 dark:text-base-300 text-xl font-bold">Mobile Editing</p> <p class="text-base-500 dark:text-base-400 text-sm"> Mobile editing is currently experimental. For the best experience, use a desktop browser. </p> <Button class="mt-2 w-full" onclick={() => (showMobileWarning = false)}>Continue</Button> </div> </Modal>
<div class={[ '@container/wrapper relative w-full', showingMobileView ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90' : '' ]} > {#if !getHideProfileSection(data)} <EditableProfile bind:data hideBlento={showLoginOnEditPage} /> {/if}
<div class={[ 'pointer-events-none relative mx-auto max-w-lg', !getHideProfileSection(data) && getProfilePosition(data) === 'side' ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4' : '@5xl/wrapper:max-w-4xl' ]} > <div class="pointer-events-none"></div> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> <div bind:this={container} onclick={(e) => { // Deselect when tapping empty grid space if (e.target === e.currentTarget || !(e.target as HTMLElement)?.closest?.('.card')) { selectedCardId = null; } }} ontouchstart={touchStart} ontouchend={touchEnd} ondragover={(e) => { e.preventDefault();
const result = getDragXY(e); if (!result) return;
activeDragElement.x = result.x; activeDragElement.y = result.y;
if (activeDragElement.item) { // Get dragged card's original position for swapping const draggedOrigPos = activeDragElement.originalPositions.get( activeDragElement.item.id );
// Reset all items to original positions first for (const it of items) { const origPos = activeDragElement.originalPositions.get(it.id); if (origPos && it !== activeDragElement.item) { if (isMobile) { it.mobileX = origPos.mobileX; it.mobileY = origPos.mobileY; } else { it.x = origPos.x; it.y = origPos.y; } } }
// Update dragged item position if (isMobile) { activeDragElement.item.mobileX = result.x; activeDragElement.item.mobileY = result.y; } else { activeDragElement.item.x = result.x; activeDragElement.item.y = result.y; }
// Handle horizontal swap if (result.swapWithId && draggedOrigPos) { const swapTarget = items.find((it) => it.id === result.swapWithId); if (swapTarget) { // Move swap target to dragged card's original position if (isMobile) { swapTarget.mobileX = draggedOrigPos.mobileX; swapTarget.mobileY = draggedOrigPos.mobileY; } else { swapTarget.x = draggedOrigPos.x; swapTarget.y = draggedOrigPos.y; } } }
// Now fix collisions (with compacting) fixCollisions(items, activeDragElement.item, isMobile); }
// Auto-scroll when dragging near top or bottom of viewport const scrollZone = 100; const scrollSpeed = 10; const viewportHeight = window.innerHeight;
if (e.clientY < scrollZone) { // Near top - scroll up const intensity = 1 - e.clientY / scrollZone; window.scrollBy(0, -scrollSpeed * intensity); } else if (e.clientY > viewportHeight - scrollZone) { // Near bottom - scroll down const intensity = 1 - (viewportHeight - e.clientY) / scrollZone; window.scrollBy(0, scrollSpeed * intensity); } }} ondragend={async (e) => { e.preventDefault(); // safari fix activeDragElement.x = -1; activeDragElement.y = -1; activeDragElement.element = null; activeDragElement.item = null; activeDragElement.lastTargetId = null; activeDragElement.lastPlacement = null; return true; }} class={[ '@container/grid pointer-events-auto relative col-span-3 rounded-4xl px-2 py-8 @5xl/wrapper:px-8', imageDragOver && 'outline-accent-500 outline-3 -outline-offset-10 outline-dashed' ]} > {#each items as item, i (item.id)} <!-- {#if item !== activeDragElement.item} --> <BaseEditingCard bind:item={items[i]} ondelete={() => { items = items.filter((it) => it !== item); compactItems(items, false); compactItems(items, true); onLayoutChanged(); }} onsetsize={(newW: number, newH: number) => { if (isMobile) { item.mobileW = newW; item.mobileH = newH; } else { item.w = newW; item.h = newH; }
fixCollisions(items, item, isMobile); onLayoutChanged(); }} ondragstart={(e: DragEvent) => { const target = e.currentTarget as HTMLDivElement; activeDragElement.element = target; activeDragElement.w = item.w; activeDragElement.h = item.h; activeDragElement.item = item; // fix for div shadow during drag and drop const transparent = document.createElement('div'); transparent.style.position = 'fixed'; transparent.style.top = '-1000px'; transparent.style.width = '1px'; transparent.style.height = '1px'; document.body.appendChild(transparent); e.dataTransfer?.setDragImage(transparent, 0, 0); requestAnimationFrame(() => transparent.remove());
// Store original positions of all items activeDragElement.originalPositions = new Map(); for (const it of items) { activeDragElement.originalPositions.set(it.id, { x: it.x, y: it.y, mobileX: it.mobileX, mobileY: it.mobileY }); }
const rect = target.getBoundingClientRect(); activeDragElement.mouseDeltaX = rect.left - e.clientX; activeDragElement.mouseDeltaY = rect.top - e.clientY; }} > <EditingCard bind:item={items[i]} /> </BaseEditingCard> <!-- {/if} --> {/each}
<div style="height: {((maxHeight + 2) / 8) * 100}cqw;"></div> </div> </div> </div>
<EditBar {data} bind:linkValue bind:isSaving bind:showingMobileView {hasUnsavedChanges} {newCard} {addLink} {save} {handleImageInputChange} {handleVideoInputChange} showCardCommand={() => { showCardCommand = true; }} {selectedCard} {isMobile} {isCoarse} ondeselect={() => { selectedCardId = null; }} ondelete={() => { if (selectedCard) { items = items.filter((it) => it.id !== selectedCardId); compactItems(items, false); compactItems(items, true); onLayoutChanged(); selectedCardId = null; } }} onsetsize={(w: number, h: number) => { if (selectedCard) { if (isMobile) { selectedCard.mobileW = w; selectedCard.mobileH = h; } else { selectedCard.w = w; selectedCard.h = h; } fixCollisions(items, selectedCard, isMobile); onLayoutChanged(); } }} />
<Toaster />
<FloatingEditButton {data} />
{#if dev} <div 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" > <span>editedOn: {editedOn}</span> <button class="underline" onclick={addAllCardTypes}>+ all cards</button> <input bind:value={copyInput} placeholder="handle/page" class="bg-base-800 text-base-100 w-32 rounded px-1 py-0.5" onkeydown={(e) => { if (e.key === 'Enter') copyPageFrom(); }} /> <button class="underline" onclick={copyPageFrom} disabled={isCopying}> {isCopying ? 'copying...' : 'copy'} </button> </div> {/if}</Context>