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';
6export function clamp(value: number, min: number, max: number): number {
7 return Math.min(Math.max(value, min), max);
8}
9
10export const colors = [
11 'bg-red-500',
12 'bg-orange-500',
13 'bg-amber-500',
14 'bg-yellow-500',
15 'bg-lime-500',
16 'bg-green-500',
17 'bg-emerald-500',
18 'bg-teal-500',
19 'bg-cyan-500',
20 'bg-sky-500',
21 'bg-blue-500',
22 'bg-indigo-500',
23 'bg-violet-500',
24 'bg-purple-500',
25 'bg-fuchsia-500',
26 'bg-pink-500',
27 'bg-rose-500'
28];
29
30export function sortItems(a: Item, b: Item) {
31 return a.y * COLUMNS + a.x - b.y * COLUMNS - b.x;
32}
33
34export function cardsEqual(a: Item, b: Item) {
35 return (
36 a.id === b.id &&
37 a.cardType === b.cardType &&
38 JSON.stringify(a.cardData) === JSON.stringify(b.cardData) &&
39 a.w === b.w &&
40 a.h === b.h &&
41 a.mobileW === b.mobileW &&
42 a.mobileH === b.mobileH &&
43 a.x === b.x &&
44 a.y === b.y &&
45 a.mobileX === b.mobileX &&
46 a.mobileY === b.mobileY &&
47 a.color === b.color &&
48 a.page === b.page
49 );
50}
51
52export async function refreshData(data: { updatedAt?: number; handle: string }) {
53 const TEN_MINUTES = 10 * 60 * 1000;
54 const now = Date.now();
55
56 if (now - (data.updatedAt || 0) > TEN_MINUTES) {
57 try {
58 await fetch('/' + data.handle + '/api/refresh');
59 console.log('successfully refreshed data', data.handle);
60 } catch (error) {
61 console.error('error refreshing data', error);
62 }
63 } else {
64 console.log('data still fresh, skipping refreshing', data.handle);
65 }
66}
67
68export function getName(data: WebsiteData): string {
69 return data.publication?.name || data.profile.displayName || data.handle;
70}
71
72export function getDescription(data: WebsiteData): string {
73 return data.publication?.description ?? data.profile.description ?? '';
74}
75
76export function getHideProfileSection(data: WebsiteData): boolean {
77 if (data?.publication?.preferences?.hideProfileSection !== undefined)
78 return data?.publication?.preferences?.hideProfileSection;
79
80 if (data?.publication?.preferences?.hideProfile !== undefined)
81 return data?.publication?.preferences?.hideProfile;
82
83 return data.page !== 'blento.self';
84}
85
86export function getProfilePosition(data: WebsiteData): 'side' | 'top' {
87 return data?.publication?.preferences?.profilePosition ?? 'side';
88}
89
90export function isTyping() {
91 const active = document.activeElement;
92
93 const isEditable =
94 active instanceof HTMLInputElement ||
95 active instanceof HTMLTextAreaElement ||
96 // @ts-expect-error this fine
97 active?.isContentEditable;
98
99 return isEditable;
100}
101
102export function validateLink(
103 link: string | undefined,
104 tryAdding: boolean = true
105): string | undefined {
106 if (!link) return;
107 try {
108 new URL(link);
109
110 return link;
111 } catch (e) {
112 if (!tryAdding) return;
113
114 try {
115 link = 'https://' + link;
116 new URL(link);
117
118 return link;
119 } catch (e) {
120 return;
121 }
122 }
123}
124
125export function compressImage(file: File | Blob, maxSize: number = 900 * 1024): Promise<Blob> {
126 return new Promise((resolve, reject) => {
127 const img = new Image();
128 const reader = new FileReader();
129
130 reader.onload = (e) => {
131 if (!e.target?.result) {
132 return reject(new Error('Failed to read file.'));
133 }
134 img.src = e.target.result as string;
135 };
136
137 reader.onerror = (err) => reject(err);
138 reader.readAsDataURL(file);
139
140 img.onload = () => {
141 const maxDimension = 2048;
142
143 // If image is already small enough, return original
144 if (file.size <= maxSize) {
145 console.log('skipping compression+resizing, already small enough');
146 return resolve(file);
147 }
148
149 let width = img.width;
150 let height = img.height;
151
152 if (width > maxDimension || height > maxDimension) {
153 if (width > height) {
154 height = Math.round((maxDimension / width) * height);
155 width = maxDimension;
156 } else {
157 width = Math.round((maxDimension / height) * width);
158 height = maxDimension;
159 }
160 }
161
162 // Create a canvas to draw the image
163 const canvas = document.createElement('canvas');
164 canvas.width = width;
165 canvas.height = height;
166 const ctx = canvas.getContext('2d');
167 if (!ctx) return reject(new Error('Failed to get canvas context.'));
168 ctx.drawImage(img, 0, 0, width, height);
169
170 // Use WebP for both compression and transparency support
171 let quality = 0.9;
172
173 function attemptCompression() {
174 canvas.toBlob(
175 (blob) => {
176 if (!blob) {
177 return reject(new Error('Compression failed.'));
178 }
179 if (blob.size <= maxSize || quality < 0.3) {
180 resolve(blob);
181 } else {
182 quality -= 0.1;
183 attemptCompression();
184 }
185 },
186 'image/webp',
187 quality
188 );
189 }
190
191 attemptCompression();
192 };
193
194 img.onerror = (err) => reject(err);
195 });
196}
197
198export async function savePage(
199 data: WebsiteData,
200 currentItems: Item[],
201 originalPublication: string
202) {
203 const promises = [];
204
205 // Build a lookup of original cards by ID for O(1) access
206 const originalCardsById = new Map<string, Item>();
207 for (const card of data.cards) {
208 originalCardsById.set(card.id, card);
209 }
210
211 // find all cards that have been updated (where items differ from originalItems)
212 for (let item of currentItems) {
213 const orig = originalCardsById.get(item.id);
214 const originalItem = orig && cardsEqual(orig, item) ? orig : undefined;
215
216 if (!originalItem) {
217 console.log('updated or new item', item);
218 item.updatedAt = new Date().toISOString();
219 // run optional upload function for this card type
220 const cardDef = CardDefinitionsByType[item.cardType];
221
222 if (cardDef?.upload) {
223 item = await cardDef?.upload(item);
224 }
225
226 const parsedItem = JSON.parse(JSON.stringify(item));
227
228 parsedItem.page = data.page;
229 parsedItem.version = 2;
230
231 promises.push(
232 putRecord({
233 collection: 'app.blento.card',
234 rkey: parsedItem.id,
235 record: parsedItem
236 })
237 );
238 }
239 }
240
241 // delete items that are in originalItems but not in items
242 for (const originalItem of data.cards) {
243 const item = currentItems.find((i) => i.id === originalItem.id);
244 if (!item) {
245 console.log('deleting item', originalItem);
246 promises.push(deleteRecord({ collection: 'app.blento.card', rkey: originalItem.id }));
247 }
248 }
249
250 if (
251 data.publication?.preferences?.hideProfile !== undefined &&
252 data.publication?.preferences?.hideProfileSection === undefined
253 ) {
254 data.publication.preferences.hideProfileSection = data.publication?.preferences?.hideProfile;
255 }
256
257 if (!originalPublication || originalPublication !== JSON.stringify(data.publication)) {
258 data.publication ??= {
259 name: getName(data),
260 description: getDescription(data),
261 preferences: {
262 hideProfileSection: getHideProfileSection(data)
263 }
264 };
265
266 if (!data.publication.url) {
267 data.publication.url = 'https://blento.app/' + data.handle;
268
269 if (data.page !== 'blento.self') {
270 data.publication.url += '/' + data.page.replace('blento.', '');
271 }
272 }
273 if (data.page !== 'blento.self') {
274 promises.push(
275 putRecord({
276 collection: 'app.blento.page',
277 rkey: data.page,
278 record: data.publication
279 })
280 );
281 } else {
282 promises.push(
283 putRecord({
284 collection: 'site.standard.publication',
285 rkey: data.page,
286 record: data.publication
287 })
288 );
289 }
290
291 console.log('updating or adding publication', data.publication);
292 }
293
294 await Promise.all(promises);
295}
296
297export function createEmptyCard(page: string) {
298 return {
299 id: TID.now(),
300 x: 0,
301 y: 0,
302 w: 2,
303 h: 2,
304 mobileH: 4,
305 mobileW: 4,
306 mobileX: 0,
307 mobileY: 0,
308 cardType: '',
309 cardData: {},
310 page
311 } as Item;
312}
313
314export function scrollToItem(
315 item: Item,
316 isMobile: boolean,
317 container: HTMLDivElement | undefined,
318 force: boolean = false
319) {
320 // scroll to newly created card only if not fully visible
321 const containerRect = container?.getBoundingClientRect();
322 if (!containerRect) return;
323 const currentMargin = isMobile ? mobileMargin : margin;
324 const currentY = isMobile ? item.mobileY : item.y;
325 const currentH = isMobile ? item.mobileH : item.h;
326 const cellSize = (containerRect.width - currentMargin * 2) / COLUMNS;
327
328 const cardTop = containerRect.top + currentMargin + currentY * cellSize;
329 const cardBottom = containerRect.top + currentMargin + (currentY + currentH) * cellSize;
330
331 const isFullyVisible = cardTop >= 0 && cardBottom <= window.innerHeight;
332
333 if (!isFullyVisible || force) {
334 const bodyRect = document.body.getBoundingClientRect();
335 const offset = containerRect.top - bodyRect.top;
336 window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' });
337 }
338}
339
340export async function checkAndUploadImage(
341 objectWithImage: Record<string, any>,
342 key: string = 'image'
343) {
344 if (!objectWithImage[key]) return;
345
346 // Already uploaded as blob
347 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') {
348 return;
349 }
350
351 if (typeof objectWithImage[key] === 'string') {
352 // Download image from URL via proxy (to avoid CORS) and upload as blob
353 try {
354 const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(objectWithImage[key])}`;
355 const response = await fetch(proxyUrl);
356 if (!response.ok) {
357 console.error('Failed to fetch image:', objectWithImage[key]);
358 return;
359 }
360 const blob = await response.blob();
361 const compressedBlob = await compressImage(blob);
362 objectWithImage[key] = await uploadBlob({ blob: compressedBlob });
363 } catch (error) {
364 console.error('Failed to download and upload image:', error);
365 }
366 return;
367 }
368
369 if (objectWithImage[key]?.blob) {
370 const compressedBlob = await compressImage(objectWithImage[key].blob);
371 objectWithImage[key] = await uploadBlob({ blob: compressedBlob });
372 }
373}
374
375export function getImage(
376 objectWithImage: Record<string, any> | undefined,
377 did: string,
378 key: string = 'image'
379) {
380 if (!objectWithImage?.[key]) return;
381
382 if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl;
383
384 if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') {
385 return getCDNImageBlobUrl({ did, blob: objectWithImage[key] });
386 }
387 return objectWithImage[key];
388}