tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
19
fork
atom
overview
issues
pulls
pipelines
actual GIF card
unbedenklich
2 weeks ago
46564bb0
c688d1ff
+362
4 changed files
expand all
collapse all
unified
split
src
lib
cards
GIFCard
EditingGifCard.svelte
GifCard.svelte
GifCardSettings.svelte
index.ts
+202
src/lib/cards/GIFCard/EditingGifCard.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
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
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 { 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
···
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 { 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
···
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 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
···
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 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' };