tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
make image handling better
Florian
2 weeks ago
686ce8c0
f2dde826
+152
-45
9 changed files
expand all
collapse all
unified
split
src
lib
atproto
methods.ts
cards
ImageCard
ImageCard.svelte
index.ts
LinkCard
EditingLinkCard.svelte
LinkCard.svelte
index.ts
helper.ts
website
EditableWebsite.svelte
routes
api
image-proxy
+server.ts
+1
-1
src/lib/atproto/methods.ts
···
297
297
};
298
298
}) {
299
299
if (!did || !blob?.ref?.$link) return '';
300
300
-
return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@jpeg`;
300
300
+
return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
301
301
}
302
302
303
303
export async function searchActorsTypeahead(
+3
-12
src/lib/cards/ImageCard/ImageCard.svelte
···
1
1
<script lang="ts">
2
2
import { getDidContext } from '$lib/website/context';
3
3
-
import { getImageBlobUrl } from '$lib/atproto';
4
3
import type { ContentComponentProps } from '../types';
4
4
+
import { getImage } from '$lib/helper';
5
5
6
6
let { item = $bindable(), isEditing }: ContentComponentProps = $props();
7
7
8
8
const did = getDidContext();
9
9
-
10
10
-
function getSrc() {
11
11
-
if (item.cardData.objectUrl) return item.cardData.objectUrl;
12
12
-
13
13
-
if (item.cardData.image && typeof item.cardData.image === 'object') {
14
14
-
return getImageBlobUrl({ did, blob: item.cardData.image });
15
15
-
}
16
16
-
return item.cardData.image;
17
17
-
}
18
9
</script>
19
10
20
20
-
{#key item.cardData.image || item.cardData.objectUrl}
11
11
+
{#key getImage(item.cardData, did, 'image')}
21
12
<img
22
13
class={[
23
14
'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out',
24
15
item.cardData.href ? 'group-hover/card:scale-101' : ''
25
16
]}
26
26
-
src={getSrc()}
17
17
+
src={getImage(item.cardData, did, 'image')}
27
18
alt=""
28
19
/>
29
20
{/key}
+19
-13
src/lib/cards/ImageCard/index.ts
···
1
1
-
import { uploadBlob } from '$lib/atproto';
1
1
+
import { checkAndUploadImage } from '$lib/helper';
2
2
import type { CardDefinition } from '../types';
3
3
import ImageCard from './ImageCard.svelte';
4
4
import ImageCardSettings from './ImageCardSettings.svelte';
5
5
+
6
6
+
// Common image extensions
7
7
+
const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|svg|bmp|ico|avif|tiff?)(\?.*)?$/i;
5
8
6
9
export const ImageCardDefinition = {
7
10
type: 'image',
···
15
18
};
16
19
},
17
20
upload: async (item) => {
18
18
-
if (item.cardData.blob) {
19
19
-
item.cardData.image = await uploadBlob({ blob: item.cardData.blob });
20
20
-
21
21
-
delete item.cardData.blob;
22
22
-
}
23
23
-
24
24
-
if (item.cardData.objectUrl) {
25
25
-
URL.revokeObjectURL(item.cardData.objectUrl);
26
26
-
27
27
-
delete item.cardData.objectUrl;
28
28
-
}
29
29
-
21
21
+
await checkAndUploadImage(item.cardData, 'image');
30
22
return item;
31
23
},
32
24
settingsComponent: ImageCardSettings,
···
36
28
change: (item) => {
37
29
return item;
38
30
},
31
31
+
32
32
+
onUrlHandler: (url, item) => {
33
33
+
// Check if URL points to an image
34
34
+
if (IMAGE_EXTENSIONS.test(url)) {
35
35
+
item.cardType = 'image';
36
36
+
item.cardData.image = url;
37
37
+
item.cardData.alt = '';
38
38
+
item.cardData.href = '';
39
39
+
return item;
40
40
+
}
41
41
+
return null;
42
42
+
},
43
43
+
urlHandlerPriority: 3,
44
44
+
39
45
name: 'Image Card',
40
46
41
47
canHaveLabel: true
+10
-3
src/lib/cards/LinkCard/EditingLinkCard.svelte
···
1
1
<script lang="ts">
2
2
import { browser } from '$app/environment';
3
3
-
import { getIsMobile } from '$lib/website/context';
3
3
+
import { getImage } from '$lib/helper';
4
4
+
import { getDidContext, getIsMobile } from '$lib/website/context';
4
5
import type { ContentComponentProps } from '../types';
5
6
import PlainTextEditor from '../utils/PlainTextEditor.svelte';
6
7
···
50
51
isFetchingMetadata = false;
51
52
});
52
53
});
54
54
+
55
55
+
let did = getDidContext();
53
56
</script>
54
57
55
58
<div class="relative flex h-full flex-col justify-between p-4">
···
68
71
<img
69
72
class="size-6 rounded-lg object-cover"
70
73
onerror={() => (faviconHasError = true)}
71
71
-
src={item.cardData.favicon}
74
74
+
src={getImage(item.cardData, did, 'favicon')}
72
75
alt=""
73
76
/>
74
77
{:else}
···
119
122
</div>
120
123
121
124
{#if hasFetched && browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image}
122
122
-
<img class=" mb-2 max-h-32 w-full rounded-xl object-cover" src={item.cardData.image} alt="" />
125
125
+
<img
126
126
+
class="mb-2 aspect-2/1 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0"
127
127
+
src={getImage(item.cardData, did)}
128
128
+
alt=""
129
129
+
/>
123
130
{/if}
124
131
</div>
+6
-3
src/lib/cards/LinkCard/LinkCard.svelte
···
1
1
<script lang="ts">
2
2
import { browser } from '$app/environment';
3
3
-
import { getIsMobile } from '$lib/website/context';
3
3
+
import { getImage } from '$lib/helper';
4
4
+
import { getDidContext, getIsMobile } from '$lib/website/context';
4
5
import type { ContentComponentProps } from '../types';
5
6
6
7
let { item }: ContentComponentProps = $props();
···
8
9
let isMobile = getIsMobile();
9
10
10
11
let faviconHasError = $state(false);
12
12
+
13
13
+
let did = getDidContext();
11
14
</script>
12
15
13
16
<div class="flex h-full flex-col justify-between p-4">
···
57
60
58
61
{#if browser && ((isMobile() && item.mobileH >= 8) || (!isMobile() && item.h >= 4)) && item.cardData.image}
59
62
<img
60
60
-
class="mb-2 max-h-32 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0"
61
61
-
src={item.cardData.image}
63
63
+
class="mb-2 aspect-2/1 w-full rounded-xl object-cover opacity-100 transition-opacity duration-100 starting:opacity-0"
64
64
+
src={getImage(item.cardData, did)}
62
65
alt=""
63
66
/>
64
67
{/if}
+6
-1
src/lib/cards/LinkCard/index.ts
···
1
1
-
import { validateLink } from '$lib/helper';
1
1
+
import { checkAndUploadImage, validateLink } from '$lib/helper';
2
2
import type { CardDefinition } from '../types';
3
3
import EditingLinkCard from './EditingLinkCard.svelte';
4
4
import LinkCard from './LinkCard.svelte';
···
29
29
item.cardData.href = url;
30
30
item.cardData.domain = new URL(url).hostname;
31
31
item.cardData.hasFetched = false;
32
32
+
return item;
33
33
+
},
34
34
+
upload: async (item) => {
35
35
+
await checkAndUploadImage(item.cardData, 'image');
36
36
+
await checkAndUploadImage(item.cardData, 'favicon');
32
37
return item;
33
38
},
34
39
urlHandlerPriority: 0
+60
-10
src/lib/helper.ts
···
1
1
import type { Item, WebsiteData } from './types';
2
2
import { COLUMNS, margin, mobileMargin } from '$lib';
3
3
import { CardDefinitionsByType } from './cards';
4
4
-
import { deleteRecord, putRecord } from '$lib/atproto';
4
4
+
import { deleteRecord, getImageBlobUrl, putRecord, uploadBlob } from '$lib/atproto';
5
5
import { toast } from '@foxui/core';
6
6
import * as TID from '@atcute/tid';
7
7
···
337
337
}
338
338
}
339
339
340
340
-
export function compressImage(file: File, maxSize: number = 900 * 1024): Promise<Blob> {
340
340
+
export function compressImage(file: File | Blob, maxSize: number = 900 * 1024): Promise<Blob> {
341
341
return new Promise((resolve, reject) => {
342
342
const img = new Image();
343
343
const reader = new FileReader();
···
353
353
reader.readAsDataURL(file);
354
354
355
355
img.onload = () => {
356
356
+
const maxDimension = 2048;
357
357
+
358
358
+
// If image is already small enough, return original
359
359
+
if (file.size <= maxSize) {
360
360
+
console.log('skipping compression+resizing, already small enough');
361
361
+
return resolve(file);
362
362
+
}
363
363
+
356
364
let width = img.width;
357
365
let height = img.height;
358
358
-
const maxDimension = 2048;
359
366
360
367
if (width > maxDimension || height > maxDimension) {
361
368
if (width > height) {
···
375
382
if (!ctx) return reject(new Error('Failed to get canvas context.'));
376
383
ctx.drawImage(img, 0, 0, width, height);
377
384
378
378
-
// Function to try compressing at a given quality
379
379
-
let quality = 0.8;
385
385
+
// Use WebP for both compression and transparency support
386
386
+
let quality = 0.9;
387
387
+
380
388
function attemptCompression() {
381
389
canvas.toBlob(
382
390
(blob) => {
383
391
if (!blob) {
384
392
return reject(new Error('Compression failed.'));
385
393
}
386
386
-
// If the blob is under our size limit, or quality is too low, resolve it
387
394
if (blob.size <= maxSize || quality < 0.3) {
388
388
-
console.log('Compression successful. Blob size:', blob.size);
389
389
-
console.log('Quality:', quality);
390
395
resolve(blob);
391
396
} else {
392
392
-
// Otherwise, reduce the quality and try again
393
397
quality -= 0.1;
394
398
attemptCompression();
395
399
}
396
400
},
397
397
-
'image/jpeg',
401
401
+
'image/webp',
398
402
quality
399
403
);
400
404
}
···
536
540
window.scrollTo({ top: offset + cellSize * (currentY - 1), behavior: 'smooth' });
537
541
}
538
542
}
543
543
+
544
544
+
export async function checkAndUploadImage(
545
545
+
objectWithImage: Record<string, any>,
546
546
+
key: string = 'image'
547
547
+
) {
548
548
+
if (!objectWithImage[key]) return;
549
549
+
550
550
+
// Already uploaded as blob
551
551
+
if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') {
552
552
+
return;
553
553
+
}
554
554
+
555
555
+
if (typeof objectWithImage[key] === 'string') {
556
556
+
// Download image from URL via proxy (to avoid CORS) and upload as blob
557
557
+
try {
558
558
+
const proxyUrl = `/api/image-proxy?url=${encodeURIComponent(objectWithImage[key])}`;
559
559
+
const response = await fetch(proxyUrl);
560
560
+
if (!response.ok) {
561
561
+
console.error('Failed to fetch image:', objectWithImage[key]);
562
562
+
return;
563
563
+
}
564
564
+
const blob = await response.blob();
565
565
+
const compressedBlob = await compressImage(blob);
566
566
+
objectWithImage[key] = await uploadBlob({ blob: compressedBlob });
567
567
+
} catch (error) {
568
568
+
console.error('Failed to download and upload image:', error);
569
569
+
}
570
570
+
return;
571
571
+
}
572
572
+
573
573
+
if (objectWithImage[key]?.blob) {
574
574
+
const compressedBlob = await compressImage(objectWithImage[key].blob);
575
575
+
objectWithImage[key] = await uploadBlob({ blob: compressedBlob });
576
576
+
}
577
577
+
}
578
578
+
579
579
+
export function getImage(objectWithImage: Record<string, any>, did: string, key: string = 'image') {
580
580
+
if (!objectWithImage[key]) return;
581
581
+
582
582
+
if (objectWithImage[key].objectUrl) return objectWithImage[key].objectUrl;
583
583
+
584
584
+
if (typeof objectWithImage[key] === 'object' && objectWithImage[key].$type === 'blob') {
585
585
+
return getImageBlobUrl({ did, blob: objectWithImage[key] });
586
586
+
}
587
587
+
return objectWithImage[key];
588
588
+
}
+3
-2
src/lib/website/EditableWebsite.svelte
···
320
320
321
321
item.cardType = isGif ? 'gif' : 'image';
322
322
item.cardData = {
323
323
-
blob: processedFile,
324
324
-
objectUrl
323
323
+
image: { blob: processedFile, objectUrl }
325
324
};
326
325
327
326
// If grid position is provided
···
492
491
// Reset the input so the same file can be selected again
493
492
target.value = '';
494
493
}
494
494
+
495
495
+
$inspect(items);
495
496
</script>
496
497
497
498
<svelte:body
+44
src/routes/api/image-proxy/+server.ts
···
1
1
+
import { error } from '@sveltejs/kit';
2
2
+
3
3
+
export async function GET({ url }) {
4
4
+
const imageUrl = url.searchParams.get('url');
5
5
+
if (!imageUrl) {
6
6
+
throw error(400, 'No URL provided');
7
7
+
}
8
8
+
9
9
+
try {
10
10
+
new URL(imageUrl);
11
11
+
} catch {
12
12
+
throw error(400, 'Invalid URL');
13
13
+
}
14
14
+
15
15
+
try {
16
16
+
const response = await fetch(imageUrl);
17
17
+
18
18
+
if (!response.ok) {
19
19
+
throw error(response.status, 'Failed to fetch image');
20
20
+
}
21
21
+
22
22
+
const contentType = response.headers.get('content-type');
23
23
+
24
24
+
// Only allow image content types
25
25
+
if (!contentType?.startsWith('image/')) {
26
26
+
throw error(400, 'URL does not point to an image');
27
27
+
}
28
28
+
29
29
+
const blob = await response.blob();
30
30
+
31
31
+
return new Response(blob, {
32
32
+
headers: {
33
33
+
'Content-Type': contentType,
34
34
+
'Cache-Control': 'public, max-age=86400'
35
35
+
}
36
36
+
});
37
37
+
} catch (err) {
38
38
+
if (err && typeof err === 'object' && 'status' in err) {
39
39
+
throw err;
40
40
+
}
41
41
+
console.error('Error proxying image:', err);
42
42
+
throw error(500, 'Failed to proxy image');
43
43
+
}
44
44
+
}