feat: added photo management to admin page
ac2e5ade
2 file(s) · +376 −140
| 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 | }; |
|
| 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">← Back to gallery</a> |
|
| 306 | 434 | </p> |
|
| 307 | 435 | </div> |
|