your personal website on atproto - mirror
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #17 from flo-bit/gifs

Gifs

authored by

Florian and committed by
GitHub
f3084a6f dbe49972

+429 -9
+26
src/lib/cards/GIFCard/CreateGifCardModal.svelte
··· 1 + <script lang="ts"> 2 + import type { CreationModalComponentProps } from '../types'; 3 + import GiphySearchModal from './GiphySearchModal.svelte'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let isOpen = $state(true); 8 + 9 + function handleGifSelect(gif: { 10 + id: string; 11 + title: string; 12 + images: { original: { mp4: string } }; 13 + }) { 14 + item.cardData.url = gif.images.original.mp4; 15 + item.cardData.alt = gif.title; 16 + isOpen = false; 17 + oncreate(); 18 + } 19 + 20 + function handleCancel() { 21 + isOpen = false; 22 + oncancel(); 23 + } 24 + </script> 25 + 26 + <GiphySearchModal bind:open={isOpen} onselect={handleGifSelect} oncancel={handleCancel} />
+75
src/lib/cards/GIFCard/EditingGifCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../types'; 3 + import GiphySearchModal from './GiphySearchModal.svelte'; 4 + 5 + let { item = $bindable() }: ContentComponentProps = $props(); 6 + 7 + let hasError = $state(false); 8 + let isSearchOpen = $state(false); 9 + 10 + function handleGifSelect(gif: { 11 + id: string; 12 + title: string; 13 + images: { original: { mp4: string } }; 14 + }) { 15 + item.cardData.url = gif.images.original.mp4; 16 + item.cardData.alt = gif.title; 17 + hasError = false; 18 + isSearchOpen = false; 19 + } 20 + 21 + function openSearch() { 22 + isSearchOpen = true; 23 + } 24 + </script> 25 + 26 + <!-- svelte-ignore a11y_no_static_element_interactions --> 27 + <!-- svelte-ignore a11y_click_events_have_key_events --> 28 + <div class="group relative h-full w-full cursor-pointer overflow-hidden" onclick={openSearch}> 29 + {#if item.cardData.url && !hasError} 30 + <video 31 + class="absolute inset-0 h-full w-full object-cover" 32 + src={item.cardData.url} 33 + autoplay 34 + loop 35 + muted 36 + playsinline 37 + onerror={() => (hasError = true)} 38 + ></video> 39 + <!-- Click to change overlay --> 40 + <div 41 + class="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100" 42 + > 43 + <span class="text-sm font-medium text-white">Click to change</span> 44 + </div> 45 + {:else} 46 + <!-- Empty state --> 47 + <div 48 + class="bg-base-100 dark:bg-base-900 flex h-full w-full flex-col items-center justify-center gap-3 p-4" 49 + > 50 + <div 51 + class="border-base-300 dark:border-base-700 flex size-12 items-center justify-center rounded-xl border-2 border-dashed" 52 + > 53 + <svg 54 + xmlns="http://www.w3.org/2000/svg" 55 + fill="none" 56 + viewBox="0 0 24 24" 57 + stroke-width="1.5" 58 + stroke="currentColor" 59 + class="text-base-400 dark:text-base-600 size-6" 60 + > 61 + <path 62 + stroke-linecap="round" 63 + stroke-linejoin="round" 64 + 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" 65 + /> 66 + </svg> 67 + </div> 68 + <div class="text-center"> 69 + <p class="text-base-700 dark:text-base-300 text-sm font-medium">Click to search GIPHY</p> 70 + </div> 71 + </div> 72 + {/if} 73 + </div> 74 + 75 + <GiphySearchModal bind:open={isSearchOpen} onselect={handleGifSelect} oncancel={() => (isSearchOpen = false)} />
+43
src/lib/cards/GIFCard/GifCard.svelte
··· 1 + <script lang="ts"> 2 + import type { ContentComponentProps } from '../types'; 3 + 4 + let { item }: ContentComponentProps = $props(); 5 + 6 + let hasError = $state(false); 7 + </script> 8 + 9 + <div class="relative h-full w-full overflow-hidden"> 10 + {#key item.cardData.url} 11 + {#if item.cardData.url && !hasError} 12 + <video 13 + class="absolute inset-0 h-full w-full object-cover" 14 + src={item.cardData.url} 15 + autoplay 16 + loop 17 + muted 18 + playsinline 19 + onerror={() => (hasError = true)} 20 + ></video> 21 + 22 + {:else} 23 + <div 24 + class="flex h-full w-full items-center justify-center bg-base-100 dark:bg-base-900" 25 + > 26 + <svg 27 + xmlns="http://www.w3.org/2000/svg" 28 + fill="none" 29 + viewBox="0 0 24 24" 30 + stroke-width="1.5" 31 + stroke="currentColor" 32 + class="text-base-400 dark:text-base-600 size-12" 33 + > 34 + <path 35 + stroke-linecap="round" 36 + stroke-linejoin="round" 37 + 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" 38 + /> 39 + </svg> 40 + </div> 41 + {/if} 42 + {/key} 43 + </div>
+49
src/lib/cards/GIFCard/GifCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import type { SettingsComponentProps } from '../types'; 4 + import { Button, Label } from '@foxui/core'; 5 + import GiphySearchModal from './GiphySearchModal.svelte'; 6 + 7 + let { item = $bindable<Item>() }: SettingsComponentProps = $props(); 8 + 9 + let isSearchOpen = $state(false); 10 + 11 + function handleGifSelect(gif: { 12 + id: string; 13 + title: string; 14 + images: { original: { mp4: string } }; 15 + }) { 16 + item.cardData.url = gif.images.original.mp4; 17 + item.cardData.alt = gif.title; 18 + isSearchOpen = false; 19 + } 20 + </script> 21 + 22 + <div class="flex flex-col gap-3"> 23 + <div> 24 + <Label class="mb-1 text-xs">Change GIF</Label> 25 + <Button 26 + variant="secondary" 27 + class="w-full justify-start" 28 + onclick={() => (isSearchOpen = true)} 29 + > 30 + <svg 31 + xmlns="http://www.w3.org/2000/svg" 32 + fill="none" 33 + viewBox="0 0 24 24" 34 + stroke-width="1.5" 35 + stroke="currentColor" 36 + class="mr-2 size-4" 37 + > 38 + <path 39 + stroke-linecap="round" 40 + stroke-linejoin="round" 41 + d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" 42 + /> 43 + </svg> 44 + Search GIPHY 45 + </Button> 46 + </div> 47 + </div> 48 + 49 + <GiphySearchModal bind:open={isSearchOpen} onselect={handleGifSelect} oncancel={() => (isSearchOpen = false)} />
+171
src/lib/cards/GIFCard/GiphySearchModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import { env } from '$env/dynamic/public'; 4 + import PoweredByGiphy from './PoweredByGiphy.gif'; 5 + 6 + type GiphyGif = { 7 + id: string; 8 + title: string; 9 + images: { 10 + fixed_height: { url: string; mp4: string; width: string; height: string }; 11 + original: { mp4: string }; 12 + }; 13 + }; 14 + 15 + let { 16 + open = $bindable(false), 17 + onselect, 18 + oncancel 19 + }: { 20 + open: boolean; 21 + onselect: (gif: GiphyGif) => void; 22 + oncancel: () => void; 23 + } = $props(); 24 + 25 + let searchQuery = $state(''); 26 + let searchResults = $state<GiphyGif[]>([]); 27 + let trendingResults = $state<GiphyGif[]>([]); 28 + let isLoading = $state(false); 29 + let searchTimeout: ReturnType<typeof setTimeout> | null = null; 30 + 31 + // Split results into 4 columns for masonry layout 32 + let columns = $derived(() => { 33 + const results = searchQuery.trim() ? searchResults : trendingResults; 34 + const cols: [GiphyGif[], GiphyGif[], GiphyGif[], GiphyGif[]] = [[], [], [], []]; 35 + results.forEach((gif, i) => { 36 + cols[i % 4].push(gif); 37 + }); 38 + return cols; 39 + }); 40 + 41 + let displayResults = $derived(searchQuery.trim() ? searchResults : trendingResults); 42 + 43 + async function fetchTrending() { 44 + if (trendingResults.length > 0) return; 45 + 46 + isLoading = true; 47 + try { 48 + const url = new URL('https://api.giphy.com/v1/gifs/trending'); 49 + url.searchParams.set('api_key', env.PUBLIC_GIPHY_API_TOKEN); 50 + url.searchParams.set('limit', '24'); 51 + url.searchParams.set('rating', 'g'); 52 + 53 + const response = await fetch(url.toString()); 54 + if (response.ok) { 55 + const data = await response.json(); 56 + trendingResults = data.data || []; 57 + } 58 + } catch (error) { 59 + console.error('Failed to fetch trending:', error); 60 + } finally { 61 + isLoading = false; 62 + } 63 + } 64 + 65 + async function searchGiphy(query: string) { 66 + if (!query.trim()) { 67 + searchResults = []; 68 + return; 69 + } 70 + 71 + isLoading = true; 72 + try { 73 + const url = new URL('https://api.giphy.com/v1/gifs/search'); 74 + url.searchParams.set('api_key', env.PUBLIC_GIPHY_API_TOKEN); 75 + url.searchParams.set('q', query); 76 + url.searchParams.set('limit', '24'); 77 + url.searchParams.set('rating', 'g'); 78 + url.searchParams.set('lang', 'en'); 79 + 80 + const response = await fetch(url.toString()); 81 + if (response.ok) { 82 + const data = await response.json(); 83 + searchResults = data.data || []; 84 + } 85 + } catch (error) { 86 + console.error('Failed to search Giphy:', error); 87 + } finally { 88 + isLoading = false; 89 + } 90 + } 91 + 92 + function handleSearchInput() { 93 + if (searchTimeout) clearTimeout(searchTimeout); 94 + searchTimeout = setTimeout(() => { 95 + searchGiphy(searchQuery); 96 + }, 300); 97 + } 98 + 99 + function selectGif(gif: GiphyGif) { 100 + onselect(gif); 101 + resetState(); 102 + } 103 + 104 + function handleCancel() { 105 + oncancel(); 106 + resetState(); 107 + } 108 + 109 + function resetState() { 110 + searchQuery = ''; 111 + searchResults = []; 112 + } 113 + 114 + $effect(() => { 115 + if (open) { 116 + fetchTrending(); 117 + } 118 + }); 119 + </script> 120 + 121 + <Modal bind:open onOpenChange={(isOpen) => !isOpen && handleCancel()} closeButton={true} class="flex h-[80dvh] max-w-4xl flex-col"> 122 + <Subheading>{searchQuery.trim() ? 'Search GIPHY' : 'Trending GIFs'}</Subheading> 123 + 124 + <Input 125 + bind:value={searchQuery} 126 + oninput={handleSearchInput} 127 + placeholder="Search for GIFs..." 128 + class="w-full" 129 + /> 130 + 131 + <div class="mt-4 flex-1 overflow-y-auto"> 132 + {#if isLoading && displayResults.length === 0} 133 + <div class="flex h-[300px] items-center justify-center"> 134 + <p class="text-base-500">Loading...</p> 135 + </div> 136 + {:else if displayResults.length > 0} 137 + <div class="flex items-start gap-3"> 138 + {#each columns() as column} 139 + <div class="flex w-1/4 flex-col gap-3"> 140 + {#each column as gif} 141 + <button 142 + onclick={() => selectGif(gif)} 143 + aria-label={gif.title} 144 + class="block shrink-0 overflow-hidden rounded-xl transition-transform hover:scale-[1.02] focus:ring-2 focus:ring-accent-500 focus:outline-none" 145 + > 146 + <video 147 + src={gif.images.fixed_height.mp4} 148 + autoplay 149 + loop 150 + muted 151 + playsinline 152 + class="block w-full bg-base-200 dark:bg-base-800" 153 + style="aspect-ratio: {gif.images.fixed_height.width} / {gif.images.fixed_height.height}" 154 + ></video> 155 + </button> 156 + {/each} 157 + </div> 158 + {/each} 159 + </div> 160 + {:else if searchQuery} 161 + <div class="flex h-[300px] items-center justify-center"> 162 + <p class="text-base-500">No results found</p> 163 + </div> 164 + {/if} 165 + </div> 166 + 167 + <div class="mt-4 flex items-center justify-between"> 168 + <img src={PoweredByGiphy} alt="Powered by GIPHY" class="h-7 rounded-md" /> 169 + <Button onclick={handleCancel} variant="ghost">Cancel</Button> 170 + </div> 171 + </Modal>
src/lib/cards/GIFCard/PoweredByGiphy.gif

This is a binary file and will not be displayed.

+48
src/lib/cards/GIFCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CreateGifCardModal from './CreateGifCardModal.svelte'; 3 + import EditingGifCard from './EditingGifCard.svelte'; 4 + import GifCard from './GifCard.svelte'; 5 + import GifCardSettings from './GifCardSettings.svelte'; 6 + 7 + export const GifCardDefinition = { 8 + type: 'gif', 9 + contentComponent: GifCard, 10 + editingContentComponent: EditingGifCard, 11 + creationModalComponent: CreateGifCardModal, 12 + createNew: (card) => { 13 + card.cardType = 'gif'; 14 + card.cardData = { 15 + url: '', 16 + alt: '' 17 + }; 18 + card.w = 2; 19 + card.h = 2; 20 + card.mobileW = 4; 21 + card.mobileH = 4; 22 + }, 23 + settingsComponent: GifCardSettings, 24 + sidebarButtonText: 'GIF', 25 + defaultColor: 'transparent', 26 + allowSetColor: false, 27 + minW: 1, 28 + minH: 1, 29 + onUrlHandler: (url, item) => { 30 + // Match Giphy page URLs: https://giphy.com/gifs/name-ID or https://giphy.com/gifs/ID 31 + const pageMatch = url.match(/giphy\.com\/gifs\/(?:.*-)?([a-zA-Z0-9]+)(?:\?|$)/); 32 + if (pageMatch) { 33 + item.cardData.url = `https://media.giphy.com/media/${pageMatch[1]}/giphy.mp4`; 34 + return item; 35 + } 36 + 37 + // Match Giphy media URLs: https://media.giphy.com/media/ID/giphy.gif or .mp4 38 + const mediaMatch = url.match(/media\.giphy\.com\/media\/([a-zA-Z0-9]+)\//); 39 + if (mediaMatch) { 40 + item.cardData.url = `https://media.giphy.com/media/${mediaMatch[1]}/giphy.mp4`; 41 + return item; 42 + } 43 + 44 + return null; 45 + }, 46 + urlHandlerPriority: 5, 47 + name: 'GIF' 48 + } as CardDefinition & { type: 'gif' };
+6 -4
src/lib/cards/LivestreamCard/index.ts
··· 32 32 | undefined; 33 33 const values = Object.values(records); 34 34 if (values?.length > 0) { 35 - const latest = JSON.parse(JSON.stringify(values[0])); 35 + const latest = JSON.parse(JSON.stringify(values?.[0])); 36 36 37 37 latestLivestream = { 38 38 createdAt: latest.value.createdAt, 39 - title: latest.value.title as string, 40 - thumb: getImageBlobUrl({ link: latest.value.thumb?.ref.$link, did }), 41 - href: latest.value.canonicalUrl || latest.value.url, 39 + title: latest.value?.title as string, 40 + thumb: latest.value?.thumb?.ref?.$link 41 + ? getImageBlobUrl({ link: latest.value.thumb.ref.$link, did }) 42 + : undefined, 43 + href: latest.value?.canonicalUrl || latest.value.url, 42 44 online: undefined 43 45 }; 44 46 }
+2
src/lib/cards/index.ts
··· 19 19 import { BlueskyProfileCardDefinition } from './BlueskyProfileCard'; 20 20 import { GithubProfileCardDefitition } from './GitHubProfileCard'; 21 21 import { FluidTextCardDefinition } from './FluidTextCard'; 22 + import { GifCardDefinition } from './GIFCard'; 22 23 import { PopfeedReviewsCardDefinition } from './PopfeedReviews'; 23 24 import { TealFMPlaysCardDefinition } from './TealFMPlaysCard'; 24 25 import { PhotoGalleryCardDefinition } from './PhotoGalleryCard'; ··· 46 47 GithubProfileCardDefitition, 47 48 TetrisCardDefinition, 48 49 FluidTextCardDefinition, 50 + GifCardDefinition, 49 51 PopfeedReviewsCardDefinition, 50 52 TealFMPlaysCardDefinition, 51 53 PhotoGalleryCardDefinition,
+7 -4
src/lib/website/EditableWebsite.svelte
··· 324 324 } 325 325 326 326 async function processImageFile(file: File, gridX?: number, gridY?: number) { 327 - const compressedFile = await compressImage(file); 328 - const objectUrl = URL.createObjectURL(compressedFile); 327 + const isGif = file.type === 'image/gif'; 328 + 329 + // Don't compress GIFs to preserve animation 330 + const processedFile = isGif ? file : await compressImage(file); 331 + const objectUrl = URL.createObjectURL(processedFile); 329 332 330 333 let item = createEmptyCard(data.page); 331 334 332 - item.cardType = 'image'; 335 + item.cardType = isGif ? 'gif' : 'image'; 333 336 item.cardData = { 334 - blob: compressedFile, 337 + blob: processedFile, 335 338 objectUrl 336 339 }; 337 340
+2 -1
wrangler.jsonc
··· 37 37 "vars": { 38 38 "PUBLIC_HANDLE": "blento.app", 39 39 "PUBLIC_IS_SELFHOSTED": "", 40 - "PUBLIC_DOMAIN": "https://blento.app" 40 + "PUBLIC_DOMAIN": "https://blento.app", 41 + "PUBLIC_GIPHY_API_TOKEN": "ltXijv1bkNPrEgnpJ0tIdLWXjnAeE7bL" 41 42 }, 42 43 "kv_namespaces": [ 43 44 {