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