your personal website on atproto - mirror blento.app

part 1

Florian 9a75add1 05c4a0e5

+258 -151
+118 -113
src/lib/cards/GIFCard/EditingGifCard.svelte
··· 7 7 8 8 const did = getDidContext(); 9 9 10 - let isDragging = $state(false); 11 - let urlInput = $state(item.cardData.url || ''); 12 10 let hasError = $state(false); 13 - let isEditing = $state(false); 14 - let inputElement: HTMLInputElement | null = $state(null); 11 + let isSearchOpen = $state(false); 12 + let searchQuery = $state(''); 13 + let searchResults = $state< 14 + Array<{ 15 + id: string; 16 + title: string; 17 + images: { 18 + fixed_height: { url: string; width: string; height: string }; 19 + original: { url: string }; 20 + }; 21 + }> 22 + >([]); 23 + let isLoading = $state(false); 24 + let searchTimeout: ReturnType<typeof setTimeout> | null = null; 15 25 16 26 function getSrc() { 17 - if (item.cardData.objectUrl) return item.cardData.objectUrl; 27 + if (item.cardData.url) return item.cardData.url; 18 28 19 29 if (item.cardData.image && typeof item.cardData.image === 'object') { 20 30 return getImageBlobUrl({ did, link: item.cardData.image?.ref?.$link }); 21 31 } 22 32 23 - return item.cardData.url || item.cardData.image; 24 - } 25 - 26 - function handleDragOver(e: DragEvent) { 27 - e.preventDefault(); 28 - isDragging = true; 29 - } 30 - 31 - function handleDragLeave(e: DragEvent) { 32 - e.preventDefault(); 33 - isDragging = false; 33 + return item.cardData.image; 34 34 } 35 35 36 - async function handleDrop(e: DragEvent) { 37 - e.preventDefault(); 38 - isDragging = false; 39 - 40 - const files = e.dataTransfer?.files; 41 - if (!files || files.length === 0) return; 42 - 43 - const file = files[0]; 44 - if (!file.type.startsWith('image/gif')) { 36 + async function searchGiphy(query: string) { 37 + if (!query.trim()) { 38 + searchResults = []; 45 39 return; 46 40 } 47 41 48 - await handleFile(file); 42 + isLoading = true; 43 + try { 44 + const response = await fetch(`/api/giphy?q=${encodeURIComponent(query)}`); 45 + if (response.ok) { 46 + const data = await response.json(); 47 + searchResults = data.data || []; 48 + } 49 + } catch (error) { 50 + console.error('Failed to search Giphy:', error); 51 + } finally { 52 + isLoading = false; 53 + } 49 54 } 50 55 51 - async function handleFile(file: File) { 52 - const objectUrl = URL.createObjectURL(file); 53 - item.cardData.objectUrl = objectUrl; 54 - item.cardData.blob = file; 55 - item.cardData.url = ''; 56 - hasError = false; 56 + function handleSearchInput() { 57 + if (searchTimeout) clearTimeout(searchTimeout); 58 + searchTimeout = setTimeout(() => { 59 + searchGiphy(searchQuery); 60 + }, 300); 57 61 } 58 62 59 - function convertGifUrl(url: string): string { 60 - const trimmedUrl = url.trim(); 61 - 62 - // Giphy page URL: https://giphy.com/gifs/name-name-ID or https://giphy.com/gifs/ID 63 - const giphyMatch = trimmedUrl.match(/giphy\.com\/gifs\/(?:.*-)?([a-zA-Z0-9]+)(?:\?|$)/); 64 - if (giphyMatch) { 65 - return `https://media.giphy.com/media/${giphyMatch[1]}/giphy.gif`; 66 - } 67 - 68 - // Giphy media URL - already correct format 69 - if (trimmedUrl.includes('media.giphy.com')) { 70 - return trimmedUrl; 71 - } 72 - 73 - // Return as-is for direct GIF URLs or other sources 74 - return trimmedUrl; 63 + function selectGif(gif: (typeof searchResults)[0]) { 64 + item.cardData.url = gif.images.original.url; 65 + item.cardData.alt = gif.title; 66 + hasError = false; 67 + isSearchOpen = false; 68 + searchQuery = ''; 69 + searchResults = []; 75 70 } 76 71 77 - function handleUrlSubmit() { 78 - if (urlInput.trim()) { 79 - item.cardData.url = convertGifUrl(urlInput); 80 - item.cardData.objectUrl = undefined; 81 - item.cardData.blob = undefined; 82 - hasError = false; 83 - } 84 - isEditing = false; 72 + function openSearch() { 73 + isSearchOpen = true; 85 74 } 86 75 87 - function handleKeydown(e: KeyboardEvent) { 88 - if (e.key === 'Enter') { 89 - handleUrlSubmit(); 90 - } 91 - if (e.key === 'Escape') { 92 - urlInput = item.cardData.url || ''; 93 - isEditing = false; 94 - } 95 - } 96 - 97 - function handleClick() { 98 - isEditing = true; 99 - requestAnimationFrame(() => { 100 - inputElement?.focus(); 101 - if (getSrc()) { 102 - inputElement?.select(); 103 - } 104 - }); 76 + function closeSearch() { 77 + isSearchOpen = false; 78 + searchQuery = ''; 79 + searchResults = []; 105 80 } 106 81 </script> 107 82 108 83 <!-- svelte-ignore a11y_no_static_element_interactions --> 109 84 <!-- svelte-ignore a11y_click_events_have_key_events --> 110 - <div 111 - class="relative h-full w-full overflow-hidden" 112 - ondragover={handleDragOver} 113 - ondragleave={handleDragLeave} 114 - ondrop={handleDrop} 115 - onclick={handleClick} 116 - > 85 + <div class="relative h-full w-full overflow-hidden" onclick={openSearch}> 117 86 {#if getSrc() && !hasError} 118 87 <img 119 88 class="absolute inset-0 h-full w-full object-cover" ··· 122 91 onerror={() => (hasError = true)} 123 92 /> 124 93 {:else} 125 - <!-- Empty state / Drop zone --> 94 + <!-- Empty state --> 126 95 <div 127 - class="bg-base-100 dark:bg-base-900 flex h-full w-full cursor-pointer flex-col items-center justify-center gap-3 p-4 transition-colors {isDragging 128 - ? 'bg-accent-100 dark:bg-accent-900/30' 129 - : ''}" 96 + class="bg-base-100 dark:bg-base-900 flex h-full w-full cursor-pointer flex-col items-center justify-center gap-3 p-4" 130 97 > 131 98 <div 132 - class="flex size-12 items-center justify-center rounded-xl border-2 border-dashed {isDragging 133 - ? 'border-accent-500' 134 - : 'border-base-300 dark:border-base-700'}" 99 + class="border-base-300 dark:border-base-700 flex size-12 items-center justify-center rounded-xl border-2 border-dashed" 135 100 > 136 101 <svg 137 102 xmlns="http://www.w3.org/2000/svg" ··· 139 104 viewBox="0 0 24 24" 140 105 stroke-width="1.5" 141 106 stroke="currentColor" 142 - class="size-6 {isDragging ? 'text-accent-500' : 'text-base-400 dark:text-base-600'}" 107 + class="text-base-400 dark:text-base-600 size-6" 143 108 > 144 109 <path 145 110 stroke-linecap="round" 146 111 stroke-linejoin="round" 147 - d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" 112 + 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" 148 113 /> 149 114 </svg> 150 115 </div> 151 116 <div class="text-center"> 152 - <p class="text-base-700 dark:text-base-300 text-sm font-medium">Drop a GIF here</p> 153 - <p class="text-base-500 dark:text-base-500 mt-1 text-xs">or click to enter GIPHY URL</p> 117 + <p class="text-base-700 dark:text-base-300 text-sm font-medium">Click to search GIPHY</p> 154 118 </div> 155 119 </div> 156 120 {/if} 157 121 158 - <!-- URL input overlay --> 159 - {#if isEditing} 122 + <!-- Giphy search modal --> 123 + {#if isSearchOpen} 160 124 <!-- svelte-ignore a11y_click_events_have_key_events --> 161 125 <div 162 - class="absolute inset-0 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" 126 + class="absolute inset-0 z-50 flex flex-col bg-black/80 backdrop-blur-sm" 163 127 onclick={(e) => e.stopPropagation()} 164 128 > 165 - <div class="w-full max-w-sm"> 129 + <!-- Header --> 130 + <div class="flex items-center gap-2 p-3"> 166 131 <input 167 - bind:this={inputElement} 168 - bind:value={urlInput} 169 - onblur={handleUrlSubmit} 170 - onkeydown={handleKeydown} 171 - class="w-full rounded-lg border border-white/20 bg-white/10 px-4 py-2 text-sm text-white placeholder-white/50 transition-colors outline-none focus:border-white/40 focus:bg-white/20" 172 - placeholder="Paste GIPHY URL" 132 + bind:value={searchQuery} 133 + oninput={handleSearchInput} 134 + class="flex-1 rounded-lg border border-white/20 bg-white/10 px-4 py-2 text-sm text-white placeholder-white/50 outline-none focus:border-white/40 focus:bg-white/20" 135 + placeholder="Search GIPHY..." 173 136 /> 174 - <p class="mt-2 text-center text-xs text-white/60"> 175 - Press Enter to confirm, Escape to cancel 176 - </p> 137 + <button 138 + onclick={closeSearch} 139 + aria-label="Close search" 140 + class="rounded-lg p-2 text-white/70 transition-colors hover:bg-white/10 hover:text-white" 141 + > 142 + <svg 143 + xmlns="http://www.w3.org/2000/svg" 144 + fill="none" 145 + viewBox="0 0 24 24" 146 + stroke-width="1.5" 147 + stroke="currentColor" 148 + class="size-5" 149 + > 150 + <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /> 151 + </svg> 152 + </button> 153 + </div> 154 + 155 + <!-- Results grid --> 156 + <div class="flex-1 overflow-y-auto p-2"> 157 + {#if isLoading} 158 + <div class="flex h-full items-center justify-center"> 159 + <p class="text-white/60">Searching...</p> 160 + </div> 161 + {:else if searchResults.length > 0} 162 + <div class="grid grid-cols-2 gap-2"> 163 + {#each searchResults as gif} 164 + <button 165 + onclick={() => selectGif(gif)} 166 + class="overflow-hidden rounded-lg transition-transform hover:scale-[1.02]" 167 + > 168 + <img 169 + src={gif.images.fixed_height.url} 170 + alt={gif.title} 171 + class="h-auto w-full" 172 + loading="lazy" 173 + /> 174 + </button> 175 + {/each} 176 + </div> 177 + {:else if searchQuery} 178 + <div class="flex h-full items-center justify-center"> 179 + <p class="text-white/60">No results found</p> 180 + </div> 181 + {:else} 182 + <div class="flex h-full items-center justify-center"> 183 + <p class="text-white/60">Type to search for GIFs</p> 184 + </div> 185 + {/if} 177 186 </div> 178 - </div> 179 - {/if} 180 187 181 - <!-- Drag overlay --> 182 - {#if isDragging} 183 - <div 184 - class="bg-accent-500/20 pointer-events-none absolute inset-0 flex items-center justify-center backdrop-blur-sm" 185 - > 186 - <p class="text-accent-700 dark:text-accent-300 text-lg font-semibold">Drop GIF here</p> 188 + <!-- Giphy attribution --> 189 + <div class="p-2 text-center"> 190 + <span class="text-xs text-white/40">Powered by GIPHY</span> 191 + </div> 187 192 </div> 188 193 {/if} 189 194 </div>
+103 -24
src/lib/cards/GIFCard/GifCardSettings.svelte
··· 1 1 <script lang="ts"> 2 2 import type { Item } from '$lib/types'; 3 3 import type { SettingsComponentProps } from '../types'; 4 - import { Input, Label } from '@foxui/core'; 4 + import { Button, Input, Label } from '@foxui/core'; 5 5 6 6 let { item = $bindable<Item>() }: SettingsComponentProps = $props(); 7 7 8 - function handleFileSelect(e: Event) { 9 - const input = e.target as HTMLInputElement; 10 - const file = input.files?.[0]; 11 - if (!file || !file.type.startsWith('image/gif')) return; 8 + let isSearchOpen = $state(false); 9 + let searchQuery = $state(''); 10 + let searchResults = $state< 11 + Array<{ 12 + id: string; 13 + title: string; 14 + images: { 15 + fixed_height: { url: string; width: string; height: string }; 16 + original: { url: string }; 17 + }; 18 + }> 19 + >([]); 20 + let isLoading = $state(false); 21 + let searchTimeout: ReturnType<typeof setTimeout> | null = null; 12 22 13 - const objectUrl = URL.createObjectURL(file); 14 - item.cardData.objectUrl = objectUrl; 15 - item.cardData.blob = file; 16 - item.cardData.url = ''; 23 + async function searchGiphy(query: string) { 24 + if (!query.trim()) { 25 + searchResults = []; 26 + return; 27 + } 28 + 29 + isLoading = true; 30 + try { 31 + const response = await fetch(`/api/giphy?q=${encodeURIComponent(query)}`); 32 + if (response.ok) { 33 + const data = await response.json(); 34 + searchResults = data.data || []; 35 + } 36 + } catch (error) { 37 + console.error('Failed to search Giphy:', error); 38 + } finally { 39 + isLoading = false; 40 + } 41 + } 42 + 43 + function handleSearchInput() { 44 + if (searchTimeout) clearTimeout(searchTimeout); 45 + searchTimeout = setTimeout(() => { 46 + searchGiphy(searchQuery); 47 + }, 300); 48 + } 49 + 50 + function selectGif(gif: (typeof searchResults)[0]) { 51 + item.cardData.url = gif.images.original.url; 52 + item.cardData.alt = gif.title; 53 + isSearchOpen = false; 54 + searchQuery = ''; 55 + searchResults = []; 17 56 } 18 57 </script> 19 58 20 59 <div class="flex flex-col gap-3"> 21 60 <div> 22 - <Label class="mb-1 text-xs">GIF URL</Label> 23 - <Input 24 - bind:value={item.cardData.url} 25 - placeholder="https://media.giphy.com/..." 26 - class="w-full" 27 - /> 61 + <Label class="mb-1 text-xs">Search GIPHY</Label> 62 + <Button 63 + variant="secondary" 64 + class="w-full justify-start" 65 + onclick={() => (isSearchOpen = !isSearchOpen)} 66 + > 67 + <svg 68 + xmlns="http://www.w3.org/2000/svg" 69 + fill="none" 70 + viewBox="0 0 24 24" 71 + stroke-width="1.5" 72 + stroke="currentColor" 73 + class="mr-2 size-4" 74 + > 75 + <path 76 + stroke-linecap="round" 77 + stroke-linejoin="round" 78 + 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" 79 + /> 80 + </svg> 81 + Search for GIFs 82 + </Button> 28 83 </div> 29 84 30 - <div> 31 - <Label class="mb-1 text-xs">Or upload a GIF</Label> 32 - <input 33 - type="file" 34 - accept="image/gif" 35 - onchange={handleFileSelect} 36 - class="w-full rounded border border-base-300 bg-base-100 px-2 py-1 text-sm dark:border-base-700 dark:bg-base-800" 37 - /> 38 - </div> 85 + {#if isSearchOpen} 86 + <div class="flex flex-col gap-2"> 87 + <Input 88 + bind:value={searchQuery} 89 + oninput={handleSearchInput} 90 + placeholder="Type to search..." 91 + class="w-full" 92 + autofocus 93 + /> 94 + {#if isLoading} 95 + <p class="text-base-500 text-xs">Searching...</p> 96 + {:else if searchResults.length > 0} 97 + <div class="grid max-h-48 grid-cols-2 gap-1 overflow-y-auto"> 98 + {#each searchResults as gif} 99 + <button 100 + onclick={() => selectGif(gif)} 101 + class="overflow-hidden rounded transition-transform hover:scale-[1.02]" 102 + > 103 + <img 104 + src={gif.images.fixed_height.url} 105 + alt={gif.title} 106 + class="h-auto w-full" 107 + loading="lazy" 108 + /> 109 + </button> 110 + {/each} 111 + </div> 112 + <p class="text-base-400 text-center text-xs">Powered by GIPHY</p> 113 + {:else if searchQuery} 114 + <p class="text-base-500 text-xs">No results found</p> 115 + {/if} 116 + </div> 117 + {/if} 39 118 40 119 <div> 41 120 <Label class="mb-1 text-xs">Alt text</Label>
-14
src/lib/cards/GIFCard/index.ts
··· 1 - import { uploadBlob } from '$lib/oauth/utils'; 2 1 import type { CardDefinition } from '../types'; 3 2 import EditingGifCard from './EditingGifCard.svelte'; 4 3 import GifCard from './GifCard.svelte'; ··· 18 17 card.h = 2; 19 18 card.mobileW = 4; 20 19 card.mobileH = 4; 21 - }, 22 - upload: async (item) => { 23 - if (item.cardData.blob) { 24 - item.cardData.image = await uploadBlob(item.cardData.blob); 25 - delete item.cardData.blob; 26 - } 27 - 28 - if (item.cardData.objectUrl) { 29 - URL.revokeObjectURL(item.cardData.objectUrl); 30 - delete item.cardData.objectUrl; 31 - } 32 - 33 - return item; 34 20 }, 35 21 settingsComponent: GifCardSettings, 36 22 sidebarButtonText: 'GIF',
+37
src/routes/api/giphy/+server.ts
··· 1 + import { env } from '$env/dynamic/private'; 2 + import { json } from '@sveltejs/kit'; 3 + 4 + export async function GET({ url }) { 5 + const q = url.searchParams.get('q'); 6 + const offset = url.searchParams.get('offset') || '0'; 7 + const limit = url.searchParams.get('limit') || '24'; 8 + 9 + if (!q) { 10 + return json({ error: 'No search query provided' }, { status: 400 }); 11 + } 12 + 13 + const apiKey = env.GIPHY_API_TOKEN; 14 + if (!apiKey) { 15 + return json({ error: 'Giphy API not configured' }, { status: 500 }); 16 + } 17 + 18 + const giphyUrl = new URL('https://api.giphy.com/v1/gifs/search'); 19 + giphyUrl.searchParams.set('api_key', apiKey); 20 + giphyUrl.searchParams.set('q', q); 21 + giphyUrl.searchParams.set('limit', limit); 22 + giphyUrl.searchParams.set('offset', offset); 23 + giphyUrl.searchParams.set('rating', 'g'); 24 + giphyUrl.searchParams.set('lang', 'en'); 25 + 26 + try { 27 + const response = await fetch(giphyUrl.toString()); 28 + if (!response.ok) { 29 + throw new Error(`Giphy API error: ${response.status}`); 30 + } 31 + const data = await response.json(); 32 + return json(data); 33 + } catch (error) { 34 + console.error('Error fetching from Giphy:', error); 35 + return json({ error: 'Failed to fetch from Giphy' }, { status: 500 }); 36 + } 37 + }