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