your personal website on atproto - mirror
blento.app
1import type { Item, WebsiteData } from './types';
2import { COLUMNS, margin, mobileMargin } from '$lib';
3import { CardDefinitionsByType } from './cards';
4import { deleteRecord, getCDNImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto';
5import * as TID from '@atcute/tid';
6
7export function clamp(value: number, min: number, max: number): number {
8 return Math.min(Math.max(value, min), max);
9}
10
11export const colors = [
12 'bg-red-500',
13 'bg-orange-500',
14 'bg-amber-500',
15 'bg-yellow-500',
16 'bg-lime-500',
17 'bg-green-500',
18 'bg-emerald-500',
19 'bg-teal-500',
20 'bg-cyan-500',
21 'bg-sky-500',
22 'bg-blue-500',
23 'bg-indigo-500',
24 'bg-violet-500',
25 'bg-purple-500',
26 'bg-fuchsia-500',
27 'bg-pink-500',
28 'bg-rose-500'
29];
30
31export const overlaps = (a: Item, b: Item, mobile: boolean = false) => {
32 if (a === b) return false;
33 if (mobile) {
34 return (
35 a.mobileX < b.mobileX + b.mobileW &&
36 a.mobileX + a.mobileW > b.mobileX &&
37 a.mobileY < b.mobileY + b.mobileH &&
38 a.mobileY + a.mobileH > b.mobileY
39 );
40 }
41 return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
42};
43
44export function fixCollisions(
45 items: Item[],
46 movedItem: Item,
47 mobile: boolean = false,
48 skipCompact: boolean = false
49) {
50 const clampX = (item: Item) => {
51 if (mobile) item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW);
52 else item.x = clamp(item.x, 0, COLUMNS - item.w);
53 };
54
55 // Push `target` down until it no longer overlaps with any item (including movedItem),
56 // while keeping target.x fixed. Any item we collide with gets pushed down first (cascade).
57 const pushDownCascade = (target: Item, blocker: Item) => {
58 // Keep x fixed always when pushing down
59 const fixedX = mobile ? target.mobileX : target.x;
60 const prevY = mobile ? target.mobileY : target.y;
61
62 // We need target to move just below `blocker`
63 const desiredY = mobile ? blocker.mobileY + blocker.mobileH : blocker.y + blocker.h;
64 if (!mobile && target.y < desiredY) target.y = desiredY;
65 if (mobile && target.mobileY < desiredY) target.mobileY = desiredY;
66
67 const newY = mobile ? target.mobileY : target.y;
68 const targetH = mobile ? target.mobileH : target.h;
69
70 // fall trough fix
71 if (newY > prevY) {
72 const prevBottom = prevY + targetH;
73 const newBottom = newY + targetH;
74 for (const it of items) {
75 if (it === target || it === movedItem || it === blocker) continue;
76 const itY = mobile ? it.mobileY : it.y;
77 const itH = mobile ? it.mobileH : it.h;
78 const itBottom = itY + itH;
79 if (itBottom <= prevBottom || itY >= newBottom) continue;
80 // horizontal overlap check
81 const hOverlap = mobile
82 ? target.mobileX < it.mobileX + it.mobileW && target.mobileX + target.mobileW > it.mobileX
83 : target.x < it.x + it.w && target.x + target.w > it.x;
84 if (hOverlap) {
85 pushDownCascade(it, target);
86 }
87 }
88 }
89
90 // Now resolve any collisions that creates by pushing those items down first
91 // Repeat until target is clean.
92 while (true) {
93 const hit = items.find((it) => it !== target && overlaps(target, it, mobile));
94 if (!hit) break;
95
96 // push the hit item down first (cascade), keeping its x fixed
97 pushDownCascade(hit, target);
98
99 // after moving the hit item, target.x must remain fixed
100 if (mobile) target.mobileX = fixedX;
101 else target.x = fixedX;
102 }
103 };
104
105 // Ensure moved item is in bounds
106 clampX(movedItem);
107
108 // Find all items colliding with movedItem, and push them down in a stable order:
109 // top-to-bottom so you get the nice chain reaction (0,0 -> 0,1 -> 0,2).
110 const colliders = items
111 .filter((it) => it !== movedItem && overlaps(movedItem, it, mobile))
112 .toSorted((a, b) =>
113 mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x
114 );
115
116 for (const it of colliders) {
117 // keep x clamped, but do NOT change x during push (we rely on fixed x)
118 clampX(it);
119
120 // push it down just below movedItem; cascade handles the rest
121 pushDownCascade(it, movedItem);
122
123 // enforce "x stays the same" during pushing (clamp already applied)
124 if (mobile) it.mobileX = clamp(it.mobileX, 0, COLUMNS - it.mobileW);
125 else it.x = clamp(it.x, 0, COLUMNS - it.w);
126 }
127
128 if (!skipCompact) {
129 compactItems(items, mobile);
130 }
131}
132
133// Fix all collisions between items (not just one moved item)
134// Items higher on the page have priority and stay in place
135export function fixAllCollisions(items: Item[], mobile: boolean = false) {
136 // Sort by Y position (top-to-bottom, then left-to-right)
137 // Items at the top have priority and won't be moved
138 const sortedItems = items.toSorted((a, b) =>
139 mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x
140 );
141
142 // Process each item and push it down if it overlaps with any item above it
143 for (let i = 0; i < sortedItems.length; i++) {
144 const item = sortedItems[i];
145
146 // Clamp X to valid range
147 if (mobile) {
148 item.mobileX = clamp(item.mobileX, 0, COLUMNS - item.mobileW);
149 } else {
150 item.x = clamp(item.x, 0, COLUMNS - item.w);
151 }
152
153 // Check for collisions with all items that come before (higher priority)
154 let hasCollision = true;
155 while (hasCollision) {
156 hasCollision = false;
157 for (let j = 0; j < i; j++) {
158 const other = sortedItems[j];
159 if (overlaps(item, other, mobile)) {
160 // Push item down below the colliding item
161 if (mobile) {
162 item.mobileY = other.mobileY + other.mobileH;
163 } else {
164 item.y = other.y + other.h;
165 }
166 hasCollision = true;
167 break; // Restart collision check from the beginning
168 }
169 }
170 }
171 }
172
173 compactItems(items, mobile);
174}
175
176// Move all items up as far as possible without collisions
177export function compactItems(items: Item[], mobile: boolean = false) {
178 // Sort by Y position (top-to-bottom) so upper items settle first.
179 const sortedItems = items.toSorted((a, b) =>
180 mobile ? a.mobileY - b.mobileY || a.mobileX - b.mobileX : a.y - b.y || a.x - b.x
181 );
182
183 // For each item, find the lowest Y it can occupy by checking the bottom edges
184 // of all horizontally-overlapping items already placed above it.
185 const settled: Item[] = [];
186
187 for (const item of sortedItems) {
188 const itemX = mobile ? item.mobileX : item.x;
189 const itemW = mobile ? item.mobileW : item.w;
190
191 let minY = 0;
192
193 for (const other of settled) {
194 const otherX = mobile ? other.mobileX : other.x;
195 const otherW = mobile ? other.mobileW : other.w;
196
197 // Check horizontal overlap
198 if (itemX < otherX + otherW && itemX + itemW > otherX) {
199 const otherBottom = mobile ? other.mobileY + other.mobileH : other.y + other.h;
200 if (otherBottom > minY) {
201 minY = otherBottom;
202 }
203 }
204 }
205
206 if (mobile) {
207 item.mobileY = minY;
208 } else {
209 item.y = minY;
210 }
211
212 settled.push(item);
213 }
214}
215
216// Simulate where an item would end up after fixCollisions + compaction
217export function simulateFinalPosition(
218 items: Item[],
219 movedItem: Item,
220 newX: number,
221 newY: number,
222 mobile: boolean = false
223): { x: number; y: number } {
224 // Deep clone positions for simulation
225 const clonedItems: Item[] = items.map((item) => ({
226 ...item,
227 x: item.x,
228 y: item.y,
229 mobileX: item.mobileX,
230 mobileY: item.mobileY
231 }));
232
233 const clonedMovedItem = clonedItems.find((item) => item.id === movedItem.id);
234 if (!clonedMovedItem) return { x: newX, y: newY };
235
236 // Set the new position
237 if (mobile) {
238 clonedMovedItem.mobileX = newX;
239 clonedMovedItem.mobileY = newY;
240 } else {
241 clonedMovedItem.x = newX;
242 clonedMovedItem.y = newY;
243 }
244
245 // Run fixCollisions on the cloned data
246 fixCollisions(clonedItems, clonedMovedItem, mobile);
247
248 // Return the final position of the moved item
249 return mobile
250 ? { x: clonedMovedItem.mobileX, y: clonedMovedItem.mobileY }
251 : { x: clonedMovedItem.x, y: clonedMovedItem.y };
252}
253
254export function sortItems(a: Item, b: Item) {
255 return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x;
256}
257
258export function cardsEqual(a: Item, b: Item) {
259 return (
260 a.id === b.id &&
261 a.cardType === b.cardType &&
262 JSON.stringify(a.cardData) === JSON.stringify(b.cardData) &&
263 a.w === b.w &&
264 a.h === b.h &&
265 a.mobileW === b.mobileW &&
266 a.mobileH === b.mobileH &&
267 a.x === b.x &&
268 a.y === b.y &&
269 a.mobileX === b.mobileX &&
270 a.mobileY === b.mobileY &&
271 a.color === b.color &&
272 a.page === b.page
273 );
274}
275
276export function setPositionOfNewItem(
277 newItem: Item,
278 items: Item[],
279 viewportCenter?: { gridY: number; isMobile: boolean }
280) {
281 if (viewportCenter) {
282 const { gridY, isMobile } = viewportCenter;
283
284 if (isMobile) {
285 // Place at viewport center Y
286 newItem.mobileY = Math.max(0, Math.round(gridY - newItem.mobileH / 2));
287 newItem.mobileY = Math.floor(newItem.mobileY / 2) * 2;
288
289 // Try to find a free X at this Y
290 let found = false;
291 for (
292 newItem.mobileX = 0;
293 newItem.mobileX <= COLUMNS - newItem.mobileW;
294 newItem.mobileX += 2
295 ) {
296 if (!items.some((item) => overlaps(newItem, item, true))) {
297 found = true;
298 break;
299 }
300 }
301 if (!found) {
302 newItem.mobileX = 0;
303 }
304
305 // Desktop: derive from mobile
306 newItem.y = Math.max(0, Math.round(newItem.mobileY / 2));
307 found = false;
308 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) {
309 if (!items.some((item) => overlaps(newItem, item, false))) {
310 found = true;
311 break;
312 }
313 }
314 if (!found) {
315 newItem.x = 0;
316 }
317 } else {
318 // Place at viewport center Y
319 newItem.y = Math.max(0, Math.round(gridY - newItem.h / 2));
320
321 // Try to find a free X at this Y
322 let found = false;
323 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x += 2) {
324 if (!items.some((item) => overlaps(newItem, item, false))) {
325 found = true;
326 break;
327 }
328 }
329 if (!found) {
330 newItem.x = 0;
331 }
332
333 // Mobile: derive from desktop
334 newItem.mobileY = Math.max(0, Math.round(newItem.y * 2));
335 found = false;
336 for (
337 newItem.mobileX = 0;
338 newItem.mobileX <= COLUMNS - newItem.mobileW;
339 newItem.mobileX += 2
340 ) {
341 if (!items.some((item) => overlaps(newItem, item, true))) {
342 found = true;
343 break;
344 }
345 }
346 if (!found) {
347 newItem.mobileX = 0;
348 }
349 }
350 return;
351 }
352
353 let foundPosition = false;
354 while (!foundPosition) {
355 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) {
356 const collision = items.find((item) => overlaps(newItem, item));
357 if (!collision) {
358 foundPosition = true;
359 break;
360 }
361 }
362 if (!foundPosition) newItem.y += 1;
363 }
364
365 let foundMobilePosition = false;
366 while (!foundMobilePosition) {
367 for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX += 1) {
368 const collision = items.find((item) => overlaps(newItem, item, true));
369
370 if (!collision) {
371 foundMobilePosition = true;
372 break;
373 }
374 }
375 if (!foundMobilePosition) newItem.mobileY! += 1;
376 }
377}
378
379/**
380 * Find a valid position for a new item in a single mode (desktop or mobile).
381 * This modifies the item's position properties in-place.
382 */
383export function findValidPosition(newItem: Item, items: Item[], mobile: boolean) {
384 if (mobile) {
385 let foundPosition = false;
386 newItem.mobileY = 0;
387 while (!foundPosition) {
388 for (newItem.mobileX = 0; newItem.mobileX <= COLUMNS - newItem.mobileW; newItem.mobileX++) {
389 const collision = items.find((item) => overlaps(newItem, item, true));
390 if (!collision) {
391 foundPosition = true;
392 break;
393 }
394 }
395 if (!foundPosition) newItem.mobileY! += 1;
396 }
397 } else {
398 let foundPosition = false;
399 newItem.y = 0;
400 while (!foundPosition) {
401 for (newItem.x = 0; newItem.x <= COLUMNS - newItem.w; newItem.x++) {
402 const collision = items.find((item) => overlaps(newItem, item, false));
403 if (!collision) {
404 foundPosition = true;
405 break;
406 }
407 }
408 if (!foundPosition) newItem.y += 1;
409 }
410 }
411}
412
413export async function refreshData(data: { updatedAt?: number; handle: string }) {
414 const TEN_MINUTES = 10 * 60 * 1000;
415 const now = Date.now();
416
417 if (now - (data.updatedAt || 0) > TEN_MINUTES) {
418 try {
419 await fetch('/' + data.handle + '/api/refresh');
420 console.log('successfully refreshed data', data.handle);
421 } catch (error) {
422 console.error('error refreshing data', error);
423 }
424 } else {
425 console.log('data still fresh, skipping refreshing', data.handle);
426 }
427}
428
429export function getName(data: WebsiteData): string {
430 return data.publication?.name || data.profile.displayName || data.handle;
431}
432
433export function getDescription(data: WebsiteData): string {
434 return data.publication?.description ?? data.profile.description ?? '';
435}
436
437export function getHideProfileSection(data: WebsiteData): boolean {
438 if (data?.publication?.preferences?.hideProfileSection !== undefined)
439 return data?.publication?.preferences?.hideProfileSection;
440
441 if (data?.publication?.preferences?.hideProfile !== undefined)
442 return data?.publication?.preferences?.hideProfile;
443
444 return data.page !== 'blento.self';
445}
446
447export function getProfilePosition(data: WebsiteData): 'side' | 'top' {
448 return data?.publication?.preferences?.profilePosition ?? 'side';
449}
450
451export function isTyping() {
452 const active = document.activeElement;
453
454 const isEditable =
455 active instanceof HTMLInputElement ||
456 active instanceof HTMLTextAreaElement ||
457 // @ts-expect-error this fine
458 active?.isContentEditable;
459
460 return isEditable;
461}
462
463export function validateLink(
464 link: string | undefined,
465 tryAdding: boolean = true
466): string | undefined {
467 if (!link) return;
468 try {
469 new URL(link);
470
471 return link;
472 } catch (e) {
473 if (!tryAdding) return;
474
475 try {
476 link = 'https://' + link;
477 new URL(link);
478
479 return link;
480 } catch (e) {
481 return;
482 }
483 }
484}
485
486export function compressImage(file: File | Blob, maxSize: number = 900 * 1024): Promise<Blob> {
487 return new Promise((resolve, reject) => {
488 const img = new Image();
489 const reader = new FileReader();
490
491 reader.onload = (e) => {
492 if (!e.target?.result) {
493 return reject(new Error('Failed to read file.'));
494 }
495 img.src = e.target.result as string;
496 };
497
498 reader.onerror = (err) => reject(err);
499 reader.readAsDataURL(file);
500
501 img.onload = () => {
502 const maxDimension = 2048;
503
504 // If image is already small enough, return original
505 if (file.size <= maxSize) {
506 console.log('skipping compression+resizing, already small enough');
507 return resolve(file);
508 }
509
510 let width = img.width;
511 let height = img.height;
512
513 if (width > maxDimension || height > maxDimension) {
514 if (width > height) {
515 height = Math.round((maxDimension / width) * height);
516 width = maxDimension;
517 } else {
518 width = Math.round((maxDimension / height) * width);
519 height = maxDimension;
520 }
521 }
522
523 // Create a canvas to draw the image
524 const canvas = document.createElement('canvas');
525 canvas.width = width;
526 canvas.height = height;
527 const ctx = canvas.getContext('2d');
528 if (!ctx) return reject(new Error('Failed to get canvas context.'));
529 ctx.drawImage(img, 0, 0, width, height);
530
531 // Use WebP for both compression and transparency support
532 let quality = 0.9;
533
534 function attemptCompression() {
535 canvas.toBlob(
536 (blob) => {
537 if (!blob) {
538 return reject(new Error('Compression failed.'));
539 }
540 if (blob.size <= maxSize || quality < 0.3) {
541 resolve(blob);
542 } else {
543 quality -= 0.1;
544 attemptCompression();
545 }
546 },
547 'image/webp',
548 quality
549 );
550 }
551
552 attemptCompression();
553 };
554
555 img.onerror = (err) => reject(err);
556 });
557}
558
559export async function savePage(
560 data: WebsiteData,
561 currentItems: Item[],
562 originalPublication: string
563) {
564 const promises = [];
565
566 // Build a lookup of original cards by ID for O(1) access
567 const originalCardsById = new Map<string, Item>();
568 for (const card of data.cards) {
569 originalCardsById.set(card.id, card);
570 }
571
572 // find all cards that have been updated (where items differ from originalItems)
573 for (let item of currentItems) {
574 const orig = originalCardsById.get(item.id);
575 const originalItem = orig && cardsEqual(orig, item) ? orig : undefined;
576
577 if (!originalItem) {
578 console.log('updated or new item', item);
579 item.updatedAt = new Date().toISOString();
580 // run optional upload function for this card type
581 const cardDef = CardDefinitionsByType[item.cardType];
582
583 if (cardDef?.upload) {
584 item = await cardDef?.upload(item);
585 }
586
587 const parsedItem = JSON.parse(JSON.stringify(item));
588
589 parsedItem.page = data.page;
590 parsedItem.version = 2;
591
592 promises.push(
593 putRecord({
594 collection: 'app.blento.card',
595 rkey: parsedItem.id,
596 record: parsedItem
597 })
598 );
599 }
600 }
601
602 // delete items that are in originalItems but not in items
603 for (const originalItem of data.cards) {
604 const item = currentItems.find((i) => i.id === originalItem.id);
605 if (!item) {
606 console.log('deleting item', originalItem);
607 promises.push(deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id }));
608 }
609 }
610
611 if (
612 data.publication?.preferences?.hideProfile !== undefined &&
613 data.publication?.preferences?.hideProfileSection === undefined
614 ) {
615 data.publication.preferences.hideProfileSection = data.publication?.preferences?.hideProfile;
616 }
617
618 if (!originalPublication || originalPublication !== JSON.stringify(data.publication)) {
619 data.publication ??= {
620 name: getName(data),
621 description: getDescription(data),
622 preferences: {
623 hideProfileSection: getHideProfileSection(data)
624 }
625 };
626
627 if (!data.publication.url) {
628 data.publication.url = 'https://blento.app/' + data.handle;
629
630 if (data.page !== 'blento.self') {
631 data.publication.url += '/' + data.page.replace('blento.', '');
632 }
633 }
634 if (data.page !== 'blento.self') {
635 promises.push(
636 putRecord({
637 collection: 'app.blento.page',
638 rkey: data.page,
639 record: data.publication
640 })
641 );
642 } else {
643 promises.push(
644 putRecord({
645 collection: 'site.standard.publication',
646 rkey: data.page,
647 record: data.publication
648 })
649 );
650 }
651
652 console.log('updating or adding publication', data.publication);
653 }
654
655 await Promise.all(promises);
656}
657
658export function createEmptyCard(page: string) {
659 return {
660 id: TID.now(),
661 x: 0,
662 y: 0,
663 w: 2,
664 h: 2,
665 mobileH: 4,
666 mobileW: 4,
667 mobileX: 0,
668 mobileY: 0,
669 cardType: '',
670 cardData: {},
671 page
672 } as Item;
673}
674
675export function scrollToItem(
676 item: Item,
677 isMobile: boolean,
678 container: HTMLDivElement | undefined,
679 force: boolean = false
680) {
681 // scroll to newly created card only if not fully visible
682 const containerRect = container?.getBoundingClientRect();
683 if (!containerRect) return;
684 const currentMargin = isMobile ? mobileMargin : margin;
685 const currentY = isMobile ? item.mobileY : item.y;
686 const currentH = isMobile ? item.mobileH : item.h;
687 const cellSize = (containerRect.width - currentMargin * 2) / COLUMNS;
688
689 const cardTop = containerRect.top + currentMargin + currentY * cellSize;
690 const cardBottom = containerRect.top + currentMargin + (currentY + currentH) * cellSize;
691
692 const isFullyVisible = cardTop >= 0 && cardBottom <= window.innerHeight;
693
694 if (!isFullyVisible || force) {
695 const bodyRect = document.body.getBoundingClientRect();
696 const offset = containerRect.top - bodyRect.top;
697 window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' });
698 }
699}
700
701export async function checkAndUploadImage(
702 objectWithImage: Record<string, any>,
703 key: string = 'image'
704) {
705 if (!objectWithImage[key]) return;
706
707 // Already uploaded as blob
708 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') {
709 return;
710 }
711
712 if (typeof objectWithImage[key] === 'string') {
713 // Download image from URL via proxy (to avoid CORS) and upload as blob
714 try {
715 const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(objectWithImage[key])}`;
716 const response = await fetch(proxyUrl);
717 if (!response.ok) {
718 console.error('Failed to fetch image:', objectWithImage[key]);
719 return;
720 }
721 const blob = await response.blob();
722 const compressedBlob = await compressImage(blob);
723 objectWithImage[key] = await uploadBlob({ blob: compressedBlob });
724 } catch (error) {
725 console.error('Failed to download and upload image:', error);
726 }
727 return;
728 }
729
730 if (objectWithImage[key]?.blob) {
731 const compressedBlob = await compressImage(objectWithImage[key].blob);
732 objectWithImage[key] = await uploadBlob({ blob: compressedBlob });
733 }
734}
735
736export function getImage(
737 objectWithImage: Record<string, any> | undefined,
738 did: string,
739 key: string = 'image'
740) {
741 if (!objectWithImage?.[key]) return;
742
743 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl;
744
745 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') {
746 return getCDNImageBlobUrl({ did, blob: objectWithImage[key] });
747 }
748 return objectWithImage[key];
749}