tangled
alpha
login
or
join now
flo-bit.dev
/
blento
your personal website on atproto - mirror
blento.app
20
fork
atom
overview
issues
pulls
pipelines
part 1
Florian
2 weeks ago
9a75add1
05c4a0e5
+258
-151
4 changed files
expand all
collapse all
unified
split
src
lib
cards
GIFCard
EditingGifCard.svelte
GifCardSettings.svelte
index.ts
routes
api
giphy
+server.ts
+118
-113
src/lib/cards/GIFCard/EditingGifCard.svelte
···
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);
0
0
0
0
0
0
0
0
0
0
0
0
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);
0
0
0
0
0
0
0
0
0
0
0
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
-
// Return as-is for direct GIF URLs or other sources
74
-
return trimmedUrl;
75
}
76
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;
85
}
86
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
-
});
105
}
106
</script>
107
108
<!-- svelte-ignore a11y_no_static_element_interactions -->
109
<!-- 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
-
>
117
{#if getSrc() && !hasError}
118
<img
119
class="absolute inset-0 h-full w-full object-cover"
···
122
onerror={() => (hasError = true)}
123
/>
124
{:else}
125
-
<!-- Empty state / Drop zone -->
126
<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
-
: ''}"
130
>
131
<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'}"
135
>
136
<svg
137
xmlns="http://www.w3.org/2000/svg"
···
139
viewBox="0 0 24 24"
140
stroke-width="1.5"
141
stroke="currentColor"
142
-
class="size-6 {isDragging ? 'text-accent-500' : 'text-base-400 dark:text-base-600'}"
143
>
144
<path
145
stroke-linecap="round"
146
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"
148
/>
149
</svg>
150
</div>
151
<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>
154
</div>
155
</div>
156
{/if}
157
158
-
<!-- URL input overlay -->
159
-
{#if isEditing}
160
<!-- svelte-ignore a11y_click_events_have_key_events -->
161
<div
162
-
class="absolute inset-0 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
163
onclick={(e) => e.stopPropagation()}
164
>
165
-
<div class="w-full max-w-sm">
0
166
<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"
173
/>
174
-
<p class="mt-2 text-center text-xs text-white/60">
175
-
Press Enter to confirm, Escape to cancel
176
-
</p>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
177
</div>
178
-
</div>
179
-
{/if}
180
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>
187
</div>
188
{/if}
189
</div>
···
7
8
const did = getDidContext();
9
0
0
10
let hasError = $state(false);
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;
25
26
function getSrc() {
27
+
if (item.cardData.url) return item.cardData.url;
28
29
if (item.cardData.image && typeof item.cardData.image === 'object') {
30
return getImageBlobUrl({ did, link: item.cardData.image?.ref?.$link });
31
}
32
33
+
return item.cardData.image;
0
0
0
0
0
0
0
0
0
0
34
}
35
36
+
async function searchGiphy(query: string) {
37
+
if (!query.trim()) {
38
+
searchResults = [];
0
0
0
0
0
0
39
return;
40
}
41
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
+
}
54
}
55
56
+
function handleSearchInput() {
57
+
if (searchTimeout) clearTimeout(searchTimeout);
58
+
searchTimeout = setTimeout(() => {
59
+
searchGiphy(searchQuery);
60
+
}, 300);
0
61
}
62
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 = [];
0
0
0
0
0
0
0
0
0
70
}
71
72
+
function openSearch() {
73
+
isSearchOpen = true;
0
0
0
0
0
0
74
}
75
76
+
function closeSearch() {
77
+
isSearchOpen = false;
78
+
searchQuery = '';
79
+
searchResults = [];
0
0
0
0
0
0
0
0
0
0
0
0
0
0
80
}
81
</script>
82
83
<!-- svelte-ignore a11y_no_static_element_interactions -->
84
<!-- svelte-ignore a11y_click_events_have_key_events -->
85
+
<div class="relative h-full w-full overflow-hidden" onclick={openSearch}>
0
0
0
0
0
0
86
{#if getSrc() && !hasError}
87
<img
88
class="absolute inset-0 h-full w-full object-cover"
···
91
onerror={() => (hasError = true)}
92
/>
93
{:else}
94
+
<!-- Empty state -->
95
<div
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"
0
0
97
>
98
<div
99
+
class="border-base-300 dark:border-base-700 flex size-12 items-center justify-center rounded-xl border-2 border-dashed"
0
0
100
>
101
<svg
102
xmlns="http://www.w3.org/2000/svg"
···
104
viewBox="0 0 24 24"
105
stroke-width="1.5"
106
stroke="currentColor"
107
+
class="text-base-400 dark:text-base-600 size-6"
108
>
109
<path
110
stroke-linecap="round"
111
stroke-linejoin="round"
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"
113
/>
114
</svg>
115
</div>
116
<div class="text-center">
117
+
<p class="text-base-700 dark:text-base-300 text-sm font-medium">Click to search GIPHY</p>
0
118
</div>
119
</div>
120
{/if}
121
122
+
<!-- Giphy search modal -->
123
+
{#if isSearchOpen}
124
<!-- svelte-ignore a11y_click_events_have_key_events -->
125
<div
126
+
class="absolute inset-0 z-50 flex flex-col bg-black/80 backdrop-blur-sm"
127
onclick={(e) => e.stopPropagation()}
128
>
129
+
<!-- Header -->
130
+
<div class="flex items-center gap-2 p-3">
131
<input
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..."
0
0
136
/>
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}
186
</div>
0
0
187
188
+
<!-- Giphy attribution -->
189
+
<div class="p-2 text-center">
190
+
<span class="text-xs text-white/40">Powered by GIPHY</span>
191
+
</div>
0
0
192
</div>
193
{/if}
194
</div>
+103
-24
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;
0
0
0
0
0
0
0
0
0
0
12
13
-
const objectUrl = URL.createObjectURL(file);
14
-
item.cardData.objectUrl = objectUrl;
15
-
item.cardData.blob = file;
16
-
item.cardData.url = '';
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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
-
/>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
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>
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
39
40
<div>
41
<Label class="mb-1 text-xs">Alt text</Label>
···
1
<script lang="ts">
2
import type { Item } from '$lib/types';
3
import type { SettingsComponentProps } from '../types';
4
+
import { Button, Input, Label } from '@foxui/core';
5
6
let { item = $bindable<Item>() }: SettingsComponentProps = $props();
7
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;
22
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 = [];
56
}
57
</script>
58
59
<div class="flex flex-col gap-3">
60
<div>
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>
83
</div>
84
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}
118
119
<div>
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
import type { CardDefinition } from '../types';
3
import EditingGifCard from './EditingGifCard.svelte';
4
import GifCard from './GifCard.svelte';
···
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',
···
0
1
import type { CardDefinition } from '../types';
2
import EditingGifCard from './EditingGifCard.svelte';
3
import GifCard from './GifCard.svelte';
···
17
card.h = 2;
18
card.mobileW = 4;
19
card.mobileH = 4;
0
0
0
0
0
0
0
0
0
0
0
0
0
20
},
21
settingsComponent: GifCardSettings,
22
sidebarButtonText: 'GIF',
+37
src/routes/api/giphy/+server.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
···
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
+
}