your personal website on atproto - mirror blento.app

actual GIF card

+362
+202
src/lib/cards/GIFCard/EditingGifCard.svelte
··· 1 + <script lang="ts"> 2 + import { getDidContext } from '$lib/website/context'; 3 + import { getImageBlobUrl } from '$lib/oauth/utils'; 4 + import type { ContentComponentProps } from '../types'; 5 + 6 + let { item = $bindable() }: ContentComponentProps = $props(); 7 + 8 + const did = getDidContext(); 9 + 10 + let isDragging = $state(false); 11 + let urlInput = $state(item.cardData.url || ''); 12 + let hasError = $state(false); 13 + let isEditing = $state(false); 14 + let inputElement: HTMLInputElement | null = $state(null); 15 + 16 + function getSrc() { 17 + if (item.cardData.objectUrl) return item.cardData.objectUrl; 18 + 19 + if (item.cardData.image && typeof item.cardData.image === 'object') { 20 + return getImageBlobUrl({ did, link: item.cardData.image?.ref?.$link }); 21 + } 22 + 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; 34 + } 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')) { 45 + return; 46 + } 47 + 48 + await handleFile(file); 49 + } 50 + 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; 57 + } 58 + 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 + // Tenor page URL: https://tenor.com/view/name-name-gif-ID 74 + const tenorMatch = trimmedUrl.match(/tenor\.com\/view\/.*-(\d+)(?:\?|$)/); 75 + if (tenorMatch) { 76 + // Tenor doesn't have a simple direct URL conversion, keep as-is for now 77 + // Users should use the "Copy GIF" link from Tenor which gives media URL 78 + return trimmedUrl; 79 + } 80 + 81 + // Tenor media URL - already correct 82 + if (trimmedUrl.includes('media.tenor.com') || trimmedUrl.includes('c.tenor.com')) { 83 + return trimmedUrl; 84 + } 85 + 86 + // Return as-is for direct GIF URLs or other sources 87 + return trimmedUrl; 88 + } 89 + 90 + function handleUrlSubmit() { 91 + if (urlInput.trim()) { 92 + item.cardData.url = convertGifUrl(urlInput); 93 + item.cardData.objectUrl = undefined; 94 + item.cardData.blob = undefined; 95 + hasError = false; 96 + } 97 + isEditing = false; 98 + } 99 + 100 + function handleKeydown(e: KeyboardEvent) { 101 + if (e.key === 'Enter') { 102 + handleUrlSubmit(); 103 + } 104 + if (e.key === 'Escape') { 105 + urlInput = item.cardData.url || ''; 106 + isEditing = false; 107 + } 108 + } 109 + 110 + function handleClick() { 111 + isEditing = true; 112 + requestAnimationFrame(() => { 113 + inputElement?.focus(); 114 + if (getSrc()) { 115 + inputElement?.select(); 116 + } 117 + }); 118 + } 119 + </script> 120 + 121 + <!-- svelte-ignore a11y_no_static_element_interactions --> 122 + <!-- svelte-ignore a11y_click_events_have_key_events --> 123 + <div 124 + class="relative h-full w-full overflow-hidden" 125 + ondragover={handleDragOver} 126 + ondragleave={handleDragLeave} 127 + ondrop={handleDrop} 128 + onclick={handleClick} 129 + > 130 + {#if getSrc() && !hasError} 131 + <img 132 + class="absolute inset-0 h-full w-full object-cover" 133 + src={getSrc()} 134 + alt={item.cardData.alt || 'GIF'} 135 + onerror={() => (hasError = true)} 136 + /> 137 + {:else} 138 + <!-- Empty state / Drop zone --> 139 + <div 140 + 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 141 + ? 'bg-accent-100 dark:bg-accent-900/30' 142 + : ''}" 143 + > 144 + <div 145 + class="flex size-12 items-center justify-center rounded-xl border-2 border-dashed {isDragging 146 + ? 'border-accent-500' 147 + : 'border-base-300 dark:border-base-700'}" 148 + > 149 + <svg 150 + xmlns="http://www.w3.org/2000/svg" 151 + fill="none" 152 + viewBox="0 0 24 24" 153 + stroke-width="1.5" 154 + stroke="currentColor" 155 + class="size-6 {isDragging ? 'text-accent-500' : 'text-base-400 dark:text-base-600'}" 156 + > 157 + <path 158 + stroke-linecap="round" 159 + stroke-linejoin="round" 160 + 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" 161 + /> 162 + </svg> 163 + </div> 164 + <div class="text-center"> 165 + <p class="text-base-700 dark:text-base-300 text-sm font-medium">Drop a GIF here</p> 166 + <p class="text-base-500 dark:text-base-500 mt-1 text-xs">or click to enter URL</p> 167 + </div> 168 + </div> 169 + {/if} 170 + 171 + <!-- URL input overlay --> 172 + {#if isEditing} 173 + <!-- svelte-ignore a11y_click_events_have_key_events --> 174 + <div 175 + class="absolute inset-0 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm" 176 + onclick={(e) => e.stopPropagation()} 177 + > 178 + <div class="w-full max-w-sm"> 179 + <input 180 + bind:this={inputElement} 181 + bind:value={urlInput} 182 + onblur={handleUrlSubmit} 183 + onkeydown={handleKeydown} 184 + 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" 185 + placeholder="Paste GIF URL" 186 + /> 187 + <p class="mt-2 text-center text-xs text-white/60"> 188 + Press Enter to confirm, Escape to cancel 189 + </p> 190 + </div> 191 + </div> 192 + {/if} 193 + 194 + <!-- Drag overlay --> 195 + {#if isDragging} 196 + <div 197 + class="bg-accent-500/20 pointer-events-none absolute inset-0 flex items-center justify-center backdrop-blur-sm" 198 + > 199 + <p class="text-accent-700 dark:text-accent-300 text-lg font-semibold">Drop GIF here</p> 200 + </div> 201 + {/if} 202 + </div>
+53
src/lib/cards/GIFCard/GifCard.svelte
··· 1 + <script lang="ts"> 2 + import { getDidContext } from '$lib/website/context'; 3 + import { getImageBlobUrl } from '$lib/oauth/utils'; 4 + import type { ContentComponentProps } from '../types'; 5 + 6 + let { item }: ContentComponentProps = $props(); 7 + 8 + const did = getDidContext(); 9 + 10 + function getSrc() { 11 + if (item.cardData.objectUrl) return item.cardData.objectUrl; 12 + 13 + if (item.cardData.image && typeof item.cardData.image === 'object') { 14 + return getImageBlobUrl({ did, link: item.cardData.image?.ref?.$link }); 15 + } 16 + 17 + return item.cardData.url || item.cardData.image; 18 + } 19 + 20 + let hasError = $state(false); 21 + </script> 22 + 23 + <div class="relative h-full w-full overflow-hidden"> 24 + {#key item.cardData.url || item.cardData.image || item.cardData.objectUrl} 25 + {#if getSrc() && !hasError} 26 + <img 27 + class="absolute inset-0 h-full w-full object-cover" 28 + src={getSrc()} 29 + alt={item.cardData.alt || 'GIF'} 30 + onerror={() => (hasError = true)} 31 + /> 32 + {:else} 33 + <div 34 + class="flex h-full w-full items-center justify-center bg-base-100 dark:bg-base-900" 35 + > 36 + <svg 37 + xmlns="http://www.w3.org/2000/svg" 38 + fill="none" 39 + viewBox="0 0 24 24" 40 + stroke-width="1.5" 41 + stroke="currentColor" 42 + class="text-base-400 dark:text-base-600 size-12" 43 + > 44 + <path 45 + stroke-linecap="round" 46 + stroke-linejoin="round" 47 + 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" 48 + /> 49 + </svg> 50 + </div> 51 + {/if} 52 + {/key} 53 + </div>
+44
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 { Input, Label } from '@foxui/core'; 5 + 6 + let { item = $bindable<Item>() }: SettingsComponentProps = $props(); 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; 12 + 13 + const objectUrl = URL.createObjectURL(file); 14 + item.cardData.objectUrl = objectUrl; 15 + item.cardData.blob = file; 16 + item.cardData.url = ''; 17 + } 18 + </script> 19 + 20 + <div class="flex flex-col gap-3"> 21 + <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 + /> 28 + </div> 29 + 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> 39 + 40 + <div> 41 + <Label class="mb-1 text-xs">Alt text</Label> 42 + <Input bind:value={item.cardData.alt} placeholder="Description" class="w-full" /> 43 + </div> 44 + </div>
+63
src/lib/cards/GIFCard/index.ts
··· 1 + import { uploadBlob } from '$lib/oauth/utils'; 2 + import type { CardDefinition } from '../types'; 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 + createNew: (card) => { 12 + card.cardType = 'gif'; 13 + card.cardData = { 14 + url: '', 15 + alt: '' 16 + }; 17 + card.w = 2; 18 + card.h = 2; 19 + card.mobileW = 4; 20 + 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 + }, 35 + settingsComponent: GifCardSettings, 36 + sidebarButtonText: 'GIF', 37 + defaultColor: 'transparent', 38 + allowSetColor: false, 39 + minW: 1, 40 + minH: 1, 41 + onUrlHandler: (url, item) => { 42 + const gifUrlPatterns = [ 43 + /\.gif(\?.*)?$/i, 44 + /giphy\.com\/gifs\//i, 45 + /media\.giphy\.com/i, 46 + /tenor\.com/i, 47 + /imgur\.com.*\.gif/i 48 + ]; 49 + 50 + if (gifUrlPatterns.some((pattern) => pattern.test(url))) { 51 + // Convert Giphy page URLs to direct media URLs 52 + const giphyMatch = url.match(/giphy\.com\/gifs\/(?:.*-)?([a-zA-Z0-9]+)(?:\?|$)/); 53 + if (giphyMatch) { 54 + item.cardData.url = `https://media.giphy.com/media/${giphyMatch[1]}/giphy.gif`; 55 + } else { 56 + item.cardData.url = url; 57 + } 58 + return item; 59 + } 60 + return null; 61 + }, 62 + urlHandlerPriority: 5 63 + } as CardDefinition & { type: 'gif' };