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