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
the bad video card
Florian
3 weeks ago
edbf90ae
a1761fe1
+286
-1
6 changed files
expand all
collapse all
unified
split
.claude
settings.local.json
src
lib
cards
VideoCard
VideoCard.svelte
VideoCardSettings.svelte
index.ts
index.ts
website
EditableWebsite.svelte
+2
-1
.claude/settings.local.json
···
1
1
{
2
2
"permissions": {
3
3
"allow": [
4
4
-
"Bash(pnpm check:*)"
4
4
+
"Bash(pnpm check:*)",
5
5
+
"mcp__ide__getDiagnostics"
5
6
]
6
7
}
7
8
}
+94
src/lib/cards/VideoCard/VideoCard.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { getDidContext } from '$lib/website/context';
3
3
+
import { getBlob } from '$lib/oauth/atproto';
4
4
+
import { onMount } from 'svelte';
5
5
+
import type { ContentComponentProps } from '../types';
6
6
+
7
7
+
let { item = $bindable() }: ContentComponentProps = $props();
8
8
+
9
9
+
const did = getDidContext();
10
10
+
11
11
+
let element: HTMLVideoElement | undefined = $state();
12
12
+
13
13
+
onMount(async () => {
14
14
+
const el = element;
15
15
+
if (!el) return;
16
16
+
17
17
+
el.muted = true;
18
18
+
19
19
+
// If we already have an objectUrl (preview before upload), use it directly
20
20
+
if (item.cardData.objectUrl) {
21
21
+
el.src = item.cardData.objectUrl;
22
22
+
el.play().catch((e) => {
23
23
+
console.error('Video play error:', e);
24
24
+
});
25
25
+
return;
26
26
+
}
27
27
+
28
28
+
// Fetch the video blob from the PDS
29
29
+
if (item.cardData.video?.video && typeof item.cardData.video.video === 'object') {
30
30
+
const cid = item.cardData.video.video?.ref?.$link;
31
31
+
if (!cid) return;
32
32
+
33
33
+
try {
34
34
+
const blobUrl = await getBlob({ did, cid });
35
35
+
const res = await fetch(blobUrl);
36
36
+
if (!res.ok) throw new Error(res.statusText);
37
37
+
const blob = await res.blob();
38
38
+
const url = URL.createObjectURL(blob);
39
39
+
el.src = url;
40
40
+
el.play().catch((e) => {
41
41
+
console.error('Video play error:', e);
42
42
+
});
43
43
+
} catch (e) {
44
44
+
console.error('Failed to load video:', e);
45
45
+
}
46
46
+
}
47
47
+
});
48
48
+
</script>
49
49
+
50
50
+
{#key item.cardData.video || item.cardData.objectUrl}
51
51
+
<!-- svelte-ignore a11y_media_has_caption -->
52
52
+
<video
53
53
+
bind:this={element}
54
54
+
muted
55
55
+
loop
56
56
+
autoplay
57
57
+
playsinline
58
58
+
class={[
59
59
+
'absolute inset-0 h-full w-full object-cover opacity-100 transition-transform duration-300 ease-in-out',
60
60
+
item.cardData.href ? 'group-hover:scale-102' : ''
61
61
+
]}
62
62
+
></video>
63
63
+
{/key}
64
64
+
{#if item.cardData.href}
65
65
+
<a
66
66
+
href={item.cardData.href}
67
67
+
class="absolute inset-0 h-full w-full"
68
68
+
target="_blank"
69
69
+
rel="noopener noreferrer"
70
70
+
>
71
71
+
<span class="sr-only">
72
72
+
{item.cardData.hrefText ?? 'Learn more'}
73
73
+
</span>
74
74
+
75
75
+
<div
76
76
+
class="bg-base-800/30 border-base-900/30 absolute top-2 right-2 rounded-full border p-1 text-white opacity-50 backdrop-blur-lg group-focus-within:opacity-100 group-hover:opacity-100"
77
77
+
>
78
78
+
<svg
79
79
+
xmlns="http://www.w3.org/2000/svg"
80
80
+
fill="none"
81
81
+
viewBox="0 0 24 24"
82
82
+
stroke-width="2.5"
83
83
+
stroke="currentColor"
84
84
+
class="size-4"
85
85
+
>
86
86
+
<path
87
87
+
stroke-linecap="round"
88
88
+
stroke-linejoin="round"
89
89
+
d="m4.5 19.5 15-15m0 0H8.25m11.25 0v11.25"
90
90
+
/>
91
91
+
</svg>
92
92
+
</div>
93
93
+
</a>
94
94
+
{/if}
+54
src/lib/cards/VideoCard/VideoCardSettings.svelte
···
1
1
+
<script lang="ts">
2
2
+
import { validateLink } from '$lib/helper';
3
3
+
import type { Item } from '$lib/types';
4
4
+
import { Button, Input, toast } from '@foxui/core';
5
5
+
6
6
+
let { item, onclose }: { item: Item; onclose: () => void } = $props();
7
7
+
8
8
+
let linkValue = $derived(
9
9
+
item.cardData.href?.replace('https://', '').replace('http://', '') ?? ''
10
10
+
);
11
11
+
12
12
+
function updateLink() {
13
13
+
if (!linkValue.trim()) {
14
14
+
item.cardData.href = '';
15
15
+
item.cardData.domain = '';
16
16
+
}
17
17
+
18
18
+
let link = validateLink(linkValue);
19
19
+
if (!link) {
20
20
+
toast.error('Invalid link');
21
21
+
return;
22
22
+
}
23
23
+
24
24
+
item.cardData.href = link;
25
25
+
item.cardData.domain = new URL(link).hostname;
26
26
+
27
27
+
onclose?.();
28
28
+
}
29
29
+
</script>
30
30
+
31
31
+
<Input
32
32
+
spellcheck={false}
33
33
+
type="url"
34
34
+
bind:value={linkValue}
35
35
+
onkeydown={(event) => {
36
36
+
if (event.code === 'Enter') {
37
37
+
updateLink();
38
38
+
event.preventDefault();
39
39
+
}
40
40
+
}}
41
41
+
placeholder="Enter link"
42
42
+
/>
43
43
+
<Button onclick={updateLink} size="icon"
44
44
+
><svg
45
45
+
xmlns="http://www.w3.org/2000/svg"
46
46
+
fill="none"
47
47
+
viewBox="0 0 24 24"
48
48
+
stroke-width="1.5"
49
49
+
stroke="currentColor"
50
50
+
class="size-6"
51
51
+
>
52
52
+
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
53
53
+
</svg>
54
54
+
</Button>
+69
src/lib/cards/VideoCard/index.ts
···
1
1
+
import { uploadBlob } from '$lib/oauth/utils';
2
2
+
import type { CardDefinition } from '../types';
3
3
+
import VideoCard from './VideoCard.svelte';
4
4
+
import VideoCardSettings from './VideoCardSettings.svelte';
5
5
+
6
6
+
async function getAspectRatio(videoBlob: Blob): Promise<{ width: number; height: number }> {
7
7
+
return new Promise((resolve, reject) => {
8
8
+
const video = document.createElement('video');
9
9
+
video.preload = 'metadata';
10
10
+
11
11
+
video.onloadedmetadata = () => {
12
12
+
URL.revokeObjectURL(video.src);
13
13
+
resolve({
14
14
+
width: video.videoWidth,
15
15
+
height: video.videoHeight
16
16
+
});
17
17
+
};
18
18
+
19
19
+
video.onerror = () => {
20
20
+
URL.revokeObjectURL(video.src);
21
21
+
reject(new Error('Failed to load video metadata'));
22
22
+
};
23
23
+
24
24
+
video.src = URL.createObjectURL(videoBlob);
25
25
+
});
26
26
+
}
27
27
+
28
28
+
export const VideoCardDefinition = {
29
29
+
type: 'video',
30
30
+
contentComponent: VideoCard,
31
31
+
createNew: (card) => {
32
32
+
card.cardType = 'video';
33
33
+
card.cardData = {
34
34
+
video: null,
35
35
+
href: ''
36
36
+
};
37
37
+
},
38
38
+
upload: async (item) => {
39
39
+
if (item.cardData.blob) {
40
40
+
const blob = item.cardData.blob;
41
41
+
const aspectRatio = await getAspectRatio(blob);
42
42
+
const uploadedBlob = await uploadBlob(blob);
43
43
+
44
44
+
item.cardData.video = {
45
45
+
$type: 'app.bsky.embed.video',
46
46
+
video: uploadedBlob,
47
47
+
aspectRatio
48
48
+
};
49
49
+
50
50
+
delete item.cardData.blob;
51
51
+
}
52
52
+
53
53
+
if (item.cardData.objectUrl) {
54
54
+
URL.revokeObjectURL(item.cardData.objectUrl);
55
55
+
delete item.cardData.objectUrl;
56
56
+
}
57
57
+
58
58
+
return item;
59
59
+
},
60
60
+
settingsComponent: VideoCardSettings,
61
61
+
62
62
+
canChange: (item) => Boolean(item.cardData.video),
63
63
+
64
64
+
change: (item) => {
65
65
+
return item;
66
66
+
},
67
67
+
name: 'Video Card',
68
68
+
sidebarButtonText: 'Video'
69
69
+
} as CardDefinition & { type: 'video' };
+2
src/lib/cards/index.ts
···
13
13
import { UpdatedBlentosCardDefitition } from './SpecialCards/UpdatedBlentos';
14
14
import { TextCardDefinition } from './TextCard';
15
15
import type { CardDefinition } from './types';
16
16
+
import { VideoCardDefinition } from './VideoCard';
16
17
import { YoutubeCardDefinition } from './YoutubeVideo';
17
18
import { BlueskyProfileCardDefinition } from './BlueskyProfileCard';
18
19
19
20
export const AllCardDefinitions = [
20
21
ImageCardDefinition,
22
22
+
VideoCardDefinition,
21
23
TextCardDefinition,
22
24
LinkCardDefinition,
23
25
BigSocialCardDefinition,
+65
src/lib/website/EditableWebsite.svelte
···
40
40
} = $props();
41
41
42
42
let imageInputRef: HTMLInputElement | undefined = $state();
43
43
+
let videoInputRef: HTMLInputElement | undefined = $state();
43
44
let imageDragOver = $state(false);
44
45
let imageDragPosition: { x: number; y: number } | null = $state(null);
45
46
···
466
467
// Reset the input so the same file can be selected again
467
468
target.value = '';
468
469
}
470
470
+
471
471
+
async function processVideoFile(file: File) {
472
472
+
const objectUrl = URL.createObjectURL(file);
473
473
+
474
474
+
let item = createEmptyCard(data.page);
475
475
+
476
476
+
item.cardType = 'video';
477
477
+
item.cardData = {
478
478
+
blob: file,
479
479
+
objectUrl
480
480
+
};
481
481
+
482
482
+
setPositionOfNewItem(item, items);
483
483
+
items = [...items, item];
484
484
+
485
485
+
await tick();
486
486
+
487
487
+
scrollToItem(item, isMobile, container);
488
488
+
}
489
489
+
490
490
+
async function handleVideoInputChange(event: Event) {
491
491
+
const target = event.target as HTMLInputElement;
492
492
+
if (!target.files || target.files.length < 1) return;
493
493
+
494
494
+
const files = Array.from(target.files);
495
495
+
496
496
+
for (const file of files) {
497
497
+
await processVideoFile(file);
498
498
+
}
499
499
+
500
500
+
// Reset the input so the same file can be selected again
501
501
+
target.value = '';
502
502
+
}
469
503
</script>
470
504
471
505
<svelte:body
···
502
536
class="hidden"
503
537
multiple
504
538
bind:this={imageInputRef}
539
539
+
/>
540
540
+
<input
541
541
+
type="file"
542
542
+
accept="video/*"
543
543
+
onchange={handleVideoInputChange}
544
544
+
class="hidden"
545
545
+
multiple
546
546
+
bind:this={videoInputRef}
505
547
/>
506
548
507
549
{#if !dev}
···
823
865
stroke-linecap="round"
824
866
stroke-linejoin="round"
825
867
d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"
868
868
+
/>
869
869
+
</svg>
870
870
+
</Button>
871
871
+
872
872
+
<Button
873
873
+
size="iconLg"
874
874
+
variant="ghost"
875
875
+
class="backdrop-blur-none"
876
876
+
onclick={() => {
877
877
+
videoInputRef?.click();
878
878
+
}}
879
879
+
>
880
880
+
<svg
881
881
+
xmlns="http://www.w3.org/2000/svg"
882
882
+
fill="none"
883
883
+
viewBox="0 0 24 24"
884
884
+
stroke-width="1.5"
885
885
+
stroke="currentColor"
886
886
+
>
887
887
+
<path
888
888
+
stroke-linecap="round"
889
889
+
stroke-linejoin="round"
890
890
+
d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z"
826
891
/>
827
892
</svg>
828
893
</Button>