src/routes/admin/+page.svelte 10.7 K raw
1
<script lang="ts">
2
import { enhance } from "$app/forms";
3
import exifr from "exifr";
4
5
let { data, form } = $props();
6
7
let fileInput = $state<HTMLInputElement | null>(null);
8
let selectedFile = $state<File | null>(null);
9
let previewUrl = $state<string | null>(null);
10
let thumbnailBlob = $state<Blob | null>(null);
11
let blurData = $state<string>("");
12
13
let title = $state("");
14
let date = $state("");
15
let camera = $state("");
16
let lens = $state("");
17
let aperture = $state("");
18
let exposure = $state("");
19
let focalLength = $state("");
20
let iso = $state("");
21
let make = $state("");
22
23
let isLoading = $state(false);
24
let editingId = $state<number | null>(null);
25
let editingTitle = $state("");
26
let deleteConfirmId = $state<number | null>(null);
27
28
async function handleFileSelect(event: Event) {
29
	const input = event.target as HTMLInputElement;
30
	const file = input.files?.[0];
31
	if (!file) return;
32
33
	selectedFile = file;
34
	previewUrl = URL.createObjectURL(file);
35
36
	// Auto-populate title from filename
37
	const nameWithoutExt = file.name.replace(/\.[^/.]+$/, "");
38
	title = nameWithoutExt.replace(/[-_]/g, " ");
39
40
	// Extract EXIF data
41
	try {
42
		const exif = await exifr.parse(file, {
43
			pick: [
44
				"DateTimeOriginal",
45
				"Model",
46
				"LensModel",
47
				"FNumber",
48
				"ExposureTime",
49
				"FocalLength",
50
				"ISO",
51
				"Make",
52
			],
53
		});
54
55
		if (exif) {
56
			if (exif.DateTimeOriginal) {
57
				const d = new Date(exif.DateTimeOriginal);
58
				date = d.toISOString().split("T")[0];
59
			}
60
			if (exif.Model) camera = exif.Model;
61
			if (exif.LensModel) lens = exif.LensModel;
62
			if (exif.FNumber) aperture = `f/${exif.FNumber}`;
63
			if (exif.ExposureTime) {
64
				exposure =
65
					exif.ExposureTime < 1
66
						? `1/${Math.round(1 / exif.ExposureTime)}s`
67
						: `${exif.ExposureTime}s`;
68
			}
69
			if (exif.FocalLength) focalLength = `${exif.FocalLength}mm`;
70
			if (exif.ISO) iso = String(exif.ISO);
71
			if (exif.Make) make = exif.Make;
72
		}
73
	} catch (err) {
74
		console.error("Failed to read EXIF data:", err);
75
	}
76
77
	// Generate thumbnail and blur placeholder
78
	await generateThumbnail(file);
79
	await generateBlurData(file);
80
}
81
82
async function generateThumbnail(file: File): Promise<void> {
83
	return new Promise((resolve) => {
84
		const img = new Image();
85
		img.onload = () => {
86
			const maxSize = 400;
87
			let width = img.width;
88
			let height = img.height;
89
90
			if (width > height) {
91
				if (width > maxSize) {
92
					height = (height * maxSize) / width;
93
					width = maxSize;
94
				}
95
			} else {
96
				if (height > maxSize) {
97
					width = (width * maxSize) / height;
98
					height = maxSize;
99
				}
100
			}
101
102
			const canvas = document.createElement("canvas");
103
			canvas.width = width;
104
			canvas.height = height;
105
106
			const ctx = canvas.getContext("2d");
107
			if (ctx) {
108
				ctx.drawImage(img, 0, 0, width, height);
109
				canvas.toBlob(
110
					(blob) => {
111
						thumbnailBlob = blob;
112
						resolve();
113
					},
114
					"image/jpeg",
115
					0.8,
116
				);
117
			} else {
118
				resolve();
119
			}
120
		};
121
		img.src = URL.createObjectURL(file);
122
	});
123
}
124
125
async function generateBlurData(file: File): Promise<void> {
126
	return new Promise((resolve) => {
127
		const img = new Image();
128
		img.onload = () => {
129
			const targetWidth = 20;
130
			const aspectRatio = img.height / img.width;
131
			const targetHeight = Math.round(targetWidth * aspectRatio);
132
133
			const canvas = document.createElement("canvas");
134
			canvas.width = targetWidth;
135
			canvas.height = targetHeight;
136
137
			const ctx = canvas.getContext("2d");
138
			if (ctx) {
139
				ctx.drawImage(img, 0, 0, targetWidth, targetHeight);
140
				blurData = canvas.toDataURL("image/jpeg", 0.3);
141
			}
142
			resolve();
143
		};
144
		img.src = URL.createObjectURL(file);
145
	});
146
}
147
148
function handleSubmit() {
149
	isLoading = true;
150
}
151
152
function startEdit(photo: { id: number; title: string }) {
153
	editingId = photo.id;
154
	editingTitle = photo.title;
155
}
156
157
function cancelEdit() {
158
	editingId = null;
159
	editingTitle = "";
160
}
161
162
function resetUploadForm() {
163
	selectedFile = null;
164
	if (previewUrl) {
165
		URL.revokeObjectURL(previewUrl);
166
		previewUrl = null;
167
	}
168
	thumbnailBlob = null;
169
	blurData = "";
170
	title = "";
171
	date = "";
172
	camera = "";
173
	lens = "";
174
	aperture = "";
175
	exposure = "";
176
	focalLength = "";
177
	iso = "";
178
	make = "";
179
	if (fileInput) {
180
		fileInput.value = "";
181
	}
182
}
183
</script>
184
185
<svelte:head>
186
	<title>Admin</title>
187
</svelte:head>
188
189
190
191
<div class="min-h-screen p-4 md:p-8">
192
	<div class="max-w-4xl mx-auto space-y-4">
193
		<div class="flex items-center justify-between mb-8">
194
			<a href="/" class="hover:text-white transition-colors text-sm text-zinc-400">&larr; Back to gallery</a>
195
			<a
196
				href="/api/logout"
197
				class="text-sm text-zinc-400 hover:text-white transition-colors"
198
			>
199
				Logout
200
			</a>
201
		</div>
202
203
		<!-- Upload Section -->
204
		<section>
205
			<h2 class="text-lg font-semibold mb-3">Upload New Photo</h2>
206
207
			<form
208
				method="POST"
209
				action="?/upload"
210
				enctype="multipart/form-data"
211
				use:enhance={({ formData }) => {
212
					handleSubmit();
213
					if (thumbnailBlob) {
214
						formData.append("thumbnail", thumbnailBlob, "thumbnail.jpg");
215
					}
216
					return async ({ result, update }) => {
217
						isLoading = false;
218
						if (result.type === "success") {
219
							resetUploadForm();
220
						}
221
						await update();
222
					};
223
				}}
224
				class="space-y-4"
225
			>
226
				<div>
227
					<input
228
						type="file"
229
						id="file"
230
						name="file"
231
						accept="image/jpeg,image/png,image/webp"
232
						required
233
						bind:this={fileInput}
234
						onchange={handleFileSelect}
235
						class="w-full text-sm bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:bg-zinc-700 file:text-white file:text-sm file:cursor-pointer"
236
					/>
237
				</div>
238
239
				{#if previewUrl}
240
					<div class="flex gap-4">
241
						<img
242
							src={previewUrl}
243
							alt="Preview"
244
							class="w-32 h-32 object-cover rounded bg-zinc-900 shrink-0"
245
						/>
246
						<div class="flex-1 space-y-3">
247
							<div>
248
								<label for="title" class="block text-xs text-zinc-400 mb-1">Title</label>
249
								<input
250
									type="text"
251
									id="title"
252
									name="title"
253
									required
254
									bind:value={title}
255
									class="w-full px-3 py-1.5 text-sm bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
256
								/>
257
							</div>
258
259
							{#if date || camera || lens}
260
								<div class="text-xs text-zinc-500 space-y-0.5">
261
									{#if date}<p>{date}</p>{/if}
262
									{#if make || camera}<p>{[make, camera].filter(Boolean).join(" ")}</p>{/if}
263
									{#if lens}<p>{lens}</p>{/if}
264
									{#if aperture || exposure || focalLength || iso}
265
										<p>{[aperture, exposure, focalLength, iso ? `ISO ${iso}` : ""].filter(Boolean).join(" | ")}</p>
266
									{/if}
267
								</div>
268
							{/if}
269
270
							<!-- Hidden inputs for form submission -->
271
							<input type="hidden" name="date" value={date} />
272
							<input type="hidden" name="make" value={make} />
273
							<input type="hidden" name="camera" value={camera} />
274
							<input type="hidden" name="lens" value={lens} />
275
							<input type="hidden" name="aperture" value={aperture} />
276
							<input type="hidden" name="exposure" value={exposure} />
277
							<input type="hidden" name="focalLength" value={focalLength} />
278
							<input type="hidden" name="iso" value={iso} />
279
							<input type="hidden" name="blur_data" value={blurData} />
280
						</div>
281
					</div>
282
				{/if}
283
284
				{#if form?.error}
285
					<p class="text-red-500 text-xs">{form.error}</p>
286
				{/if}
287
288
				<button
289
					type="submit"
290
					disabled={isLoading || !selectedFile}
291
					class="w-full py-2 text-sm bg-white text-black font-medium rounded hover:bg-zinc-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
292
				>
293
					{isLoading ? "Uploading..." : "Upload Photo"}
294
				</button>
295
			</form>
296
		</section>
297
298
299
		<!-- Existing Photos Section -->
300
		{#if data.photos.length > 0}
301
			<section class="mb-12">
302
				<h2 class="text-xl font-semibold mb-4">Photos ({data.photos.length})</h2>
303
				<div class="space-y-2">
304
					{#each data.photos as photo (photo.id)}
305
						<div class="flex items-center gap-4 p-3 bg-zinc-900 rounded-lg">
306
							<a href="/photo/{photo.slug}" class="shrink-0">
307
								<img
308
									src={photo.thumb}
309
									alt={photo.title}
310
									class="w-16 h-16 object-cover rounded"
311
								/>
312
							</a>
313
314
							<div class="flex-1 min-w-0">
315
								{#if editingId === photo.id}
316
									<form
317
										method="POST"
318
										action="?/edit"
319
										use:enhance={() => {
320
											return async ({ update }) => {
321
												editingId = null;
322
												await update();
323
											};
324
										}}
325
										class="flex items-center gap-2"
326
									>
327
										<input type="hidden" name="id" value={photo.id} />
328
										<input
329
											type="text"
330
											name="title"
331
											bind:value={editingTitle}
332
											class="flex-1 px-3 py-1 bg-zinc-800 border border-zinc-600 rounded text-sm focus:outline-none focus:border-zinc-500"
333
										/>
334
										<button
335
											type="submit"
336
											class="px-3 py-1 bg-white text-black text-sm rounded hover:bg-zinc-200"
337
										>
338
											Save
339
										</button>
340
										<button
341
											type="button"
342
											onclick={cancelEdit}
343
											class="px-3 py-1 text-sm text-zinc-400 hover:text-white"
344
										>
345
											Cancel
346
										</button>
347
									</form>
348
								{:else}
349
									<a href="/photo/{photo.slug}" class="block">
350
										<p class="font-medium truncate">{photo.title}</p>
351
										<p class="text-sm text-zinc-500">{photo.date}</p>
352
									</a>
353
								{/if}
354
							</div>
355
356
							{#if editingId !== photo.id}
357
								<div class="flex items-center gap-2 shrink-0">
358
									<button
359
										type="button"
360
										onclick={() => startEdit(photo)}
361
										class="px-3 py-1 text-sm text-zinc-400 hover:text-white transition-colors"
362
									>
363
										Edit
364
									</button>
365
366
									{#if deleteConfirmId === photo.id}
367
										<form
368
											method="POST"
369
											action="?/delete"
370
											use:enhance={() => {
371
												return async ({ update }) => {
372
													deleteConfirmId = null;
373
													await update();
374
												};
375
											}}
376
											class="flex items-center gap-1"
377
										>
378
											<input type="hidden" name="id" value={photo.id} />
379
											<button
380
												type="submit"
381
												class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700"
382
											>
383
												Confirm
384
											</button>
385
											<button
386
												type="button"
387
												onclick={() => deleteConfirmId = null}
388
												class="px-3 py-1 text-sm text-zinc-400 hover:text-white"
389
											>
390
												Cancel
391
											</button>
392
										</form>
393
									{:else}
394
										<button
395
											type="button"
396
											onclick={() => deleteConfirmId = photo.id}
397
											class="px-3 py-1 text-sm text-red-400 hover:text-red-300 transition-colors"
398
										>
399
											Delete
400
										</button>
401
									{/if}
402
								</div>
403
							{/if}
404
						</div>
405
					{/each}
406
				</div>
407
			</section>
408
		{/if}
409
410
411
	</div>
412
</div>