all repos — weimar @ master

Unnamed repository; edit this file 'description' to name the repository.

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">&#x2B07;</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>