feat: added photo management to admin page ac2e5ade
Steve · 2026-01-25 16:43 2 file(s) · +376 −140
src/routes/admin/+page.server.ts +110 −2
3 3
4 4
const R2_BASE_URL = "https://r2.steve.photo";
5 5
6 -
export const load: PageServerLoad = async () => {
7 -
	return {};
6 +
type PhotoRow = {
7 +
	id: number;
8 +
	slug: string;
9 +
	title: string;
10 +
	date: string;
11 +
	image_key: string;
12 +
	thumb_key: string;
13 +
};
14 +
15 +
export const load: PageServerLoad = async ({ platform }) => {
16 +
	const db = platform?.env?.DB;
17 +
	if (!db) {
18 +
		return { photos: [] };
19 +
	}
20 +
21 +
	const result = await db
22 +
		.prepare(
23 +
			"SELECT id, slug, title, date, image_key, thumb_key FROM photos ORDER BY date DESC",
24 +
		)
25 +
		.all<PhotoRow>();
26 +
27 +
	const photos = result.results.map((row: PhotoRow) => ({
28 +
		id: row.id,
29 +
		slug: row.slug,
30 +
		title: row.title,
31 +
		date: row.date,
32 +
		thumb: `${R2_BASE_URL}/${row.thumb_key}`,
33 +
	}));
34 +
35 +
	return { photos };
8 36
};
9 37
10 38
export const actions: Actions = {
113 141
			const errorMessage = err instanceof Error ? err.message : String(err);
114 142
			console.error("Upload error:", errorMessage, err);
115 143
			return fail(500, { error: `Failed to upload photo: ${errorMessage}` });
144 +
		}
145 +
	},
146 +
147 +
	edit: async ({ request, platform, locals }) => {
148 +
		if (!locals.user?.authenticated) {
149 +
			return fail(401, { error: "Unauthorized" });
150 +
		}
151 +
152 +
		const db = platform?.env?.DB;
153 +
		if (!db) {
154 +
			return fail(500, { error: "Server configuration error" });
155 +
		}
156 +
157 +
		const formData = await request.formData();
158 +
		const id = formData.get("id") as string;
159 +
		const title = formData.get("title") as string;
160 +
161 +
		if (!id || !title) {
162 +
			return fail(400, { error: "ID and title are required" });
163 +
		}
164 +
165 +
		try {
166 +
			await db
167 +
				.prepare("UPDATE photos SET title = ? WHERE id = ?")
168 +
				.bind(title, parseInt(id, 10))
169 +
				.run();
170 +
171 +
			return { success: true };
172 +
		} catch (err) {
173 +
			const errorMessage = err instanceof Error ? err.message : String(err);
174 +
			console.error("Edit error:", errorMessage);
175 +
			return fail(500, { error: `Failed to update photo: ${errorMessage}` });
176 +
		}
177 +
	},
178 +
179 +
	delete: async ({ request, platform, locals }) => {
180 +
		if (!locals.user?.authenticated) {
181 +
			return fail(401, { error: "Unauthorized" });
182 +
		}
183 +
184 +
		const db = platform?.env?.DB;
185 +
		const bucket = platform?.env?.PHOTOS;
186 +
187 +
		if (!db || !bucket) {
188 +
			return fail(500, { error: "Server configuration error" });
189 +
		}
190 +
191 +
		const formData = await request.formData();
192 +
		const id = formData.get("id") as string;
193 +
194 +
		if (!id) {
195 +
			return fail(400, { error: "ID is required" });
196 +
		}
197 +
198 +
		try {
199 +
			// Get photo details first to delete from R2
200 +
			const photo = await db
201 +
				.prepare("SELECT image_key, thumb_key FROM photos WHERE id = ?")
202 +
				.bind(parseInt(id, 10))
203 +
				.first<{ image_key: string; thumb_key: string }>();
204 +
205 +
			if (!photo) {
206 +
				return fail(404, { error: "Photo not found" });
207 +
			}
208 +
209 +
			// Delete from R2
210 +
			await bucket.delete(photo.image_key);
211 +
			await bucket.delete(photo.thumb_key);
212 +
213 +
			// Delete from database
214 +
			await db
215 +
				.prepare("DELETE FROM photos WHERE id = ?")
216 +
				.bind(parseInt(id, 10))
217 +
				.run();
218 +
219 +
			return { success: true };
220 +
		} catch (err) {
221 +
			const errorMessage = err instanceof Error ? err.message : String(err);
222 +
			console.error("Delete error:", errorMessage);
223 +
			return fail(500, { error: `Failed to delete photo: ${errorMessage}` });
116 224
		}
117 225
	},
118 226
};
src/routes/admin/+page.svelte +266 −138
2 2
	import { enhance } from "$app/forms";
3 3
	import exifr from "exifr";
4 4
5 -
	let { form } = $props();
5 +
	let { data, form } = $props();
6 6
7 7
	let fileInput = $state<HTMLInputElement | null>(null);
8 8
	let selectedFile = $state<File | null>(null);
20 20
	let make = $state("");
21 21
22 22
	let isLoading = $state(false);
23 +
	let editingId = $state<number | null>(null);
24 +
	let editingTitle = $state("");
25 +
	let deleteConfirmId = $state<number | null>(null);
23 26
24 27
	async function handleFileSelect(event: Event) {
25 28
		const input = event.target as HTMLInputElement;
118 121
	function handleSubmit() {
119 122
		isLoading = true;
120 123
	}
124 +
125 +
	function startEdit(photo: { id: number; title: string }) {
126 +
		editingId = photo.id;
127 +
		editingTitle = photo.title;
128 +
	}
129 +
130 +
	function cancelEdit() {
131 +
		editingId = null;
132 +
		editingTitle = "";
133 +
	}
121 134
</script>
122 135
123 136
<svelte:head>
125 138
</svelte:head>
126 139
127 140
<div class="min-h-screen p-4 md:p-8">
128 -
	<div class="max-w-2xl mx-auto">
141 +
	<div class="max-w-4xl mx-auto">
129 142
		<div class="flex items-center justify-between mb-8">
130 -
			<h1 class="text-2xl font-bold">Upload Photo</h1>
143 +
			<h1 class="text-2xl font-bold">Admin</h1>
131 144
			<a
132 145
				href="/api/logout"
133 146
				class="text-sm text-zinc-400 hover:text-white transition-colors"
136 149
			</a>
137 150
		</div>
138 151
139 -
		<form
140 -
			method="POST"
141 -
			action="?/upload"
142 -
			enctype="multipart/form-data"
143 -
			use:enhance={({ formData }) => {
144 -
				handleSubmit();
145 -
				// Append thumbnail blob as file
146 -
				if (thumbnailBlob) {
147 -
					formData.append("thumbnail", thumbnailBlob, "thumbnail.jpg");
148 -
				}
149 -
				return async ({ update }) => {
150 -
					isLoading = false;
151 -
					await update();
152 -
				};
153 -
			}}
154 -
			class="space-y-6"
155 -
		>
156 -
			<div>
157 -
				<label for="file" class="block text-sm mb-2">Image</label>
158 -
				<input
159 -
					type="file"
160 -
					id="file"
161 -
					name="file"
162 -
					accept="image/jpeg,image/png,image/webp"
163 -
					required
164 -
					bind:this={fileInput}
165 -
					onchange={handleFileSelect}
166 -
					class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500 file:mr-4 file:py-1 file:px-4 file:rounded file:border-0 file:bg-zinc-700 file:text-white file:cursor-pointer"
167 -
				/>
168 -
			</div>
169 -
170 -
			{#if previewUrl}
171 -
				<div class="relative">
172 -
					<img
173 -
						src={previewUrl}
174 -
						alt="Preview"
175 -
						class="w-full max-h-64 object-contain rounded bg-zinc-900"
176 -
					/>
177 -
				</div>
178 -
			{/if}
179 -
180 -
181 -
182 -
			<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
183 -
				<div>
184 -
					<label for="title" class="block text-sm mb-2">Title *</label>
185 -
					<input
186 -
						type="text"
187 -
						id="title"
188 -
						name="title"
189 -
						required
190 -
						bind:value={title}
191 -
						class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
192 -
					/>
193 -
				</div>
152 +
		<!-- Upload Section -->
153 +
		<section>
154 +
			<h2 class="text-xl font-semibold mb-4">Upload New Photo</h2>
194 155
156 +
			<form
157 +
				method="POST"
158 +
				action="?/upload"
159 +
				enctype="multipart/form-data"
160 +
				use:enhance={({ formData }) => {
161 +
					handleSubmit();
162 +
					if (thumbnailBlob) {
163 +
						formData.append("thumbnail", thumbnailBlob, "thumbnail.jpg");
164 +
					}
165 +
					return async ({ update }) => {
166 +
						isLoading = false;
167 +
						await update();
168 +
					};
169 +
				}}
170 +
				class="space-y-6"
171 +
			>
195 172
				<div>
196 -
					<label for="date" class="block text-sm mb-2">Date *</label>
173 +
					<label for="file" class="block text-sm mb-2">Image</label>
197 174
					<input
198 -
						type="date"
199 -
						id="date"
200 -
						name="date"
175 +
						type="file"
176 +
						id="file"
177 +
						name="file"
178 +
						accept="image/jpeg,image/png,image/webp"
201 179
						required
202 -
						bind:value={date}
203 -
						class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
180 +
						bind:this={fileInput}
181 +
						onchange={handleFileSelect}
182 +
						class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500 file:mr-4 file:py-1 file:px-4 file:rounded file:border-0 file:bg-zinc-700 file:text-white file:cursor-pointer"
204 183
					/>
205 184
				</div>
206 -
			</div>
207 185
208 -
			<div class="border-t border-zinc-800 pt-6">
209 -
				<h2 class="text-lg font-medium mb-4">EXIF Data</h2>
210 -
211 -
				<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
212 -
					<div>
213 -
						<label for="make" class="block text-sm mb-2">Make</label>
214 -
						<input
215 -
							type="text"
216 -
							id="make"
217 -
							name="make"
218 -
							bind:value={make}
219 -
							class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
186 +
				{#if previewUrl}
187 +
					<div class="relative">
188 +
						<img
189 +
							src={previewUrl}
190 +
							alt="Preview"
191 +
							class="w-full max-h-64 object-contain rounded bg-zinc-900"
220 192
						/>
221 193
					</div>
194 +
				{/if}
222 195
196 +
				<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
223 197
					<div>
224 -
						<label for="camera" class="block text-sm mb-2">Camera</label>
198 +
						<label for="title" class="block text-sm mb-2">Title *</label>
225 199
						<input
226 200
							type="text"
227 -
							id="camera"
228 -
							name="camera"
229 -
							bind:value={camera}
201 +
							id="title"
202 +
							name="title"
203 +
							required
204 +
							bind:value={title}
230 205
							class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
231 206
						/>
232 207
					</div>
233 208
234 209
					<div>
235 -
						<label for="lens" class="block text-sm mb-2">Lens</label>
210 +
						<label for="date" class="block text-sm mb-2">Date *</label>
236 211
						<input
237 -
							type="text"
238 -
							id="lens"
239 -
							name="lens"
240 -
							bind:value={lens}
212 +
							type="date"
213 +
							id="date"
214 +
							name="date"
215 +
							required
216 +
							bind:value={date}
241 217
							class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
242 218
						/>
243 219
					</div>
220 +
				</div>
244 221
245 -
					<div>
246 -
						<label for="aperture" class="block text-sm mb-2">Aperture</label>
247 -
						<input
248 -
							type="text"
249 -
							id="aperture"
250 -
							name="aperture"
251 -
							bind:value={aperture}
252 -
							class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
253 -
						/>
254 -
					</div>
222 +
				<div class="border-t border-zinc-800 pt-6">
223 +
					<h3 class="text-lg font-medium mb-4">EXIF Data</h3>
255 224
256 -
					<div>
257 -
						<label for="exposure" class="block text-sm mb-2">Exposure</label>
258 -
						<input
259 -
							type="text"
260 -
							id="exposure"
261 -
							name="exposure"
262 -
							bind:value={exposure}
263 -
							class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
264 -
						/>
265 -
					</div>
225 +
					<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
226 +
						<div>
227 +
							<label for="make" class="block text-sm mb-2">Make</label>
228 +
							<input
229 +
								type="text"
230 +
								id="make"
231 +
								name="make"
232 +
								bind:value={make}
233 +
								class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
234 +
							/>
235 +
						</div>
266 236
267 -
					<div>
268 -
						<label for="focalLength" class="block text-sm mb-2">Focal Length</label>
269 -
						<input
270 -
							type="text"
271 -
							id="focalLength"
272 -
							name="focalLength"
273 -
							bind:value={focalLength}
274 -
							class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
275 -
						/>
276 -
					</div>
237 +
						<div>
238 +
							<label for="camera" class="block text-sm mb-2">Camera</label>
239 +
							<input
240 +
								type="text"
241 +
								id="camera"
242 +
								name="camera"
243 +
								bind:value={camera}
244 +
								class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
245 +
							/>
246 +
						</div>
277 247
278 -
					<div>
279 -
						<label for="iso" class="block text-sm mb-2">ISO</label>
280 -
						<input
281 -
							type="text"
282 -
							id="iso"
283 -
							name="iso"
284 -
							bind:value={iso}
285 -
							class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
286 -
						/>
248 +
						<div>
249 +
							<label for="lens" class="block text-sm mb-2">Lens</label>
250 +
							<input
251 +
								type="text"
252 +
								id="lens"
253 +
								name="lens"
254 +
								bind:value={lens}
255 +
								class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
256 +
							/>
257 +
						</div>
258 +
259 +
						<div>
260 +
							<label for="aperture" class="block text-sm mb-2">Aperture</label>
261 +
							<input
262 +
								type="text"
263 +
								id="aperture"
264 +
								name="aperture"
265 +
								bind:value={aperture}
266 +
								class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
267 +
							/>
268 +
						</div>
269 +
270 +
						<div>
271 +
							<label for="exposure" class="block text-sm mb-2">Exposure</label>
272 +
							<input
273 +
								type="text"
274 +
								id="exposure"
275 +
								name="exposure"
276 +
								bind:value={exposure}
277 +
								class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
278 +
							/>
279 +
						</div>
280 +
281 +
						<div>
282 +
							<label for="focalLength" class="block text-sm mb-2">Focal Length</label>
283 +
							<input
284 +
								type="text"
285 +
								id="focalLength"
286 +
								name="focalLength"
287 +
								bind:value={focalLength}
288 +
								class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
289 +
							/>
290 +
						</div>
291 +
292 +
						<div>
293 +
							<label for="iso" class="block text-sm mb-2">ISO</label>
294 +
							<input
295 +
								type="text"
296 +
								id="iso"
297 +
								name="iso"
298 +
								bind:value={iso}
299 +
								class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500"
300 +
							/>
301 +
						</div>
287 302
					</div>
288 303
				</div>
289 -
			</div>
290 304
291 -
			{#if form?.error}
292 -
				<p class="text-red-500 text-sm">{form.error}</p>
293 -
			{/if}
305 +
				{#if form?.error}
306 +
					<p class="text-red-500 text-sm">{form.error}</p>
307 +
				{/if}
294 308
295 -
			<button
296 -
				type="submit"
297 -
				disabled={isLoading || !selectedFile}
298 -
				class="w-full py-3 bg-white text-black font-medium rounded hover:bg-zinc-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
299 -
			>
300 -
				{isLoading ? "Uploading..." : "Upload Photo"}
301 -
			</button>
302 -
		</form>
309 +
				<button
310 +
					type="submit"
311 +
					disabled={isLoading || !selectedFile}
312 +
					class="w-full py-3 bg-white text-black font-medium rounded hover:bg-zinc-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
313 +
				>
314 +
					{isLoading ? "Uploading..." : "Upload Photo"}
315 +
				</button>
316 +
			</form>
317 +
		</section>
303 318
304 -
		<p class="mt-6 text-center text-sm text-zinc-500">
319 +
320 +
		<!-- Existing Photos Section -->
321 +
		{#if data.photos.length > 0}
322 +
			<section class="mb-12">
323 +
				<h2 class="text-xl font-semibold mb-4">Photos ({data.photos.length})</h2>
324 +
				<div class="space-y-2">
325 +
					{#each data.photos as photo (photo.id)}
326 +
						<div class="flex items-center gap-4 p-3 bg-zinc-900 rounded-lg">
327 +
							<a href="/photo/{photo.slug}" class="shrink-0">
328 +
								<img
329 +
									src={photo.thumb}
330 +
									alt={photo.title}
331 +
									class="w-16 h-16 object-cover rounded"
332 +
								/>
333 +
							</a>
334 +
335 +
							<div class="flex-1 min-w-0">
336 +
								{#if editingId === photo.id}
337 +
									<form
338 +
										method="POST"
339 +
										action="?/edit"
340 +
										use:enhance={() => {
341 +
											return async ({ update }) => {
342 +
												editingId = null;
343 +
												await update();
344 +
											};
345 +
										}}
346 +
										class="flex items-center gap-2"
347 +
									>
348 +
										<input type="hidden" name="id" value={photo.id} />
349 +
										<input
350 +
											type="text"
351 +
											name="title"
352 +
											bind:value={editingTitle}
353 +
											class="flex-1 px-3 py-1 bg-zinc-800 border border-zinc-600 rounded text-sm focus:outline-none focus:border-zinc-500"
354 +
										/>
355 +
										<button
356 +
											type="submit"
357 +
											class="px-3 py-1 bg-white text-black text-sm rounded hover:bg-zinc-200"
358 +
										>
359 +
											Save
360 +
										</button>
361 +
										<button
362 +
											type="button"
363 +
											onclick={cancelEdit}
364 +
											class="px-3 py-1 text-sm text-zinc-400 hover:text-white"
365 +
										>
366 +
											Cancel
367 +
										</button>
368 +
									</form>
369 +
								{:else}
370 +
									<a href="/photo/{photo.slug}" class="block">
371 +
										<p class="font-medium truncate">{photo.title}</p>
372 +
										<p class="text-sm text-zinc-500">{photo.date}</p>
373 +
									</a>
374 +
								{/if}
375 +
							</div>
376 +
377 +
							{#if editingId !== photo.id}
378 +
								<div class="flex items-center gap-2 shrink-0">
379 +
									<button
380 +
										type="button"
381 +
										onclick={() => startEdit(photo)}
382 +
										class="px-3 py-1 text-sm text-zinc-400 hover:text-white transition-colors"
383 +
									>
384 +
										Edit
385 +
									</button>
386 +
387 +
									{#if deleteConfirmId === photo.id}
388 +
										<form
389 +
											method="POST"
390 +
											action="?/delete"
391 +
											use:enhance={() => {
392 +
												return async ({ update }) => {
393 +
													deleteConfirmId = null;
394 +
													await update();
395 +
												};
396 +
											}}
397 +
											class="flex items-center gap-1"
398 +
										>
399 +
											<input type="hidden" name="id" value={photo.id} />
400 +
											<button
401 +
												type="submit"
402 +
												class="px-3 py-1 bg-red-600 text-white text-sm rounded hover:bg-red-700"
403 +
											>
404 +
												Confirm
405 +
											</button>
406 +
											<button
407 +
												type="button"
408 +
												onclick={() => deleteConfirmId = null}
409 +
												class="px-3 py-1 text-sm text-zinc-400 hover:text-white"
410 +
											>
411 +
												Cancel
412 +
											</button>
413 +
										</form>
414 +
									{:else}
415 +
										<button
416 +
											type="button"
417 +
											onclick={() => deleteConfirmId = photo.id}
418 +
											class="px-3 py-1 text-sm text-red-400 hover:text-red-300 transition-colors"
419 +
										>
420 +
											Delete
421 +
										</button>
422 +
									{/if}
423 +
								</div>
424 +
							{/if}
425 +
						</div>
426 +
					{/each}
427 +
				</div>
428 +
			</section>
429 +
		{/if}
430 +
431 +
432 +
		<p class="mt-8 text-center text-sm text-zinc-500">
305 433
			<a href="/" class="hover:text-white transition-colors">&larr; Back to gallery</a>
306 434
		</p>
307 435
	</div>