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