@jant/core 0.2.12 → 0.2.13

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 (146) hide show
  1. package/bin/jant.js +3 -1
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +112 -85
  4. package/dist/auth.d.ts +1 -0
  5. package/dist/auth.d.ts.map +1 -1
  6. package/dist/auth.js +2 -1
  7. package/dist/client.js +1 -1
  8. package/dist/db/schema.d.ts.map +1 -1
  9. package/dist/i18n/context.d.ts.map +1 -1
  10. package/dist/i18n/context.js +0 -3
  11. package/dist/i18n/detect.d.ts +0 -11
  12. package/dist/i18n/detect.d.ts.map +1 -1
  13. package/dist/i18n/detect.js +1 -52
  14. package/dist/i18n/i18n.d.ts +4 -14
  15. package/dist/i18n/i18n.d.ts.map +1 -1
  16. package/dist/i18n/i18n.js +19 -25
  17. package/dist/i18n/index.d.ts +1 -1
  18. package/dist/i18n/index.d.ts.map +1 -1
  19. package/dist/i18n/index.js +1 -1
  20. package/dist/i18n/middleware.d.ts +2 -5
  21. package/dist/i18n/middleware.d.ts.map +1 -1
  22. package/dist/i18n/middleware.js +12 -23
  23. package/dist/lib/constants.d.ts.map +1 -1
  24. package/dist/lib/image.d.ts.map +1 -1
  25. package/dist/lib/schemas.d.ts.map +1 -1
  26. package/dist/lib/sse.d.ts +45 -17
  27. package/dist/lib/sse.d.ts.map +1 -1
  28. package/dist/lib/sse.js +77 -37
  29. package/dist/middleware/auth.d.ts.map +1 -1
  30. package/dist/routes/api/posts.js +0 -1
  31. package/dist/routes/api/upload.js +3 -1
  32. package/dist/routes/dash/collections.d.ts.map +1 -1
  33. package/dist/routes/dash/collections.js +134 -142
  34. package/dist/routes/dash/index.js +25 -26
  35. package/dist/routes/dash/media.d.ts.map +1 -1
  36. package/dist/routes/dash/media.js +60 -56
  37. package/dist/routes/dash/pages.js +64 -66
  38. package/dist/routes/dash/posts.d.ts.map +1 -1
  39. package/dist/routes/dash/posts.js +50 -59
  40. package/dist/routes/dash/redirects.d.ts.map +1 -1
  41. package/dist/routes/dash/redirects.js +63 -60
  42. package/dist/routes/dash/settings.d.ts.map +1 -1
  43. package/dist/routes/dash/settings.js +249 -93
  44. package/dist/routes/feed/rss.js +6 -4
  45. package/dist/routes/pages/archive.js +60 -62
  46. package/dist/routes/pages/collection.js +8 -8
  47. package/dist/routes/pages/home.js +14 -14
  48. package/dist/routes/pages/page.js +7 -6
  49. package/dist/routes/pages/post.js +8 -8
  50. package/dist/routes/pages/search.js +25 -27
  51. package/dist/services/collection.d.ts.map +1 -1
  52. package/dist/services/index.d.ts.map +1 -1
  53. package/dist/services/media.d.ts.map +1 -1
  54. package/dist/services/post.d.ts.map +1 -1
  55. package/dist/services/redirect.d.ts.map +1 -1
  56. package/dist/services/settings.d.ts.map +1 -1
  57. package/dist/theme/components/ActionButtons.d.ts +1 -1
  58. package/dist/theme/components/ActionButtons.d.ts.map +1 -1
  59. package/dist/theme/components/ActionButtons.js +17 -21
  60. package/dist/theme/components/CrudPageHeader.d.ts.map +1 -1
  61. package/dist/theme/components/DangerZone.d.ts.map +1 -1
  62. package/dist/theme/components/DangerZone.js +12 -15
  63. package/dist/theme/components/EmptyState.d.ts.map +1 -1
  64. package/dist/theme/components/PageForm.d.ts.map +1 -1
  65. package/dist/theme/components/PageForm.js +58 -56
  66. package/dist/theme/components/Pagination.d.ts.map +1 -1
  67. package/dist/theme/components/Pagination.js +22 -25
  68. package/dist/theme/components/PostForm.d.ts +0 -1
  69. package/dist/theme/components/PostForm.d.ts.map +1 -1
  70. package/dist/theme/components/PostForm.js +85 -77
  71. package/dist/theme/components/PostList.d.ts.map +1 -1
  72. package/dist/theme/components/PostList.js +17 -17
  73. package/dist/theme/components/ThreadView.d.ts.map +1 -1
  74. package/dist/theme/components/ThreadView.js +15 -18
  75. package/dist/theme/components/TypeBadge.d.ts.map +1 -1
  76. package/dist/theme/components/TypeBadge.js +20 -20
  77. package/dist/theme/components/VisibilityBadge.d.ts.map +1 -1
  78. package/dist/theme/components/VisibilityBadge.js +14 -14
  79. package/dist/theme/components/index.d.ts +1 -1
  80. package/dist/theme/components/index.d.ts.map +1 -1
  81. package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
  82. package/dist/theme/layouts/BaseLayout.js +4 -2
  83. package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
  84. package/dist/theme/layouts/DashLayout.js +29 -29
  85. package/dist/types/lingui-react-macro.d.js +9 -0
  86. package/dist/types.d.ts +2 -0
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/vendor/datastar.js +1606 -0
  89. package/package.json +5 -2
  90. package/src/app.tsx +175 -56
  91. package/src/auth.ts +5 -1
  92. package/src/client.ts +1 -1
  93. package/src/db/schema.ts +22 -7
  94. package/src/i18n/EXAMPLES.md +34 -14
  95. package/src/i18n/README.md +19 -9
  96. package/src/i18n/context.tsx +1 -4
  97. package/src/i18n/detect.ts +1 -67
  98. package/src/i18n/i18n.ts +15 -19
  99. package/src/i18n/index.ts +0 -3
  100. package/src/i18n/middleware.ts +12 -24
  101. package/src/lib/constants.ts +2 -1
  102. package/src/lib/image-processor.ts +23 -7
  103. package/src/lib/image.ts +6 -2
  104. package/src/lib/schemas.ts +6 -2
  105. package/src/lib/sse.ts +138 -50
  106. package/src/middleware/auth.ts +6 -2
  107. package/src/routes/api/posts.ts +14 -5
  108. package/src/routes/api/upload.ts +25 -7
  109. package/src/routes/dash/collections.tsx +162 -70
  110. package/src/routes/dash/index.tsx +22 -7
  111. package/src/routes/dash/media.tsx +59 -16
  112. package/src/routes/dash/pages.tsx +102 -44
  113. package/src/routes/dash/posts.tsx +87 -54
  114. package/src/routes/dash/redirects.tsx +74 -26
  115. package/src/routes/dash/settings.tsx +250 -57
  116. package/src/routes/feed/rss.ts +6 -4
  117. package/src/routes/pages/archive.tsx +71 -21
  118. package/src/routes/pages/collection.tsx +21 -6
  119. package/src/routes/pages/home.tsx +30 -9
  120. package/src/routes/pages/page.tsx +14 -5
  121. package/src/routes/pages/post.tsx +21 -7
  122. package/src/routes/pages/search.tsx +42 -11
  123. package/src/services/collection.ts +34 -9
  124. package/src/services/index.ts +4 -1
  125. package/src/services/media.ts +15 -3
  126. package/src/services/post.ts +39 -10
  127. package/src/services/redirect.ts +4 -1
  128. package/src/services/settings.ts +14 -3
  129. package/src/theme/components/ActionButtons.tsx +26 -14
  130. package/src/theme/components/CrudPageHeader.tsx +6 -1
  131. package/src/theme/components/DangerZone.tsx +19 -13
  132. package/src/theme/components/EmptyState.tsx +6 -1
  133. package/src/theme/components/PageForm.tsx +71 -24
  134. package/src/theme/components/Pagination.tsx +26 -8
  135. package/src/theme/components/PostForm.tsx +72 -25
  136. package/src/theme/components/PostList.tsx +16 -5
  137. package/src/theme/components/ThreadView.tsx +25 -7
  138. package/src/theme/components/TypeBadge.tsx +13 -4
  139. package/src/theme/components/VisibilityBadge.tsx +17 -5
  140. package/src/theme/components/index.ts +4 -1
  141. package/src/theme/layouts/BaseLayout.tsx +5 -2
  142. package/src/theme/layouts/DashLayout.tsx +41 -12
  143. package/src/types/lingui-react-macro.d.ts +34 -0
  144. package/src/types.ts +16 -2
  145. package/src/vendor/datastar.js +9 -0
  146. package/src/vendor/datastar.js.map +7 -0
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import { useLingui } from "../../i18n/index.js";
6
+ import { useLingui } from "@lingui/react/macro";
7
7
  import type { Bindings, Collection, Post } from "../../types.js";
8
8
  import type { AppVariables } from "../../app.js";
9
9
  import { DashLayout } from "../../theme/layouts/index.js";
@@ -15,18 +15,26 @@ import {
15
15
  DangerZone,
16
16
  } from "../../theme/components/index.js";
17
17
  import * as sqid from "../../lib/sqid.js";
18
+ import { sse } from "../../lib/sse.js";
18
19
 
19
20
  type Env = { Bindings: Bindings; Variables: AppVariables };
20
21
 
21
22
  export const collectionsRoutes = new Hono<Env>();
22
23
 
23
- function CollectionsListContent({ collections }: { collections: Collection[] }) {
24
+ function CollectionsListContent({
25
+ collections,
26
+ }: {
27
+ collections: Collection[];
28
+ }) {
24
29
  const { t } = useLingui();
25
30
 
26
31
  return (
27
32
  <>
28
33
  <CrudPageHeader
29
- title={t({ message: "Collections", comment: "@context: Dashboard heading" })}
34
+ title={t({
35
+ message: "Collections",
36
+ comment: "@context: Dashboard heading",
37
+ })}
30
38
  ctaLabel={t({
31
39
  message: "New Collection",
32
40
  comment: "@context: Button to create new collection",
@@ -36,7 +44,10 @@ function CollectionsListContent({ collections }: { collections: Collection[] })
36
44
 
37
45
  {collections.length === 0 ? (
38
46
  <EmptyState
39
- message={t({ message: "No collections yet.", comment: "@context: Empty state message" })}
47
+ message={t({
48
+ message: "No collections yet.",
49
+ comment: "@context: Empty state message",
50
+ })}
40
51
  ctaText={t({
41
52
  message: "New Collection",
42
53
  comment: "@context: Button to create new collection",
@@ -51,18 +62,29 @@ function CollectionsListContent({ collections }: { collections: Collection[] })
51
62
  actions={
52
63
  <ActionButtons
53
64
  editHref={`/dash/collections/${col.id}/edit`}
54
- editLabel={t({ message: "Edit", comment: "@context: Button to edit collection" })}
65
+ editLabel={t({
66
+ message: "Edit",
67
+ comment: "@context: Button to edit collection",
68
+ })}
55
69
  viewHref={`/c/${col.path}`}
56
- viewLabel={t({ message: "View", comment: "@context: Button to view collection" })}
70
+ viewLabel={t({
71
+ message: "View",
72
+ comment: "@context: Button to view collection",
73
+ })}
57
74
  />
58
75
  }
59
76
  >
60
- <a href={`/dash/collections/${col.id}`} class="font-medium hover:underline">
77
+ <a
78
+ href={`/dash/collections/${col.id}`}
79
+ class="font-medium hover:underline"
80
+ >
61
81
  {col.title}
62
82
  </a>
63
83
  <p class="text-sm text-muted-foreground">/{col.path}</p>
64
84
  {col.description && (
65
- <p class="text-sm text-muted-foreground mt-1">{col.description}</p>
85
+ <p class="text-sm text-muted-foreground mt-1">
86
+ {col.description}
87
+ </p>
66
88
  )}
67
89
  </ListItemRow>
68
90
  ))}
@@ -80,14 +102,21 @@ function NewCollectionContent() {
80
102
  {t({ message: "New Collection", comment: "@context: Page heading" })}
81
103
  </h1>
82
104
 
83
- <form method="post" action="/dash/collections" class="flex flex-col gap-4 max-w-lg">
105
+ <form
106
+ data-signals="{title: '', path: '', description: ''}"
107
+ data-on:submit__prevent="@post('/dash/collections')"
108
+ class="flex flex-col gap-4 max-w-lg"
109
+ >
84
110
  <div class="field">
85
111
  <label class="label">
86
- {t({ message: "Title", comment: "@context: Collection form field" })}
112
+ {t({
113
+ message: "Title",
114
+ comment: "@context: Collection form field",
115
+ })}
87
116
  </label>
88
117
  <input
89
118
  type="text"
90
- name="title"
119
+ data-bind="title"
91
120
  class="input"
92
121
  required
93
122
  placeholder={t({
@@ -103,7 +132,7 @@ function NewCollectionContent() {
103
132
  </label>
104
133
  <input
105
134
  type="text"
106
- name="path"
135
+ data-bind="path"
107
136
  class="input"
108
137
  required
109
138
  placeholder="my-collection"
@@ -119,10 +148,13 @@ function NewCollectionContent() {
119
148
 
120
149
  <div class="field">
121
150
  <label class="label">
122
- {t({ message: "Description (optional)", comment: "@context: Collection form field" })}
151
+ {t({
152
+ message: "Description (optional)",
153
+ comment: "@context: Collection form field",
154
+ })}
123
155
  </label>
124
156
  <textarea
125
- name="description"
157
+ data-bind="description"
126
158
  class="textarea"
127
159
  rows={3}
128
160
  placeholder={t({
@@ -140,7 +172,10 @@ function NewCollectionContent() {
140
172
  })}
141
173
  </button>
142
174
  <a href="/dash/collections" class="btn-outline">
143
- {t({ message: "Cancel", comment: "@context: Button to cancel form" })}
175
+ {t({
176
+ message: "Cancel",
177
+ comment: "@context: Button to cancel form",
178
+ })}
144
179
  </a>
145
180
  </div>
146
181
  </form>
@@ -148,7 +183,13 @@ function NewCollectionContent() {
148
183
  );
149
184
  }
150
185
 
151
- function ViewCollectionContent({ collection, posts }: { collection: Collection; posts: Post[] }) {
186
+ function ViewCollectionContent({
187
+ collection,
188
+ posts,
189
+ }: {
190
+ collection: Collection;
191
+ posts: Post[];
192
+ }) {
152
193
  const { t } = useLingui();
153
194
  const postsHeader = t({
154
195
  message: "Posts in Collection ({count})",
@@ -165,13 +206,21 @@ function ViewCollectionContent({ collection, posts }: { collection: Collection;
165
206
  </div>
166
207
  <ActionButtons
167
208
  editHref={`/dash/collections/${collection.id}/edit`}
168
- editLabel={t({ message: "Edit", comment: "@context: Button to edit collection" })}
209
+ editLabel={t({
210
+ message: "Edit",
211
+ comment: "@context: Button to edit collection",
212
+ })}
169
213
  viewHref={`/c/${collection.path}`}
170
- viewLabel={t({ message: "View", comment: "@context: Button to view collection" })}
214
+ viewLabel={t({
215
+ message: "View",
216
+ comment: "@context: Button to view collection",
217
+ })}
171
218
  />
172
219
  </div>
173
220
 
174
- {collection.description && <p class="text-muted-foreground mb-6">{collection.description}</p>}
221
+ {collection.description && (
222
+ <p class="text-muted-foreground mb-6">{collection.description}</p>
223
+ )}
175
224
 
176
225
  <div class="card">
177
226
  <header>
@@ -194,18 +243,22 @@ function ViewCollectionContent({ collection, posts }: { collection: Collection;
194
243
  href={`/dash/posts/${sqid.encode(post.id)}`}
195
244
  class="font-medium hover:underline"
196
245
  >
197
- {post.title || post.content?.slice(0, 50) || `Post #${post.id}`}
246
+ {post.title ||
247
+ post.content?.slice(0, 50) ||
248
+ `Post #${post.id}`}
198
249
  </a>
199
250
  </div>
200
- <form method="post" action={`/dash/collections/${collection.id}/remove-post`}>
201
- <input type="hidden" name="postId" value={post.id} />
202
- <button type="submit" class="btn-sm-ghost text-destructive">
203
- {t({
204
- message: "Remove",
205
- comment: "@context: Button to remove post from collection",
206
- })}
207
- </button>
208
- </form>
251
+ <button
252
+ type="button"
253
+ class="btn-sm-ghost text-destructive"
254
+ data-on:click__prevent={`@post('/dash/collections/${collection.id}/remove-post', {payload: {postId: ${post.id}}})`}
255
+ >
256
+ {t({
257
+ message: "Remove",
258
+ comment:
259
+ "@context: Button to remove post from collection",
260
+ })}
261
+ </button>
209
262
  </div>
210
263
  ))}
211
264
  </div>
@@ -215,7 +268,10 @@ function ViewCollectionContent({ collection, posts }: { collection: Collection;
215
268
 
216
269
  <div class="mt-6">
217
270
  <a href="/dash/collections" class="text-sm hover:underline">
218
- {t({ message: "← Back to Collections", comment: "@context: Navigation link" })}
271
+ {t({
272
+ message: "← Back to Collections",
273
+ comment: "@context: Navigation link",
274
+ })}
219
275
  </a>
220
276
  </div>
221
277
  </>
@@ -225,6 +281,12 @@ function ViewCollectionContent({ collection, posts }: { collection: Collection;
225
281
  function EditCollectionContent({ collection }: { collection: Collection }) {
226
282
  const { t } = useLingui();
227
283
 
284
+ const signals = JSON.stringify({
285
+ title: collection.title,
286
+ path: collection.path ?? "",
287
+ description: collection.description ?? "",
288
+ }).replace(/</g, "\\u003c");
289
+
228
290
  return (
229
291
  <>
230
292
  <h1 class="text-2xl font-semibold mb-6">
@@ -232,15 +294,18 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
232
294
  </h1>
233
295
 
234
296
  <form
235
- method="post"
236
- action={`/dash/collections/${collection.id}`}
297
+ data-signals={signals}
298
+ data-on:submit__prevent={`@post('/dash/collections/${collection.id}')`}
237
299
  class="flex flex-col gap-4 max-w-lg"
238
300
  >
239
301
  <div class="field">
240
302
  <label class="label">
241
- {t({ message: "Title", comment: "@context: Collection form field" })}
303
+ {t({
304
+ message: "Title",
305
+ comment: "@context: Collection form field",
306
+ })}
242
307
  </label>
243
- <input type="text" name="title" class="input" required value={collection.title} />
308
+ <input type="text" data-bind="title" class="input" required />
244
309
  </div>
245
310
 
246
311
  <div class="field">
@@ -249,19 +314,21 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
249
314
  </label>
250
315
  <input
251
316
  type="text"
252
- name="path"
317
+ data-bind="path"
253
318
  class="input"
254
319
  required
255
- value={collection.path ?? ""}
256
320
  pattern="[a-z0-9-]+"
257
321
  />
258
322
  </div>
259
323
 
260
324
  <div class="field">
261
325
  <label class="label">
262
- {t({ message: "Description (optional)", comment: "@context: Collection form field" })}
326
+ {t({
327
+ message: "Description (optional)",
328
+ comment: "@context: Collection form field",
329
+ })}
263
330
  </label>
264
- <textarea name="description" class="textarea" rows={3}>
331
+ <textarea data-bind="description" class="textarea" rows={3}>
265
332
  {collection.description ?? ""}
266
333
  </textarea>
267
334
  </div>
@@ -274,7 +341,10 @@ function EditCollectionContent({ collection }: { collection: Collection }) {
274
341
  })}
275
342
  </button>
276
343
  <a href={`/dash/collections/${collection.id}`} class="btn-outline">
277
- {t({ message: "Cancel", comment: "@context: Button to cancel form" })}
344
+ {t({
345
+ message: "Cancel",
346
+ comment: "@context: Button to cancel form",
347
+ })}
278
348
  </a>
279
349
  </div>
280
350
  </form>
@@ -297,9 +367,14 @@ collectionsRoutes.get("/", async (c) => {
297
367
  const collections = await c.var.services.collections.list();
298
368
 
299
369
  return c.html(
300
- <DashLayout c={c} title="Collections" siteName={siteName} currentPath="/dash/collections">
370
+ <DashLayout
371
+ c={c}
372
+ title="Collections"
373
+ siteName={siteName}
374
+ currentPath="/dash/collections"
375
+ >
301
376
  <CollectionsListContent collections={collections} />
302
- </DashLayout>
377
+ </DashLayout>,
303
378
  );
304
379
  });
305
380
 
@@ -308,27 +383,34 @@ collectionsRoutes.get("/new", async (c) => {
308
383
  const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
309
384
 
310
385
  return c.html(
311
- <DashLayout c={c} title="New Collection" siteName={siteName} currentPath="/dash/collections">
386
+ <DashLayout
387
+ c={c}
388
+ title="New Collection"
389
+ siteName={siteName}
390
+ currentPath="/dash/collections"
391
+ >
312
392
  <NewCollectionContent />
313
- </DashLayout>
393
+ </DashLayout>,
314
394
  );
315
395
  });
316
396
 
317
397
  // Create collection
318
398
  collectionsRoutes.post("/", async (c) => {
319
- const formData = await c.req.formData();
320
-
321
- const title = formData.get("title") as string;
322
- const path = formData.get("path") as string;
323
- const description = formData.get("description") as string;
399
+ const body = await c.req.json<{
400
+ title: string;
401
+ path: string;
402
+ description?: string;
403
+ }>();
324
404
 
325
405
  const collection = await c.var.services.collections.create({
326
- title,
327
- path,
328
- description: description || undefined,
406
+ title: body.title,
407
+ path: body.path,
408
+ description: body.description || undefined,
329
409
  });
330
410
 
331
- return c.redirect(`/dash/collections/${collection.id}`);
411
+ return sse(c, async (stream) => {
412
+ await stream.redirect(`/dash/collections/${collection.id}`);
413
+ });
332
414
  });
333
415
 
334
416
  // View single collection
@@ -343,9 +425,14 @@ collectionsRoutes.get("/:id", async (c) => {
343
425
  const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
344
426
 
345
427
  return c.html(
346
- <DashLayout c={c} title={collection.title} siteName={siteName} currentPath="/dash/collections">
428
+ <DashLayout
429
+ c={c}
430
+ title={collection.title}
431
+ siteName={siteName}
432
+ currentPath="/dash/collections"
433
+ >
347
434
  <ViewCollectionContent collection={collection} posts={posts} />
348
- </DashLayout>
435
+ </DashLayout>,
349
436
  );
350
437
  });
351
438
 
@@ -367,7 +454,7 @@ collectionsRoutes.get("/:id/edit", async (c) => {
367
454
  currentPath="/dash/collections"
368
455
  >
369
456
  <EditCollectionContent collection={collection} />
370
- </DashLayout>
457
+ </DashLayout>,
371
458
  );
372
459
  });
373
460
 
@@ -376,19 +463,21 @@ collectionsRoutes.post("/:id", async (c) => {
376
463
  const id = parseInt(c.req.param("id"), 10);
377
464
  if (isNaN(id)) return c.notFound();
378
465
 
379
- const formData = await c.req.formData();
380
-
381
- const title = formData.get("title") as string;
382
- const path = formData.get("path") as string;
383
- const description = formData.get("description") as string;
466
+ const body = await c.req.json<{
467
+ title: string;
468
+ path: string;
469
+ description?: string;
470
+ }>();
384
471
 
385
472
  await c.var.services.collections.update(id, {
386
- title,
387
- path,
388
- description: description || undefined,
473
+ title: body.title,
474
+ path: body.path,
475
+ description: body.description || undefined,
389
476
  });
390
477
 
391
- return c.redirect(`/dash/collections/${id}`);
478
+ return sse(c, async (stream) => {
479
+ await stream.redirect(`/dash/collections/${id}`);
480
+ });
392
481
  });
393
482
 
394
483
  // Delete collection
@@ -398,7 +487,9 @@ collectionsRoutes.post("/:id/delete", async (c) => {
398
487
 
399
488
  await c.var.services.collections.delete(id);
400
489
 
401
- return c.redirect("/dash/collections");
490
+ return sse(c, async (stream) => {
491
+ await stream.redirect("/dash/collections");
492
+ });
402
493
  });
403
494
 
404
495
  // Remove post from collection
@@ -406,12 +497,13 @@ collectionsRoutes.post("/:id/remove-post", async (c) => {
406
497
  const id = parseInt(c.req.param("id"), 10);
407
498
  if (isNaN(id)) return c.notFound();
408
499
 
409
- const formData = await c.req.formData();
410
- const postId = parseInt(formData.get("postId") as string, 10);
500
+ const body = await c.req.json<{ postId: number }>();
411
501
 
412
- if (!isNaN(postId)) {
413
- await c.var.services.collections.removePost(id, postId);
502
+ if (body.postId) {
503
+ await c.var.services.collections.removePost(id, body.postId);
414
504
  }
415
505
 
416
- return c.redirect(`/dash/collections/${id}`);
506
+ return sse(c, async (stream) => {
507
+ await stream.redirect(`/dash/collections/${id}`);
508
+ });
417
509
  });
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import { Trans, useLingui } from "../../i18n/index.js";
8
+ import { Trans, useLingui } from "@lingui/react/macro";
9
9
  import type { Bindings } from "../../types.js";
10
10
  import type { AppVariables } from "../../app.js";
11
11
  import { DashLayout } from "../../theme/layouts/index.js";
@@ -32,13 +32,19 @@ function DashboardContent({
32
32
  <div class="container py-8">
33
33
  <h1 class="text-2xl font-semibold mb-6">
34
34
  {/* ✅ No more nesting! */}
35
- {t({ message: "Dashboard", comment: "@context: Dashboard main heading" })}
35
+ {t({
36
+ message: "Dashboard",
37
+ comment: "@context: Dashboard main heading",
38
+ })}
36
39
  </h1>
37
40
 
38
41
  <div class="grid gap-4 md:grid-cols-3 mb-6">
39
42
  <div class="p-4 border rounded">
40
43
  <p class="text-sm text-muted-foreground">
41
- {t({ message: "Published", comment: "@context: Post status label" })}
44
+ {t({
45
+ message: "Published",
46
+ comment: "@context: Post status label",
47
+ })}
42
48
  </p>
43
49
  <p class="text-3xl font-bold">{publishedCount}</p>
44
50
  </div>
@@ -52,10 +58,16 @@ function DashboardContent({
52
58
 
53
59
  <div class="p-4 border rounded">
54
60
  <p class="text-sm text-muted-foreground mb-2">
55
- {t({ message: "Quick Actions", comment: "@context: Dashboard section title" })}
61
+ {t({
62
+ message: "Quick Actions",
63
+ comment: "@context: Dashboard section title",
64
+ })}
56
65
  </p>
57
66
  <a href="/dash/posts/new" class="btn btn-primary w-full">
58
- {t({ message: "New Post", comment: "@context: Button to create new post" })}
67
+ {t({
68
+ message: "New Post",
69
+ comment: "@context: Button to create new post",
70
+ })}
59
71
  </a>
60
72
  </div>
61
73
  </div>
@@ -83,7 +95,10 @@ dashIndexRoutes.get("/", async (c) => {
83
95
 
84
96
  return c.html(
85
97
  <DashLayout c={c} title="Dashboard" siteName={siteName} currentPath="/dash">
86
- <DashboardContent publishedCount={publishedPosts.length} draftCount={draftPosts.length} />
87
- </DashLayout>
98
+ <DashboardContent
99
+ publishedCount={publishedPosts.length}
100
+ draftCount={draftPosts.length}
101
+ />
102
+ </DashLayout>,
88
103
  );
89
104
  });
@@ -6,13 +6,14 @@
6
6
  */
7
7
 
8
8
  import { Hono } from "hono";
9
- import { useLingui } from "../../i18n/index.js";
9
+ import { useLingui } from "@lingui/react/macro";
10
10
  import type { Bindings, Media } from "../../types.js";
11
11
  import type { AppVariables } from "../../app.js";
12
12
  import { DashLayout } from "../../theme/layouts/index.js";
13
13
  import { EmptyState, DangerZone } from "../../theme/components/index.js";
14
14
  import * as time from "../../lib/time.js";
15
15
  import { getMediaUrl, getImageUrl } from "../../lib/image.js";
16
+ import { sse } from "../../lib/sse.js";
16
17
 
17
18
  type Env = { Bindings: Bindings; Variables: AppVariables };
18
19
 
@@ -110,7 +111,10 @@ function MediaListContent({
110
111
  message: "Uploading...",
111
112
  comment: "@context: Upload status - uploading",
112
113
  });
113
- const uploadText = t({ message: "Upload", comment: "@context: Button to upload media file" });
114
+ const uploadText = t({
115
+ message: "Upload",
116
+ comment: "@context: Button to upload media file",
117
+ });
114
118
  const errorText = t({
115
119
  message: "Upload failed. Please try again.",
116
120
  comment: "@context: Upload error message",
@@ -281,7 +285,12 @@ function processSSEEvent(event) {
281
285
  </h1>
282
286
  <label class="btn cursor-pointer">
283
287
  <span>{uploadText}</span>
284
- <input type="file" class="hidden" accept="image/*" onchange="handleMediaUpload(this)" />
288
+ <input
289
+ type="file"
290
+ class="hidden"
291
+ accept="image/*"
292
+ onchange="handleMediaUpload(this)"
293
+ />
285
294
  </label>
286
295
  </div>
287
296
 
@@ -295,7 +304,8 @@ function processSSEEvent(event) {
295
304
  {t({
296
305
  message:
297
306
  "Images are automatically optimized: resized to max 1920px, converted to WebP, and metadata stripped.",
298
- comment: "@context: Media upload instructions - auto optimization",
307
+ comment:
308
+ "@context: Media upload instructions - auto optimization",
299
309
  })}
300
310
  </p>
301
311
  </section>
@@ -373,11 +383,15 @@ function ViewMediaContent({
373
383
  <div>
374
384
  <h1 class="text-2xl font-semibold">{media.originalName}</h1>
375
385
  <p class="text-muted-foreground mt-1">
376
- {formatSize(media.size)} · {media.mimeType} · {time.formatDate(media.createdAt)}
386
+ {formatSize(media.size)} · {media.mimeType} ·{" "}
387
+ {time.formatDate(media.createdAt)}
377
388
  </p>
378
389
  </div>
379
390
  <a href="/dash/media" class="btn-outline">
380
- {t({ message: "Back", comment: "@context: Button to go back to media list" })}
391
+ {t({
392
+ message: "Back",
393
+ comment: "@context: Button to go back to media list",
394
+ })}
381
395
  </a>
382
396
  </div>
383
397
 
@@ -386,7 +400,10 @@ function ViewMediaContent({
386
400
  <div class="card">
387
401
  <header>
388
402
  <h2>
389
- {t({ message: "Preview", comment: "@context: Media detail section - preview" })}
403
+ {t({
404
+ message: "Preview",
405
+ comment: "@context: Media detail section - preview",
406
+ })}
390
407
  </h2>
391
408
  </header>
392
409
  <section>
@@ -422,17 +439,30 @@ function ViewMediaContent({
422
439
  <div class="space-y-6">
423
440
  <div class="card">
424
441
  <header>
425
- <h2>{t({ message: "URL", comment: "@context: Media detail section - URL" })}</h2>
442
+ <h2>
443
+ {t({
444
+ message: "URL",
445
+ comment: "@context: Media detail section - URL",
446
+ })}
447
+ </h2>
426
448
  </header>
427
449
  <section>
428
450
  <div class="flex items-center gap-2">
429
- <input type="text" class="input flex-1 font-mono text-sm" value={url} readonly />
451
+ <input
452
+ type="text"
453
+ class="input flex-1 font-mono text-sm"
454
+ value={url}
455
+ readonly
456
+ />
430
457
  <button
431
458
  type="button"
432
459
  class="btn-outline"
433
460
  onclick={`navigator.clipboard.writeText('${url}')`}
434
461
  >
435
- {t({ message: "Copy", comment: "@context: Button to copy URL to clipboard" })}
462
+ {t({
463
+ message: "Copy",
464
+ comment: "@context: Button to copy URL to clipboard",
465
+ })}
436
466
  </button>
437
467
  </div>
438
468
  <p class="text-xs text-muted-foreground mt-2">
@@ -484,7 +514,8 @@ function ViewMediaContent({
484
514
  formAction={`/dash/media/${media.id}/delete`}
485
515
  confirmMessage="Are you sure you want to delete this media?"
486
516
  description={t({
487
- message: "Deleting this media will remove it permanently from storage.",
517
+ message:
518
+ "Deleting this media will remove it permanently from storage.",
488
519
  comment: "@context: Warning message before deleting media",
489
520
  })}
490
521
  />
@@ -518,13 +549,18 @@ mediaRoutes.get("/", async (c) => {
518
549
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
519
550
 
520
551
  return c.html(
521
- <DashLayout c={c} title="Media" siteName={siteName} currentPath="/dash/media">
552
+ <DashLayout
553
+ c={c}
554
+ title="Media"
555
+ siteName={siteName}
556
+ currentPath="/dash/media"
557
+ >
522
558
  <MediaListContent
523
559
  mediaList={mediaList}
524
560
  r2PublicUrl={r2PublicUrl}
525
561
  imageTransformUrl={imageTransformUrl}
526
562
  />
527
- </DashLayout>
563
+ </DashLayout>,
528
564
  );
529
565
  });
530
566
 
@@ -539,13 +575,18 @@ mediaRoutes.get("/:id", async (c) => {
539
575
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
540
576
 
541
577
  return c.html(
542
- <DashLayout c={c} title={media.originalName} siteName={siteName} currentPath="/dash/media">
578
+ <DashLayout
579
+ c={c}
580
+ title={media.originalName}
581
+ siteName={siteName}
582
+ currentPath="/dash/media"
583
+ >
543
584
  <ViewMediaContent
544
585
  media={media}
545
586
  r2PublicUrl={r2PublicUrl}
546
587
  imageTransformUrl={imageTransformUrl}
547
588
  />
548
- </DashLayout>
589
+ </DashLayout>,
549
590
  );
550
591
  });
551
592
 
@@ -568,5 +609,7 @@ mediaRoutes.post("/:id/delete", async (c) => {
568
609
  // Delete from database
569
610
  await c.var.services.media.delete(id);
570
611
 
571
- return c.redirect("/dash/media");
612
+ return sse(c, async (stream) => {
613
+ await stream.redirect("/dash/media");
614
+ });
572
615
  });