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