web/src/routes/upload/+page.svelte (view raw)
1<script lang="ts">
2 import { onMount, onDestroy } from 'svelte';
3 import { uploadImage, suggestTags } from '$lib/api';
4
5 let file = $state<File | null>(null);
6 let tags = $state('');
7 let error = $state('');
8 let success = $state('');
9 let loading = $state(false);
10 let suggestions = $state<string[]>([]);
11 let showSuggestions = $state(false);
12 let sugIndex = $state(-1);
13 let dragActive = $state(false);
14
15 let fileInput = $state<HTMLInputElement | null>(null);
16
17 let dragCounter = $state(0);
18
19 function onDocumentDragEnter(e: DragEvent) {
20 if (!e.dataTransfer?.types.includes('Files')) return;
21 dragCounter++;
22 dragActive = true;
23 }
24
25 function onDocumentDragLeave(e: DragEvent) {
26 dragCounter--;
27 if (dragCounter <= 0) {
28 dragCounter = 0;
29 dragActive = false;
30 }
31 }
32
33 function onDocumentDragOver(e: DragEvent) {
34 e.preventDefault();
35 }
36
37 function onDocumentDrop(e: DragEvent) {
38 e.preventDefault();
39 dragCounter = 0;
40 dragActive = false;
41 const dropped = e.dataTransfer?.files?.[0];
42 if (!dropped) return;
43 if (dropped.type.startsWith('image/') || dropped.type.startsWith('video/')) {
44 file = dropped;
45 } else {
46 error = 'Unsupported file type. Please drop an image or video.';
47 }
48 }
49
50 onMount(() => {
51 document.addEventListener('dragenter', onDocumentDragEnter);
52 document.addEventListener('dragleave', onDocumentDragLeave);
53 document.addEventListener('dragover', onDocumentDragOver);
54 document.addEventListener('drop', onDocumentDrop);
55 });
56
57 onDestroy(() => {
58 document.removeEventListener('dragenter', onDocumentDragEnter);
59 document.removeEventListener('dragleave', onDocumentDragLeave);
60 document.removeEventListener('dragover', onDocumentDragOver);
61 document.removeEventListener('drop', onDocumentDrop);
62 });
63
64 // Auto-scroll active suggestion into view.
65 $effect(() => {
66 if (sugIndex >= 0) {
67 const el = document.querySelector('.sug-btn.active') as HTMLElement | null;
68 if (el) el.scrollIntoView({ block: 'nearest' });
69 }
70 });
71
72 async function onTagInput() {
73 sugIndex = -1;
74 const q = tags.split(',').pop()?.trim() || '';
75 if (q.length < 1) {
76 suggestions = [];
77 showSuggestions = false;
78 return;
79 }
80 try {
81 const result = await suggestTags(q);
82 suggestions = result.map((t) => t.tag);
83 showSuggestions = suggestions.length > 0;
84 } catch {
85 suggestions = [];
86 showSuggestions = false;
87 }
88 }
89
90 function selectSuggestion(s: string) {
91 const parts = tags.split(',').slice(0, -1);
92 parts.push(s);
93 tags = parts.join(', ') + ', ';
94 showSuggestions = false;
95 sugIndex = -1;
96 }
97
98 function openFilePicker() {
99 fileInput?.click();
100 }
101
102 async function handleSubmit(e: Event) {
103 e.preventDefault();
104 if (!file) return;
105 error = '';
106 success = '';
107 loading = true;
108 try {
109 const tagList = tags.split(',').map((t) => t.trim()).filter(Boolean);
110 const img = await uploadImage(file, tagList);
111 success = `Uploaded "${img.filename}" (ID: ${img.id})`;
112 file = null;
113 tags = '';
114 } catch (err) {
115 error = err instanceof Error ? err.message : 'Upload failed';
116 } finally {
117 loading = false;
118 }
119 }
120</script>
121
122{#if dragActive}
123 <div class="drop-overlay" class:loading>
124 <div class="drop-content">
125 <div class="drop-icon">⬇</div>
126 <div class="drop-text">DROP MEDIA HERE</div>
127 </div>
128 </div>
129{/if}
130
131<div class="page-head">
132 <h1>UPLOAD</h1>
133 <div class="head-line"></div>
134</div>
135
136<form onsubmit={handleSubmit}>
137 {#if error}
138 <p class="error">{error}</p>
139 {/if}
140 {#if success}
141 <p class="success">{success} <a href="/browse">BROWSE</a></p>
142 {/if}
143
144 <input type="file" id="file-input" accept="image/*,video/mp4,video/webm"
145 bind:this={fileInput}
146 onchange={(e) => { file = (e.target as HTMLInputElement).files?.[0] ?? null; }}
147 disabled={loading}
148 />
149
150 <button type="button" class="btn select-btn" onclick={openFilePicker} disabled={loading}>
151 {file ? `CHANGE FILE: ${file.name}` : 'SELECT MEDIA'}
152 </button>
153
154 <label>
155 <span class="label-text">TAGS (COMMA-SEPARATED)</span>
156 <div class="tag-wrapper">
157 <input type="text" placeholder="funny, meme, cat" bind:value={tags}
158 oninput={onTagInput}
159 onkeydown={(e) => {
160 if (e.key === 'Enter') {
161 if (sugIndex >= 0 && sugIndex < suggestions.length) {
162 e.preventDefault();
163 selectSuggestion(suggestions[sugIndex]);
164 }
165 } else if (e.key === 'Tab' || e.key === 'ArrowDown') {
166 if (showSuggestions && suggestions.length > 0) {
167 e.preventDefault();
168 sugIndex = (sugIndex + 1) % suggestions.length;
169 }
170 } else if (e.key === 'ArrowUp') {
171 if (showSuggestions && suggestions.length > 0) {
172 e.preventDefault();
173 sugIndex = sugIndex <= 0 ? suggestions.length - 1 : sugIndex - 1;
174 }
175 } else if (e.key === 'Escape') {
176 showSuggestions = false;
177 sugIndex = -1;
178 }
179 }}
180 onblur={() => setTimeout(() => { showSuggestions = false; sugIndex = -1; }, 200)}
181 disabled={loading}
182 />
183 {#if showSuggestions}
184 <div class="suggestions">
185 {#each suggestions as s, i}
186 <button type="button" class="sug-btn" class:active={i === sugIndex}
187 onmousedown={() => selectSuggestion(s)}>{s}</button>
188 {/each}
189 </div>
190 {/if}
191 </div>
192 </label>
193
194 <button type="submit" class="btn" disabled={loading || !file}>
195 {loading ? 'UPLOADING...' : 'UPLOAD'}
196 </button>
197</form>
198
199<style>
200 .page-head {
201 margin-bottom: 1.5rem;
202 }
203 .page-head h1 {
204 margin: 0;
205 }
206 .head-line {
207 height: 3px;
208 width: 3rem;
209 background: #c62828;
210 margin-top: 0.4rem;
211 }
212
213 form {
214 display: flex;
215 flex-direction: column;
216 gap: 1rem;
217 max-width: 480px;
218 }
219
220 #file-input {
221 position: absolute;
222 width: 1px;
223 height: 1px;
224 opacity: 0;
225 pointer-events: none;
226 }
227
228 .drop-overlay {
229 position: fixed;
230 inset: 0;
231 z-index: 9999;
232 display: flex;
233 align-items: center;
234 justify-content: center;
235 background: rgba(13, 13, 13, 0.88);
236 border: 3px dashed #c62828;
237 pointer-events: none;
238 }
239
240 .drop-content {
241 display: flex;
242 flex-direction: column;
243 align-items: center;
244 gap: 0.6rem;
245 pointer-events: none;
246 }
247
248 .drop-icon {
249 font-size: 4rem;
250 line-height: 1;
251 opacity: 0.9;
252 }
253
254 .drop-text {
255 font-family: 'Oswald', sans-serif;
256 font-weight: 700;
257 font-size: 1.8rem;
258 letter-spacing: 0.15em;
259 color: #c62828;
260 text-transform: uppercase;
261 }
262
263 .select-btn {
264 align-self: stretch !important;
265 }
266 .select-btn:hover {
267 background: #c62828;
268 }
269
270 .label-text {
271 font-family: 'Oswald', sans-serif;
272 font-weight: 500;
273 font-size: 0.75rem;
274 letter-spacing: 0.15em;
275 color: #888;
276 }
277
278 input[type="text"] {
279 padding: 0.6rem 0.75rem;
280 border: 2px solid #1a1a1a;
281 border-radius: 0;
282 font-family: 'Inter', sans-serif;
283 font-size: 0.9rem;
284 font-weight: 500;
285 background: #fff;
286 width: 100%;
287 }
288 input[type="text"]:focus {
289 outline: none;
290 border-color: #c62828;
291 }
292
293 .tag-wrapper {
294 position: relative;
295 }
296 .suggestions {
297 position: absolute;
298 top: 100%;
299 left: 0;
300 right: 0;
301 background: #fff;
302 border: 2px solid #1a1a1a;
303 border-top: none;
304 margin: 0;
305 padding: 0;
306 z-index: 10;
307 max-height: 150px;
308 overflow-y: auto;
309 }
310 .sug-btn {
311 display: block;
312 width: 100%;
313 text-align: left;
314 padding: 0.45rem 0.75rem;
315 border: none;
316 background: none;
317 font-family: 'Inter', sans-serif;
318 font-size: 0.85rem;
319 font-weight: 500;
320 cursor: pointer;
321 border-radius: 0;
322 }
323 .sug-btn:hover {
324 background: #f0f0f0;
325 }
326 .sug-btn.active {
327 background: #c62828;
328 color: #fff;
329 }
330
331 .btn {
332 display: inline-flex;
333 align-items: center;
334 justify-content: center;
335 padding: 0.6rem 1.5rem;
336 background: #1a1a1a;
337 color: #fff;
338 border: none;
339 font-family: 'Oswald', sans-serif;
340 font-weight: 500;
341 font-size: 0.85rem;
342 letter-spacing: 0.1em;
343 cursor: pointer;
344 text-transform: uppercase;
345 transition: background 0.15s;
346 align-self: flex-start;
347 }
348 .btn:hover {
349 background: #c62828;
350 }
351 .btn:disabled {
352 opacity: 0.4;
353 cursor: default;
354 }
355
356 .error {
357 color: #c62828;
358 font-family: 'Inter', sans-serif;
359 font-size: 0.85rem;
360 font-weight: 600;
361 }
362 .success {
363 color: #2e7d32;
364 font-family: 'Inter', sans-serif;
365 font-size: 0.85rem;
366 font-weight: 600;
367 }
368 .success a {
369 font-family: 'Oswald', sans-serif;
370 font-weight: 500;
371 font-size: 0.8rem;
372 letter-spacing: 0.1em;
373 color: #c62828;
374 }
375</style>