your personal website on atproto - mirror
blento.app
1<script lang="ts">
2 import { Button, Modal, toast, Toaster } from '@foxui/core';
3 import { COLUMNS } from '$lib';
4 import {
5 checkAndUploadImage,
6 createEmptyCard,
7 getHideProfileSection,
8 getProfilePosition,
9 getName,
10 isTyping,
11 savePage,
12 scrollToItem,
13 validateLink,
14 getImage
15 } from '../helper';
16 import EditableProfile from './EditableProfile.svelte';
17 import type { Item, WebsiteData } from '../types';
18 import { innerWidth } from 'svelte/reactivity/window';
19 import EditingCard from '../cards/_base/Card/EditingCard.svelte';
20 import { AllCardDefinitions, CardDefinitionsByType } from '../cards';
21 import { tick, type Component } from 'svelte';
22 import type { CardDefinition, CreationModalComponentProps } from '../cards/types';
23 import { dev } from '$app/environment';
24 import { setIsCoarse, setIsMobile, setSelectedCardId, setSelectCard } from './context';
25 import BaseEditingCard from '../cards/_base/BaseCard/BaseEditingCard.svelte';
26 import Context from './Context.svelte';
27 import Head from './Head.svelte';
28 import Account from './Account.svelte';
29 import EditBar from './EditBar.svelte';
30 import SaveModal from './SaveModal.svelte';
31 import FloatingEditButton from './FloatingEditButton.svelte';
32 import { user, resolveHandle, listRecords, getCDNImageBlobUrl } from '$lib/atproto';
33 import * as TID from '@atcute/tid';
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,
41 compactItems,
42 fixAllCollisions,
43 setPositionOfNewItem,
44 shouldMirror,
45 mirrorLayout,
46 getViewportCenterGridY,
47 EditableGrid
48 } from '$lib/layout';
49
50 let {
51 data
52 }: {
53 data: WebsiteData;
54 } = $props();
55
56 // Check if floating login button will be visible (to hide MadeWithBlento)
57 const showLoginOnEditPage = $derived(!user.isInitializing && !user.isLoggedIn);
58
59 // svelte-ignore state_referenced_locally
60 let items: Item[] = $state(data.cards);
61
62 // svelte-ignore state_referenced_locally
63 let publication = $state(JSON.stringify(data.publication));
64
65 // svelte-ignore state_referenced_locally
66 let savedItemsSnapshot = JSON.stringify(data.cards);
67
68 // svelte-ignore state_referenced_locally
69 let savedPronouns = $state(JSON.stringify(data.pronounsRecord));
70
71 let hasUnsavedChanges = $state(false);
72
73 // Detect card content and publication changes (e.g. sidebar edits)
74 // The guard ensures JSON.stringify only runs while no changes are detected yet.
75 // Once hasUnsavedChanges is true, Svelte still fires this effect on item mutations
76 // but the early return makes it effectively free.
77 $effect(() => {
78 if (hasUnsavedChanges) return;
79 if (
80 JSON.stringify(items) !== savedItemsSnapshot ||
81 JSON.stringify(data.publication) !== publication ||
82 JSON.stringify(data.pronounsRecord) !== savedPronouns
83 ) {
84 hasUnsavedChanges = true;
85 }
86 });
87
88 // Warn user before closing tab if there are unsaved changes
89 $effect(() => {
90 function handleBeforeUnload(e: BeforeUnloadEvent) {
91 if (hasUnsavedChanges) {
92 e.preventDefault();
93 return '';
94 }
95 }
96
97 window.addEventListener('beforeunload', handleBeforeUnload);
98 return () => window.removeEventListener('beforeunload', handleBeforeUnload);
99 });
100
101 let gridContainer: HTMLDivElement | undefined = $state();
102
103 let showingMobileView = $state(false);
104 let isMobile = $derived(showingMobileView || (innerWidth.current ?? 1000) < 1024);
105 let showMobileWarning = $state((innerWidth.current ?? 1000) < 1024);
106
107 setIsMobile(() => isMobile);
108
109 // svelte-ignore state_referenced_locally
110 let editedOn = $state(data.publication.preferences?.editedOn ?? 0);
111
112 function onLayoutChanged() {
113 hasUnsavedChanges = true;
114 // Set the bit for the current layout: desktop=1, mobile=2
115 editedOn = editedOn | (isMobile ? 2 : 1);
116 if (shouldMirror(editedOn)) {
117 mirrorLayout(items, isMobile);
118 }
119 }
120
121 const isCoarse = typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches;
122 setIsCoarse(() => isCoarse);
123
124 let selectedCardId: string | null = $state(null);
125 let selectedCard = $derived(
126 selectedCardId ? (items.find((i) => i.id === selectedCardId) ?? null) : null
127 );
128
129 setSelectedCardId(() => selectedCardId);
130 setSelectCard((id: string | null) => {
131 selectedCardId = id;
132 });
133
134 const getY = (item: Item) => (isMobile ? (item.mobileY ?? item.y) : item.y);
135 const getH = (item: Item) => (isMobile ? (item.mobileH ?? item.h) : item.h);
136 let maxHeight = $derived(items.reduce((max, item) => Math.max(max, getY(item) + getH(item)), 0));
137
138 function newCard(type: string = 'link', cardData?: any) {
139 selectedCardId = null;
140
141 // close sidebar if open
142 const popover = document.getElementById('mobile-menu');
143 if (popover) {
144 popover.hidePopover();
145 }
146
147 let item = createEmptyCard(data.page);
148 item.cardType = type;
149
150 item.cardData = cardData ?? {};
151
152 const cardDef = CardDefinitionsByType[type];
153 cardDef?.createNew?.(item);
154
155 newItem.item = item;
156
157 if (cardDef?.creationModalComponent) {
158 newItem.modal = cardDef.creationModalComponent;
159 } else {
160 saveNewItem();
161 }
162 }
163
164 function cleanupDialogArtifacts() {
165 // bits-ui's body scroll lock and portal may not clean up fully when the
166 // modal is unmounted instead of closed via the open prop.
167 const restore = () => {
168 document.body.style.removeProperty('overflow');
169 document.body.style.removeProperty('pointer-events');
170 document.body.style.removeProperty('padding-right');
171 document.body.style.removeProperty('margin-right');
172 // Remove any orphaned dialog overlay/content elements left by the portal
173 for (const el of document.querySelectorAll('[data-dialog-overlay], [data-dialog-content]')) {
174 el.remove();
175 }
176 };
177 // Run immediately and again after bits-ui's 24ms scheduled cleanup
178 restore();
179 setTimeout(restore, 50);
180 }
181
182 async function saveNewItem() {
183 if (!newItem.item) return;
184 const item = newItem.item;
185
186 const viewportCenter = gridContainer
187 ? getViewportCenterGridY(gridContainer, isMobile)
188 : undefined;
189 setPositionOfNewItem(item, items, viewportCenter);
190
191 items = [...items, item];
192
193 // Push overlapping items down, then compact to fill gaps
194 fixCollisions(items, item, false, true);
195 fixCollisions(items, item, true, true);
196 compactItems(items, false);
197 compactItems(items, true);
198
199 onLayoutChanged();
200
201 newItem = {};
202
203 await tick();
204 cleanupDialogArtifacts();
205
206 scrollToItem(item, isMobile, gridContainer);
207 }
208
209 let isSaving = $state(false);
210 let showSaveModal = $state(false);
211 let saveSuccess = $state(false);
212
213 let newItem: { modal?: Component<CreationModalComponentProps>; item?: Item } = $state({});
214
215 async function save() {
216 isSaving = true;
217 saveSuccess = false;
218 showSaveModal = true;
219
220 try {
221 // Upload profile icon if changed
222 if (data.publication?.icon) {
223 await checkAndUploadImage(data.publication, 'icon');
224 }
225
226 // Persist layout editing state
227 data.publication.preferences ??= {};
228 data.publication.preferences.editedOn = editedOn;
229
230 await savePage(data, items, publication);
231
232 publication = JSON.stringify(data.publication);
233 savedPronouns = JSON.stringify(data.pronounsRecord);
234
235 savedItemsSnapshot = JSON.stringify(items);
236 hasUnsavedChanges = false;
237
238 saveSuccess = true;
239
240 launchConfetti();
241
242 // Refresh cached data
243 await fetch('/' + data.handle + '/api/refresh');
244 } catch (error) {
245 console.error(error);
246 showSaveModal = false;
247 toast.error('Error saving page!');
248 } finally {
249 isSaving = false;
250 }
251 }
252
253 let linkValue = $state('');
254
255 function addLink(url: string, specificCardDef?: CardDefinition) {
256 let link = validateLink(url);
257 if (!link) {
258 toast.error('invalid link');
259 return;
260 }
261 let item = createEmptyCard(data.page);
262
263 if (specificCardDef?.onUrlHandler?.(link, item)) {
264 item.cardType = specificCardDef.type;
265 newItem.item = item;
266 saveNewItem();
267 toast(specificCardDef.name + ' added!');
268 return;
269 }
270
271 for (const cardDef of AllCardDefinitions.toSorted(
272 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)
273 )) {
274 if (cardDef.onUrlHandler?.(link, item)) {
275 item.cardType = cardDef.type;
276
277 newItem.item = item;
278 saveNewItem();
279 toast(cardDef.name + ' added!');
280 break;
281 }
282 }
283 }
284
285 function getImageDimensions(src: string): Promise<{ width: number; height: number }> {
286 return new Promise((resolve) => {
287 const img = new Image();
288 img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
289 img.onerror = () => resolve({ width: 1, height: 1 });
290 img.src = src;
291 });
292 }
293
294 function getBestGridSize(
295 imageWidth: number,
296 imageHeight: number,
297 candidates: [number, number][]
298 ): [number, number] {
299 const imageRatio = imageWidth / imageHeight;
300 let best: [number, number] = candidates[0];
301 let bestDiff = Infinity;
302
303 for (const candidate of candidates) {
304 const gridRatio = candidate[0] / candidate[1];
305 const diff = Math.abs(Math.log(imageRatio) - Math.log(gridRatio));
306 if (diff < bestDiff) {
307 bestDiff = diff;
308 best = candidate;
309 }
310 }
311
312 return best;
313 }
314
315 const desktopSizeCandidates: [number, number][] = [
316 [2, 2],
317 [2, 4],
318 [4, 2],
319 [4, 4],
320 [4, 6],
321 [6, 4]
322 ];
323 const mobileSizeCandidates: [number, number][] = [
324 [4, 4],
325 [4, 6],
326 [4, 8],
327 [6, 4],
328 [8, 4],
329 [8, 6]
330 ];
331
332 async function processImageFile(file: File, gridX?: number, gridY?: number) {
333 const isGif = file.type === 'image/gif';
334
335 // Don't compress GIFs to preserve animation
336 const objectUrl = URL.createObjectURL(file);
337
338 let item = createEmptyCard(data.page);
339
340 item.cardType = isGif ? 'gif' : 'image';
341 item.cardData = {
342 image: { blob: file, objectUrl }
343 };
344
345 // Size card based on image aspect ratio
346 const { width, height } = await getImageDimensions(objectUrl);
347 const [dw, dh] = getBestGridSize(width, height, desktopSizeCandidates);
348 const [mw, mh] = getBestGridSize(width, height, mobileSizeCandidates);
349 item.w = dw;
350 item.h = dh;
351 item.mobileW = mw;
352 item.mobileH = mh;
353
354 // If grid position is provided (image dropped on grid)
355 if (gridX !== undefined && gridY !== undefined) {
356 if (isMobile) {
357 item.mobileX = gridX;
358 item.mobileY = gridY;
359 // Derive desktop Y from mobile
360 item.x = Math.floor((COLUMNS - item.w) / 2);
361 item.x = Math.floor(item.x / 2) * 2;
362 item.y = Math.max(0, Math.round(gridY / 2));
363 } else {
364 item.x = gridX;
365 item.y = gridY;
366 // Derive mobile Y from desktop
367 item.mobileX = Math.floor((COLUMNS - item.mobileW) / 2);
368 item.mobileX = Math.floor(item.mobileX / 2) * 2;
369 item.mobileY = Math.max(0, Math.round(gridY * 2));
370 }
371
372 items = [...items, item];
373 fixCollisions(items, item, isMobile);
374 fixCollisions(items, item, !isMobile);
375 } else {
376 const viewportCenter = gridContainer
377 ? getViewportCenterGridY(gridContainer, isMobile)
378 : undefined;
379 setPositionOfNewItem(item, items, viewportCenter);
380 items = [...items, item];
381 fixCollisions(items, item, false, true);
382 fixCollisions(items, item, true, true);
383 compactItems(items, false);
384 compactItems(items, true);
385 }
386
387 onLayoutChanged();
388
389 await tick();
390
391 scrollToItem(item, isMobile, gridContainer);
392 }
393
394 async function handleFileDrop(files: File[], gridX: number, gridY: number) {
395 for (let i = 0; i < files.length; i++) {
396 // First image gets the drop position, rest use normal placement
397 if (i === 0) {
398 await processImageFile(files[i], gridX, gridY);
399 } else {
400 await processImageFile(files[i]);
401 }
402 }
403 }
404
405 async function handleImageInputChange(event: Event) {
406 const target = event.target as HTMLInputElement;
407 if (!target.files || target.files.length < 1) return;
408
409 const files = Array.from(target.files);
410
411 if (files.length === 1) {
412 // Single file: use default positioning
413 await processImageFile(files[0]);
414 } else {
415 // Multiple files: place in grid pattern starting from first available position
416 let gridX = 0;
417 let gridY = maxHeight;
418 const cardW = isMobile ? 4 : 2;
419 const cardH = isMobile ? 4 : 2;
420
421 for (const file of files) {
422 await processImageFile(file, gridX, gridY);
423
424 // Move to next cell position
425 gridX += cardW;
426 if (gridX + cardW > COLUMNS) {
427 gridX = 0;
428 gridY += cardH;
429 }
430 }
431 }
432
433 // Reset the input so the same file can be selected again
434 target.value = '';
435 }
436
437 async function processVideoFile(file: File) {
438 const objectUrl = URL.createObjectURL(file);
439
440 let item = createEmptyCard(data.page);
441
442 item.cardType = 'video';
443 item.cardData = {
444 blob: file,
445 objectUrl
446 };
447
448 const viewportCenter = gridContainer
449 ? getViewportCenterGridY(gridContainer, isMobile)
450 : undefined;
451 setPositionOfNewItem(item, items, viewportCenter);
452 items = [...items, item];
453 fixCollisions(items, item, false, true);
454 fixCollisions(items, item, true, true);
455 compactItems(items, false);
456 compactItems(items, true);
457
458 onLayoutChanged();
459
460 await tick();
461
462 scrollToItem(item, isMobile, gridContainer);
463 }
464
465 async function handleVideoInputChange(event: Event) {
466 const target = event.target as HTMLInputElement;
467 if (!target.files || target.files.length < 1) return;
468
469 const files = Array.from(target.files);
470
471 for (const file of files) {
472 await processVideoFile(file);
473 }
474
475 // Reset the input so the same file can be selected again
476 target.value = '';
477 }
478
479 let showCardCommand = $state(false);
480</script>
481
482<svelte:body
483 onpaste={(event) => {
484 if (isTyping()) return;
485
486 const text = event.clipboardData?.getData('text/plain');
487 const link = validateLink(text, false);
488 if (!link) return;
489
490 addLink(link);
491 }}
492/>
493
494<Head
495 favicon={getImage(data.publication, data.did, 'icon') || data.profile.avatar}
496 title={getName(data)}
497 image={'/' + data.handle + '/og.png'}
498 accentColor={data.publication?.preferences?.accentColor}
499 baseColor={data.publication?.preferences?.baseColor}
500/>
501
502<Account {data} />
503
504<Context {data} isEditing={true}>
505 <ImageViewerProvider />
506 <CardCommand
507 bind:open={showCardCommand}
508 onselect={(cardDef: CardDefinition) => {
509 if (cardDef.type === 'image') {
510 const input = document.getElementById('image-input') as HTMLInputElement;
511 if (input) {
512 input.click();
513 return;
514 }
515 } else if (cardDef.type === 'video') {
516 const input = document.getElementById('video-input') as HTMLInputElement;
517 if (input) {
518 input.click();
519 return;
520 }
521 } else {
522 newCard(cardDef.type);
523 }
524 }}
525 onlink={(url, cardDef) => {
526 addLink(url, cardDef);
527 }}
528 />
529
530 <Controls bind:data />
531
532 {#if showingMobileView}
533 <div
534 class="bg-base-200 dark:bg-base-950 pointer-events-none fixed inset-0 -z-10 h-full w-full"
535 ></div>
536 {/if}
537
538 {#if newItem.modal && newItem.item}
539 <newItem.modal
540 oncreate={() => {
541 saveNewItem();
542 }}
543 bind:item={newItem.item}
544 oncancel={async () => {
545 newItem = {};
546 await tick();
547 cleanupDialogArtifacts();
548 }}
549 />
550 {/if}
551
552 <SaveModal
553 bind:open={showSaveModal}
554 success={saveSuccess}
555 handle={data.handle}
556 page={data.page}
557 />
558
559 <Modal open={showMobileWarning} closeButton={false}>
560 <div class="flex flex-col items-center gap-4 text-center">
561 <svg
562 xmlns="http://www.w3.org/2000/svg"
563 fill="none"
564 viewBox="0 0 24 24"
565 stroke-width="1.5"
566 stroke="currentColor"
567 class="text-accent-500 size-10"
568 >
569 <path
570 stroke-linecap="round"
571 stroke-linejoin="round"
572 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"
573 />
574 </svg>
575 <p class="text-base-700 dark:text-base-300 text-xl font-bold">Mobile Editing</p>
576 <p class="text-base-500 dark:text-base-400 text-sm">
577 Mobile editing is currently experimental. For the best experience, use a desktop browser.
578 </p>
579 <Button class="mt-2 w-full" onclick={() => (showMobileWarning = false)}>Continue</Button>
580 </div>
581 </Modal>
582
583 <div
584 class={[
585 '@container/wrapper relative w-full',
586 showingMobileView
587 ? 'bg-base-50 dark:bg-base-900 my-4 min-h-[calc(100dhv-2em)] rounded-2xl lg:mx-auto lg:w-90'
588 : ''
589 ]}
590 >
591 {#if !getHideProfileSection(data)}
592 <EditableProfile bind:data hideBlento={showLoginOnEditPage} />
593 {/if}
594
595 <div
596 class={[
597 'pointer-events-none relative mx-auto max-w-lg',
598 !getHideProfileSection(data) && getProfilePosition(data) === 'side'
599 ? '@5xl/wrapper:grid @5xl/wrapper:max-w-7xl @5xl/wrapper:grid-cols-4'
600 : '@5xl/wrapper:max-w-4xl'
601 ]}
602 >
603 <div class="pointer-events-none"></div>
604 <EditableGrid
605 bind:items
606 bind:ref={gridContainer}
607 {isMobile}
608 {selectedCardId}
609 {isCoarse}
610 onlayoutchange={onLayoutChanged}
611 ondeselect={() => {
612 selectedCardId = null;
613 }}
614 onfiledrop={handleFileDrop}
615 >
616 {#each items as item, i (item.id)}
617 <BaseEditingCard
618 bind:item={items[i]}
619 ondelete={() => {
620 items = items.filter((it) => it !== item);
621 compactItems(items, false);
622 compactItems(items, true);
623 onLayoutChanged();
624 }}
625 onsetsize={(newW: number, newH: number) => {
626 if (isMobile) {
627 item.mobileW = newW;
628 item.mobileH = newH;
629 } else {
630 item.w = newW;
631 item.h = newH;
632 }
633
634 fixCollisions(items, item, isMobile);
635 onLayoutChanged();
636 }}
637 >
638 <EditingCard bind:item={items[i]} />
639 </BaseEditingCard>
640 {/each}
641 </EditableGrid>
642 </div>
643 </div>
644
645 <EditBar
646 {data}
647 bind:linkValue
648 bind:isSaving
649 bind:showingMobileView
650 {hasUnsavedChanges}
651 {newCard}
652 {addLink}
653 {save}
654 {handleImageInputChange}
655 {handleVideoInputChange}
656 showCardCommand={() => {
657 showCardCommand = true;
658 }}
659 {selectedCard}
660 {isMobile}
661 {isCoarse}
662 ondeselect={() => {
663 selectedCardId = null;
664 }}
665 ondelete={() => {
666 if (selectedCard) {
667 items = items.filter((it) => it.id !== selectedCardId);
668 compactItems(items, false);
669 compactItems(items, true);
670 onLayoutChanged();
671 selectedCardId = null;
672 }
673 }}
674 onsetsize={(w: number, h: number) => {
675 if (selectedCard) {
676 if (isMobile) {
677 selectedCard.mobileW = w;
678 selectedCard.mobileH = h;
679 } else {
680 selectedCard.w = w;
681 selectedCard.h = h;
682 }
683 fixCollisions(items, selectedCard, isMobile);
684 onLayoutChanged();
685 }
686 }}
687 />
688
689 <Toaster />
690
691 <FloatingEditButton {data} />
692
693 {#if dev}
694 <div
695 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"
696 >
697 <span>editedOn: {editedOn}</span>
698 </div>
699 {/if}
700</Context>