@jant/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/bin/jant.js +188 -0
  2. package/drizzle.config.ts +10 -0
  3. package/lingui.config.ts +16 -0
  4. package/package.json +116 -0
  5. package/src/app.tsx +377 -0
  6. package/src/assets/datastar.min.js +8 -0
  7. package/src/auth.ts +38 -0
  8. package/src/client.ts +6 -0
  9. package/src/db/index.ts +14 -0
  10. package/src/db/migrations/0000_solid_moon_knight.sql +118 -0
  11. package/src/db/migrations/0001_add_search_fts.sql +40 -0
  12. package/src/db/migrations/0002_collection_path.sql +2 -0
  13. package/src/db/migrations/0003_collection_path_nullable.sql +21 -0
  14. package/src/db/migrations/0004_media_uuid.sql +35 -0
  15. package/src/db/migrations/meta/0000_snapshot.json +784 -0
  16. package/src/db/migrations/meta/_journal.json +41 -0
  17. package/src/db/schema.ts +159 -0
  18. package/src/i18n/EXAMPLES.md +235 -0
  19. package/src/i18n/README.md +296 -0
  20. package/src/i18n/Trans.tsx +31 -0
  21. package/src/i18n/context.tsx +101 -0
  22. package/src/i18n/detect.ts +100 -0
  23. package/src/i18n/i18n.ts +62 -0
  24. package/src/i18n/index.ts +65 -0
  25. package/src/i18n/locales/en.po +875 -0
  26. package/src/i18n/locales/en.ts +1 -0
  27. package/src/i18n/locales/zh-Hans.po +875 -0
  28. package/src/i18n/locales/zh-Hans.ts +1 -0
  29. package/src/i18n/locales/zh-Hant.po +875 -0
  30. package/src/i18n/locales/zh-Hant.ts +1 -0
  31. package/src/i18n/locales.ts +14 -0
  32. package/src/i18n/middleware.ts +59 -0
  33. package/src/index.ts +42 -0
  34. package/src/lib/assets.ts +47 -0
  35. package/src/lib/constants.ts +67 -0
  36. package/src/lib/image.ts +107 -0
  37. package/src/lib/index.ts +9 -0
  38. package/src/lib/markdown.ts +93 -0
  39. package/src/lib/schemas.ts +92 -0
  40. package/src/lib/sqid.ts +79 -0
  41. package/src/lib/sse.ts +152 -0
  42. package/src/lib/time.ts +117 -0
  43. package/src/lib/url.ts +107 -0
  44. package/src/middleware/auth.ts +59 -0
  45. package/src/routes/api/posts.ts +127 -0
  46. package/src/routes/api/search.ts +53 -0
  47. package/src/routes/api/upload.ts +240 -0
  48. package/src/routes/dash/collections.tsx +341 -0
  49. package/src/routes/dash/index.tsx +89 -0
  50. package/src/routes/dash/media.tsx +551 -0
  51. package/src/routes/dash/pages.tsx +245 -0
  52. package/src/routes/dash/posts.tsx +202 -0
  53. package/src/routes/dash/redirects.tsx +155 -0
  54. package/src/routes/dash/settings.tsx +93 -0
  55. package/src/routes/feed/rss.ts +119 -0
  56. package/src/routes/feed/sitemap.ts +75 -0
  57. package/src/routes/pages/archive.tsx +223 -0
  58. package/src/routes/pages/collection.tsx +79 -0
  59. package/src/routes/pages/home.tsx +93 -0
  60. package/src/routes/pages/page.tsx +64 -0
  61. package/src/routes/pages/post.tsx +81 -0
  62. package/src/routes/pages/search.tsx +162 -0
  63. package/src/services/collection.ts +180 -0
  64. package/src/services/index.ts +40 -0
  65. package/src/services/media.ts +97 -0
  66. package/src/services/post.ts +279 -0
  67. package/src/services/redirect.ts +74 -0
  68. package/src/services/search.ts +117 -0
  69. package/src/services/settings.ts +76 -0
  70. package/src/theme/components/ActionButtons.tsx +98 -0
  71. package/src/theme/components/CrudPageHeader.tsx +48 -0
  72. package/src/theme/components/DangerZone.tsx +77 -0
  73. package/src/theme/components/EmptyState.tsx +56 -0
  74. package/src/theme/components/ListItemRow.tsx +24 -0
  75. package/src/theme/components/PageForm.tsx +114 -0
  76. package/src/theme/components/Pagination.tsx +196 -0
  77. package/src/theme/components/PostForm.tsx +122 -0
  78. package/src/theme/components/PostList.tsx +68 -0
  79. package/src/theme/components/ThreadView.tsx +118 -0
  80. package/src/theme/components/TypeBadge.tsx +28 -0
  81. package/src/theme/components/VisibilityBadge.tsx +33 -0
  82. package/src/theme/components/index.ts +12 -0
  83. package/src/theme/index.ts +24 -0
  84. package/src/theme/layouts/BaseLayout.tsx +49 -0
  85. package/src/theme/layouts/DashLayout.tsx +108 -0
  86. package/src/theme/layouts/index.ts +2 -0
  87. package/src/theme/styles/main.css +52 -0
  88. package/src/types.ts +222 -0
  89. package/static/assets/datastar.min.js +7 -0
  90. package/static/assets/image-processor.js +234 -0
  91. package/tsconfig.json +16 -0
  92. package/vite.config.ts +82 -0
  93. package/wrangler.toml +21 -0
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Dashboard Media Routes
3
+ *
4
+ * Media management with Datastar-powered uploads.
5
+ * Uses SSE for real-time UI updates without page reloads.
6
+ */
7
+
8
+ import { Hono } from "hono";
9
+ import { useLingui } from "../../i18n/index.js";
10
+ import type { Bindings, Media } from "../../types.js";
11
+ import type { AppVariables } from "../../app.js";
12
+ import { DashLayout } from "../../theme/layouts/index.js";
13
+ import { EmptyState, DangerZone } from "../../theme/components/index.js";
14
+ import * as time from "../../lib/time.js";
15
+ import { getMediaUrl, getImageUrl } from "../../lib/image.js";
16
+ import { getAssets } from "../../lib/assets.js";
17
+
18
+ type Env = { Bindings: Bindings; Variables: AppVariables };
19
+
20
+ export const mediaRoutes = new Hono<Env>();
21
+
22
+ /**
23
+ * Format file size for display
24
+ */
25
+ function formatSize(bytes: number): string {
26
+ if (bytes < 1024) return `${bytes} B`;
27
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
28
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
29
+ }
30
+
31
+ /**
32
+ * Media card component for the grid
33
+ */
34
+ function MediaCard({
35
+ media,
36
+ r2PublicUrl,
37
+ imageTransformUrl,
38
+ }: {
39
+ media: Media;
40
+ r2PublicUrl?: string;
41
+ imageTransformUrl?: string;
42
+ }) {
43
+ const fullUrl = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
44
+ const thumbnailUrl = getImageUrl(fullUrl, imageTransformUrl, {
45
+ width: 300,
46
+ quality: 80,
47
+ format: "auto",
48
+ fit: "cover",
49
+ });
50
+ const isImage = media.mimeType.startsWith("image/");
51
+
52
+ return (
53
+ <div class="group relative" data-media-id={media.id}>
54
+ {isImage ? (
55
+ <button
56
+ type="button"
57
+ class="block w-full aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary cursor-pointer"
58
+ onclick={`document.getElementById('lightbox-img').src = '${fullUrl}'; document.getElementById('lightbox').showModal()`}
59
+ >
60
+ <img
61
+ src={thumbnailUrl}
62
+ alt={media.alt || media.originalName}
63
+ class="w-full h-full object-cover"
64
+ loading="lazy"
65
+ />
66
+ </button>
67
+ ) : (
68
+ <a
69
+ href={`/dash/media/${media.id}`}
70
+ class="block aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary"
71
+ >
72
+ <div class="w-full h-full flex items-center justify-center text-muted-foreground">
73
+ <span class="text-xs">{media.mimeType}</span>
74
+ </div>
75
+ </a>
76
+ )}
77
+ <a
78
+ href={`/dash/media/${media.id}`}
79
+ class="block mt-2 text-xs truncate hover:underline"
80
+ title={media.originalName}
81
+ >
82
+ {media.originalName}
83
+ </a>
84
+ <div class="text-xs text-muted-foreground">{formatSize(media.size)}</div>
85
+ </div>
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Media list page content
91
+ *
92
+ * Uses plain JavaScript for upload state management (more reliable than Datastar signals
93
+ * for complex async flows like file uploads with SSE responses).
94
+ */
95
+ function MediaListContent({
96
+ mediaList,
97
+ r2PublicUrl,
98
+ imageTransformUrl,
99
+ imageProcessorUrl,
100
+ }: {
101
+ mediaList: Media[];
102
+ r2PublicUrl?: string;
103
+ imageTransformUrl?: string;
104
+ imageProcessorUrl: string;
105
+ }) {
106
+ const { t } = useLingui();
107
+
108
+ const processingText = t({ message: "Processing...", comment: "@context: Upload status - processing" });
109
+ const uploadingText = t({ message: "Uploading...", comment: "@context: Upload status - uploading" });
110
+ const uploadText = t({ message: "Upload", comment: "@context: Button to upload media file" });
111
+ const errorText = t({ message: "Upload failed. Please try again.", comment: "@context: Upload error message" });
112
+
113
+ // Plain JavaScript upload handler - shows progress in the list
114
+ const uploadScript = `
115
+ async function handleMediaUpload(input) {
116
+ if (!input.files || !input.files[0]) return;
117
+
118
+ const file = input.files[0];
119
+ const errorBox = document.getElementById('upload-error');
120
+ errorBox.classList.add('hidden');
121
+
122
+ // Ensure grid exists (remove empty state if needed)
123
+ let grid = document.getElementById('media-grid');
124
+ if (!grid) {
125
+ document.getElementById('empty-state')?.remove();
126
+ grid = document.createElement('div');
127
+ grid.id = 'media-grid';
128
+ grid.className = 'grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4';
129
+ document.getElementById('media-content').appendChild(grid);
130
+ }
131
+
132
+ // Create placeholder card showing progress
133
+ const placeholder = document.createElement('div');
134
+ placeholder.id = 'upload-placeholder';
135
+ placeholder.className = 'group relative';
136
+ placeholder.innerHTML = \`
137
+ <div class="aspect-square bg-muted rounded-lg overflow-hidden border flex items-center justify-center">
138
+ <div class="text-center px-2">
139
+ <svg class="animate-spin h-6 w-6 text-muted-foreground mx-auto mb-2" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
140
+ <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
141
+ <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
142
+ </svg>
143
+ <span id="upload-status" class="text-xs text-muted-foreground">${processingText}</span>
144
+ </div>
145
+ </div>
146
+ <div class="mt-2 text-xs truncate" title="\${file.name}">\${file.name}</div>
147
+ <div class="text-xs text-muted-foreground">\${formatFileSize(file.size)}</div>
148
+ \`;
149
+ grid.prepend(placeholder);
150
+
151
+ try {
152
+ if (typeof ImageProcessor === 'undefined') {
153
+ throw new Error('ImageProcessor not loaded');
154
+ }
155
+
156
+ // Process image client-side
157
+ const processed = await ImageProcessor.processToFile(file);
158
+ document.getElementById('upload-status').textContent = '${uploadingText}';
159
+
160
+ // Upload with SSE response
161
+ const fd = new FormData();
162
+ fd.append('file', processed);
163
+
164
+ const response = await fetch('/api/upload', {
165
+ method: 'POST',
166
+ body: fd,
167
+ headers: { 'Accept': 'text/event-stream' }
168
+ });
169
+
170
+ if (!response.ok) throw new Error('Upload failed: ' + response.status);
171
+
172
+ // Parse SSE stream - will replace placeholder with real card
173
+ const reader = response.body.getReader();
174
+ const decoder = new TextDecoder();
175
+ let buffer = '';
176
+
177
+ while (true) {
178
+ const { done, value } = await reader.read();
179
+ if (done) break;
180
+
181
+ buffer += decoder.decode(value, { stream: true });
182
+ const events = buffer.split('\\n\\n');
183
+ buffer = events.pop() || '';
184
+
185
+ for (const event of events) {
186
+ if (!event.trim()) continue;
187
+ processSSEEvent(event);
188
+ }
189
+ }
190
+
191
+ } catch (err) {
192
+ console.error('Upload error:', err);
193
+ // Show error in placeholder
194
+ placeholder.innerHTML = \`
195
+ <div class="aspect-square bg-destructive/10 rounded-lg overflow-hidden border border-destructive flex items-center justify-center">
196
+ <div class="text-center px-2">
197
+ <span class="text-xs text-destructive">\${err.message || '${errorText}'}</span>
198
+ </div>
199
+ </div>
200
+ <div class="mt-2 text-xs truncate text-destructive">\${file.name}</div>
201
+ <button type="button" class="text-xs text-muted-foreground hover:underline" onclick="this.closest('.group').remove()">Remove</button>
202
+ \`;
203
+ }
204
+
205
+ input.value = '';
206
+ }
207
+
208
+ function formatFileSize(bytes) {
209
+ if (bytes < 1024) return bytes + ' B';
210
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
211
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
212
+ }
213
+
214
+ function processSSEEvent(event) {
215
+ const lines = event.split('\\n');
216
+ let eventType = '';
217
+ const data = {};
218
+ let elementsLines = [];
219
+ let inElements = false;
220
+
221
+ for (const line of lines) {
222
+ if (line.startsWith('event: ')) {
223
+ eventType = line.slice(7);
224
+ } else if (line.startsWith('data: ')) {
225
+ const content = line.slice(6);
226
+ if (content.startsWith('mode ')) {
227
+ data.mode = content.slice(5);
228
+ inElements = false;
229
+ } else if (content.startsWith('selector ')) {
230
+ data.selector = content.slice(9);
231
+ inElements = false;
232
+ } else if (content.startsWith('elements ')) {
233
+ elementsLines = [content.slice(9)];
234
+ inElements = true;
235
+ } else if (inElements) {
236
+ // Continuation of elements content
237
+ elementsLines.push(content);
238
+ }
239
+ }
240
+ }
241
+
242
+ if (elementsLines.length > 0) {
243
+ data.elements = elementsLines.join('\\n');
244
+ }
245
+
246
+ if (eventType === 'datastar-patch-elements') {
247
+ if (data.mode === 'remove' && data.selector) {
248
+ document.querySelector(data.selector)?.remove();
249
+ } else if (data.mode === 'outer' && data.selector && data.elements) {
250
+ // Replace element entirely (used for placeholder -> real card)
251
+ const target = document.querySelector(data.selector);
252
+ if (target) {
253
+ const temp = document.createElement('div');
254
+ temp.innerHTML = data.elements;
255
+ const newElement = temp.firstElementChild;
256
+ if (newElement) {
257
+ target.replaceWith(newElement);
258
+ if (window.Datastar) Datastar.apply(newElement);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ }
264
+ `.trim();
265
+
266
+ return (
267
+ <>
268
+ {/* Scripts - script tags need closing tag, self-closing doesn't work in HTML */}
269
+ <script src={imageProcessorUrl}></script>
270
+ <script dangerouslySetInnerHTML={{ __html: uploadScript }}></script>
271
+
272
+ {/* Header */}
273
+ <div class="flex items-center justify-between mb-6">
274
+ <h1 class="text-2xl font-semibold">
275
+ {t({ message: "Media", comment: "@context: Media main heading" })}
276
+ </h1>
277
+ <label class="btn cursor-pointer">
278
+ <span>{uploadText}</span>
279
+ <input
280
+ type="file"
281
+ class="hidden"
282
+ accept="image/*"
283
+ onchange="handleMediaUpload(this)"
284
+ />
285
+ </label>
286
+ </div>
287
+
288
+ {/* Hidden error container for global errors */}
289
+ <div id="upload-error" class="hidden"></div>
290
+
291
+ {/* Upload instructions */}
292
+ <div class="card mb-6">
293
+ <section class="text-sm text-muted-foreground">
294
+ <p>
295
+ {t({
296
+ message: "Images are automatically optimized: resized to max 1920px, converted to WebP, and metadata stripped.",
297
+ comment: "@context: Media upload instructions - auto optimization",
298
+ })}
299
+ </p>
300
+ </section>
301
+ </div>
302
+
303
+ {/* Media grid or empty state */}
304
+ <div id="media-content">
305
+ {mediaList.length === 0 ? (
306
+ <div id="empty-state">
307
+ <EmptyState
308
+ message={t({
309
+ message: "No media uploaded yet.",
310
+ comment: "@context: Empty state message when no media exists",
311
+ })}
312
+ />
313
+ </div>
314
+ ) : (
315
+ <div
316
+ id="media-grid"
317
+ class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
318
+ >
319
+ {mediaList.map((m) => (
320
+ <MediaCard
321
+ key={m.id}
322
+ media={m}
323
+ r2PublicUrl={r2PublicUrl}
324
+ imageTransformUrl={imageTransformUrl}
325
+ />
326
+ ))}
327
+ </div>
328
+ )}
329
+ </div>
330
+
331
+ {/* Lightbox - uses plain JS, not Datastar signals */}
332
+ <dialog
333
+ id="lightbox"
334
+ class="p-0 m-auto bg-transparent backdrop:bg-black/80"
335
+ onclick="event.target === this && this.close()"
336
+ >
337
+ <img id="lightbox-img" src="" alt="" class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg" />
338
+ </dialog>
339
+ </>
340
+ );
341
+ }
342
+
343
+ /**
344
+ * View single media content
345
+ */
346
+ function ViewMediaContent({
347
+ media,
348
+ r2PublicUrl,
349
+ imageTransformUrl,
350
+ }: {
351
+ media: Media;
352
+ r2PublicUrl?: string;
353
+ imageTransformUrl?: string;
354
+ }) {
355
+ const { t } = useLingui();
356
+ const url = getMediaUrl(media.id, media.r2Key, r2PublicUrl);
357
+ const thumbnailUrl = getImageUrl(url, imageTransformUrl, {
358
+ width: 600,
359
+ quality: 85,
360
+ format: "auto",
361
+ });
362
+ const isImage = media.mimeType.startsWith("image/");
363
+
364
+ return (
365
+ <>
366
+ <div class="flex items-center justify-between mb-6">
367
+ <div>
368
+ <h1 class="text-2xl font-semibold">{media.originalName}</h1>
369
+ <p class="text-muted-foreground mt-1">
370
+ {formatSize(media.size)} · {media.mimeType} · {time.formatDate(media.createdAt)}
371
+ </p>
372
+ </div>
373
+ <a href="/dash/media" class="btn-outline">
374
+ {t({ message: "Back", comment: "@context: Button to go back to media list" })}
375
+ </a>
376
+ </div>
377
+
378
+ <div class="grid gap-6 md:grid-cols-2">
379
+ {/* Preview */}
380
+ <div class="card">
381
+ <header>
382
+ <h2>{t({ message: "Preview", comment: "@context: Media detail section - preview" })}</h2>
383
+ </header>
384
+ <section>
385
+ {isImage ? (
386
+ <>
387
+ <button
388
+ type="button"
389
+ class="cursor-pointer"
390
+ onclick={`document.getElementById('lightbox-img').src = '${url}'; document.getElementById('lightbox').showModal()`}
391
+ >
392
+ <img
393
+ src={thumbnailUrl}
394
+ alt={media.alt || media.originalName}
395
+ class="max-w-full rounded-lg hover:opacity-90 transition-opacity"
396
+ />
397
+ </button>
398
+ <p class="text-xs text-muted-foreground mt-2">
399
+ {t({ message: "Click image to view full size", comment: "@context: Hint to click image for lightbox" })}
400
+ </p>
401
+ </>
402
+ ) : (
403
+ <div class="aspect-video bg-muted rounded-lg flex items-center justify-center text-muted-foreground">
404
+ <span>{media.mimeType}</span>
405
+ </div>
406
+ )}
407
+ </section>
408
+ </div>
409
+
410
+ {/* Details */}
411
+ <div class="space-y-6">
412
+ <div class="card">
413
+ <header>
414
+ <h2>{t({ message: "URL", comment: "@context: Media detail section - URL" })}</h2>
415
+ </header>
416
+ <section>
417
+ <div class="flex items-center gap-2">
418
+ <input
419
+ type="text"
420
+ class="input flex-1 font-mono text-sm"
421
+ value={url}
422
+ readonly
423
+ />
424
+ <button
425
+ type="button"
426
+ class="btn-outline"
427
+ onclick={`navigator.clipboard.writeText('${url}')`}
428
+ >
429
+ {t({ message: "Copy", comment: "@context: Button to copy URL to clipboard" })}
430
+ </button>
431
+ </div>
432
+ <p class="text-xs text-muted-foreground mt-2">
433
+ {t({ message: "Use this URL to embed the media in your posts.", comment: "@context: Media URL helper text" })}
434
+ </p>
435
+ </section>
436
+ </div>
437
+
438
+ <div class="card">
439
+ <header>
440
+ <h2>{t({ message: "Markdown", comment: "@context: Media detail section - Markdown snippet" })}</h2>
441
+ </header>
442
+ <section>
443
+ <div class="flex items-center gap-2">
444
+ <input
445
+ type="text"
446
+ class="input flex-1 font-mono text-sm"
447
+ value={`![${media.alt || media.originalName}](${url})`}
448
+ readonly
449
+ />
450
+ <button
451
+ type="button"
452
+ class="btn-outline"
453
+ onclick={`navigator.clipboard.writeText('![${media.alt || media.originalName}](${url})')`}
454
+ >
455
+ {t({ message: "Copy", comment: "@context: Button to copy Markdown to clipboard" })}
456
+ </button>
457
+ </div>
458
+ </section>
459
+ </div>
460
+
461
+ {/* Delete */}
462
+ <DangerZone
463
+ actionLabel={t({ message: "Delete Media", comment: "@context: Button to delete media" })}
464
+ formAction={`/dash/media/${media.id}/delete`}
465
+ confirmMessage="Are you sure you want to delete this media?"
466
+ description={t({ message: "Deleting this media will remove it permanently from storage.", comment: "@context: Warning message before deleting media" })}
467
+ />
468
+ </div>
469
+ </div>
470
+
471
+ {/* Lightbox */}
472
+ {isImage && (
473
+ <dialog
474
+ id="lightbox"
475
+ class="p-0 m-auto bg-transparent backdrop:bg-black/80"
476
+ onclick="event.target === this && this.close()"
477
+ >
478
+ <img
479
+ id="lightbox-img"
480
+ src=""
481
+ alt=""
482
+ class="max-w-[90vw] max-h-[90vh] object-contain rounded-lg"
483
+ />
484
+ </dialog>
485
+ )}
486
+ </>
487
+ );
488
+ }
489
+
490
+ // List media
491
+ mediaRoutes.get("/", async (c) => {
492
+ const mediaList = await c.var.services.media.list(100);
493
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
494
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
495
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
496
+ const assets = getAssets();
497
+
498
+ return c.html(
499
+ <DashLayout c={c} title="Media" siteName={siteName} currentPath="/dash/media">
500
+ <MediaListContent
501
+ mediaList={mediaList}
502
+ r2PublicUrl={r2PublicUrl}
503
+ imageTransformUrl={imageTransformUrl}
504
+ imageProcessorUrl={assets.imageProcessor}
505
+ />
506
+ </DashLayout>
507
+ );
508
+ });
509
+
510
+ // View single media
511
+ mediaRoutes.get("/:id", async (c) => {
512
+ const id = c.req.param("id");
513
+ const media = await c.var.services.media.getById(id);
514
+ if (!media) return c.notFound();
515
+
516
+ const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
517
+ const r2PublicUrl = c.env.R2_PUBLIC_URL;
518
+ const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
519
+
520
+ return c.html(
521
+ <DashLayout c={c} title={media.originalName} siteName={siteName} currentPath="/dash/media">
522
+ <ViewMediaContent
523
+ media={media}
524
+ r2PublicUrl={r2PublicUrl}
525
+ imageTransformUrl={imageTransformUrl}
526
+ />
527
+ </DashLayout>
528
+ );
529
+ });
530
+
531
+ // Delete media
532
+ mediaRoutes.post("/:id/delete", async (c) => {
533
+ const id = c.req.param("id");
534
+ const media = await c.var.services.media.getById(id);
535
+ if (!media) return c.notFound();
536
+
537
+ // Delete from R2
538
+ if (c.env.R2) {
539
+ try {
540
+ await c.env.R2.delete(media.r2Key);
541
+ } catch (err) {
542
+ // eslint-disable-next-line no-console -- Error logging is intentional
543
+ console.error("R2 delete error:", err);
544
+ }
545
+ }
546
+
547
+ // Delete from database
548
+ await c.var.services.media.delete(id);
549
+
550
+ return c.redirect("/dash/media");
551
+ });