your personal website on atproto - mirror blento.app

the bad video card

Florian edbf90ae a1761fe1

+286 -1
+2 -1
.claude/settings.local.json
··· 1 1 { 2 2 "permissions": { 3 3 "allow": [ 4 - "Bash(pnpm check:*)" 4 + "Bash(pnpm check:*)", 5 + "mcp__ide__getDiagnostics" 5 6 ] 6 7 } 7 8 }
+94
src/lib/cards/VideoCard/VideoCard.svelte
··· 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
··· 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
··· 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 13 import { UpdatedBlentosCardDefitition } from './SpecialCards/UpdatedBlentos'; 14 14 import { TextCardDefinition } from './TextCard'; 15 15 import type { CardDefinition } from './types'; 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 + 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 + 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 + 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 + } 469 503 </script> 470 504 471 505 <svelte:body ··· 502 536 class="hidden" 503 537 multiple 504 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} 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 + /> 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" 826 891 /> 827 892 </svg> 828 893 </Button>