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