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