@jant/core 0.2.11 → 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 (153) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +112 -85
  3. package/dist/auth.d.ts +1 -0
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js +2 -1
  6. package/dist/client.js +1 -1
  7. package/dist/db/schema.d.ts.map +1 -1
  8. package/dist/i18n/context.d.ts.map +1 -1
  9. package/dist/i18n/context.js +0 -3
  10. package/dist/i18n/detect.d.ts +0 -11
  11. package/dist/i18n/detect.d.ts.map +1 -1
  12. package/dist/i18n/detect.js +1 -52
  13. package/dist/i18n/i18n.d.ts +4 -14
  14. package/dist/i18n/i18n.d.ts.map +1 -1
  15. package/dist/i18n/i18n.js +19 -25
  16. package/dist/i18n/index.d.ts +1 -1
  17. package/dist/i18n/index.d.ts.map +1 -1
  18. package/dist/i18n/index.js +1 -1
  19. package/dist/i18n/middleware.d.ts +2 -5
  20. package/dist/i18n/middleware.d.ts.map +1 -1
  21. package/dist/i18n/middleware.js +12 -23
  22. package/dist/lib/constants.d.ts.map +1 -1
  23. package/dist/lib/schemas.d.ts.map +1 -1
  24. package/dist/lib/sse.d.ts +45 -17
  25. package/dist/lib/sse.d.ts.map +1 -1
  26. package/dist/lib/sse.js +77 -37
  27. package/dist/middleware/auth.d.ts.map +1 -1
  28. package/dist/routes/api/posts.js +0 -1
  29. package/dist/routes/api/upload.js +13 -3
  30. package/dist/routes/dash/collections.d.ts.map +1 -1
  31. package/dist/routes/dash/collections.js +134 -142
  32. package/dist/routes/dash/index.js +25 -25
  33. package/dist/routes/dash/media.d.ts.map +1 -1
  34. package/dist/routes/dash/media.js +60 -56
  35. package/dist/routes/dash/pages.d.ts.map +1 -1
  36. package/dist/routes/dash/pages.js +64 -66
  37. package/dist/routes/dash/posts.d.ts.map +1 -1
  38. package/dist/routes/dash/posts.js +50 -59
  39. package/dist/routes/dash/redirects.d.ts.map +1 -1
  40. package/dist/routes/dash/redirects.js +63 -60
  41. package/dist/routes/dash/settings.d.ts.map +1 -1
  42. package/dist/routes/dash/settings.js +249 -93
  43. package/dist/routes/feed/rss.js +6 -4
  44. package/dist/routes/pages/archive.js +60 -62
  45. package/dist/routes/pages/collection.js +8 -8
  46. package/dist/routes/pages/home.js +14 -14
  47. package/dist/routes/pages/page.js +7 -6
  48. package/dist/routes/pages/post.js +8 -8
  49. package/dist/routes/pages/search.js +25 -27
  50. package/dist/services/collection.d.ts.map +1 -1
  51. package/dist/services/index.d.ts.map +1 -1
  52. package/dist/services/media.d.ts.map +1 -1
  53. package/dist/services/post.d.ts.map +1 -1
  54. package/dist/services/redirect.d.ts.map +1 -1
  55. package/dist/services/settings.d.ts.map +1 -1
  56. package/dist/theme/components/ActionButtons.d.ts +1 -1
  57. package/dist/theme/components/ActionButtons.d.ts.map +1 -1
  58. package/dist/theme/components/ActionButtons.js +17 -21
  59. package/dist/theme/components/CrudPageHeader.d.ts.map +1 -1
  60. package/dist/theme/components/DangerZone.d.ts.map +1 -1
  61. package/dist/theme/components/DangerZone.js +12 -15
  62. package/dist/theme/components/EmptyState.d.ts.map +1 -1
  63. package/dist/theme/components/PageForm.d.ts.map +1 -1
  64. package/dist/theme/components/PageForm.js +58 -56
  65. package/dist/theme/components/Pagination.d.ts.map +1 -1
  66. package/dist/theme/components/Pagination.js +22 -25
  67. package/dist/theme/components/PostForm.d.ts +0 -1
  68. package/dist/theme/components/PostForm.d.ts.map +1 -1
  69. package/dist/theme/components/PostForm.js +85 -77
  70. package/dist/theme/components/PostList.d.ts.map +1 -1
  71. package/dist/theme/components/PostList.js +17 -17
  72. package/dist/theme/components/ThreadView.d.ts.map +1 -1
  73. package/dist/theme/components/ThreadView.js +15 -18
  74. package/dist/theme/components/TypeBadge.d.ts.map +1 -1
  75. package/dist/theme/components/TypeBadge.js +20 -20
  76. package/dist/theme/components/VisibilityBadge.d.ts.map +1 -1
  77. package/dist/theme/components/VisibilityBadge.js +14 -14
  78. package/dist/theme/components/index.d.ts +2 -2
  79. package/dist/theme/components/index.d.ts.map +1 -1
  80. package/dist/theme/layouts/BaseLayout.d.ts.map +1 -1
  81. package/dist/theme/layouts/BaseLayout.js +4 -2
  82. package/dist/theme/layouts/DashLayout.d.ts.map +1 -1
  83. package/dist/theme/layouts/DashLayout.js +29 -29
  84. package/dist/types/lingui-react-macro.d.js +9 -0
  85. package/dist/types.d.ts +2 -0
  86. package/dist/types.d.ts.map +1 -1
  87. package/dist/vendor/datastar.js +1606 -0
  88. package/package.json +7 -15
  89. package/src/app.tsx +222 -59
  90. package/src/auth.ts +5 -1
  91. package/src/client.ts +1 -1
  92. package/src/db/migrations/meta/0000_snapshot.json +16 -47
  93. package/src/db/migrations/meta/_journal.json +1 -1
  94. package/src/db/schema.ts +22 -7
  95. package/src/i18n/EXAMPLES.md +45 -23
  96. package/src/i18n/README.md +39 -25
  97. package/src/i18n/context.tsx +1 -4
  98. package/src/i18n/detect.ts +1 -67
  99. package/src/i18n/i18n.ts +15 -19
  100. package/src/i18n/index.ts +0 -3
  101. package/src/i18n/middleware.ts +12 -24
  102. package/src/lib/constants.ts +2 -1
  103. package/src/lib/image-processor.ts +14 -6
  104. package/src/lib/image.ts +2 -2
  105. package/src/lib/schemas.ts +7 -3
  106. package/src/lib/sse.ts +133 -51
  107. package/src/middleware/auth.ts +6 -2
  108. package/src/routes/api/posts.ts +9 -9
  109. package/src/routes/api/upload.ts +39 -10
  110. package/src/routes/dash/collections.tsx +249 -81
  111. package/src/routes/dash/index.tsx +22 -7
  112. package/src/routes/dash/media.tsx +94 -24
  113. package/src/routes/dash/pages.tsx +132 -54
  114. package/src/routes/dash/posts.tsx +99 -57
  115. package/src/routes/dash/redirects.tsx +117 -36
  116. package/src/routes/dash/settings.tsx +268 -55
  117. package/src/routes/feed/rss.ts +6 -4
  118. package/src/routes/pages/archive.tsx +78 -24
  119. package/src/routes/pages/collection.tsx +32 -8
  120. package/src/routes/pages/home.tsx +38 -10
  121. package/src/routes/pages/page.tsx +15 -5
  122. package/src/routes/pages/post.tsx +17 -6
  123. package/src/routes/pages/search.tsx +50 -13
  124. package/src/services/collection.ts +29 -8
  125. package/src/services/index.ts +4 -1
  126. package/src/services/media.ts +15 -3
  127. package/src/services/post.ts +37 -10
  128. package/src/services/redirect.ts +4 -1
  129. package/src/services/settings.ts +14 -3
  130. package/src/theme/components/ActionButtons.tsx +31 -15
  131. package/src/theme/components/CrudPageHeader.tsx +3 -4
  132. package/src/theme/components/DangerZone.tsx +19 -13
  133. package/src/theme/components/EmptyState.tsx +1 -5
  134. package/src/theme/components/PageForm.tsx +80 -25
  135. package/src/theme/components/Pagination.tsx +34 -31
  136. package/src/theme/components/PostForm.tsx +91 -27
  137. package/src/theme/components/PostList.tsx +23 -6
  138. package/src/theme/components/ThreadView.tsx +25 -10
  139. package/src/theme/components/TypeBadge.tsx +13 -4
  140. package/src/theme/components/VisibilityBadge.tsx +17 -5
  141. package/src/theme/components/index.ts +12 -2
  142. package/src/theme/layouts/BaseLayout.tsx +6 -5
  143. package/src/theme/layouts/DashLayout.tsx +71 -18
  144. package/src/types/lingui-react-macro.d.ts +34 -0
  145. package/src/types.ts +16 -4
  146. package/src/vendor/datastar.js +9 -0
  147. package/src/vendor/datastar.js.map +7 -0
  148. package/dist/plugin.d.ts +0 -3
  149. package/dist/plugin.d.ts.map +0 -1
  150. package/dist/plugin.js +0 -20
  151. package/dist/tailwind.d.ts +0 -12
  152. package/dist/tailwind.d.ts.map +0 -1
  153. package/dist/tailwind.js +0 -15
@@ -3,91 +3,304 @@
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 } from "../../types.js";
8
8
  import type { AppVariables } from "../../app.js";
9
9
  import { DashLayout } from "../../theme/layouts/index.js";
10
+ import { sse } from "../../lib/sse.js";
10
11
 
11
12
  type Env = { Bindings: Bindings; Variables: AppVariables };
12
13
 
13
14
  export const settingsRoutes = new Hono<Env>();
14
15
 
15
- function SettingsContent({ siteName, siteDescription, siteLanguage }: { siteName: string; siteDescription: string; siteLanguage: string }) {
16
+ function SettingsContent({
17
+ siteName,
18
+ siteDescription,
19
+ siteLanguage,
20
+ saved,
21
+ }: {
22
+ siteName: string;
23
+ siteDescription: string;
24
+ siteLanguage: string;
25
+ saved: boolean;
26
+ }) {
16
27
  const { t } = useLingui();
17
28
 
29
+ const generalSignals = JSON.stringify({
30
+ siteName,
31
+ siteDescription,
32
+ siteLanguage,
33
+ }).replace(/</g, "\\u003c");
34
+
18
35
  return (
19
36
  <>
20
- <h1 class="text-2xl font-semibold mb-6">{t({ message: "Settings", comment: "@context: Dashboard heading" })}</h1>
21
-
22
- <form method="post" action="/dash/settings" class="flex flex-col gap-6 max-w-lg">
23
- <div class="card">
24
- <header>
25
- <h2>{t({ message: "General", comment: "@context: Settings section heading" })}</h2>
26
- </header>
27
- <section class="flex flex-col gap-4">
28
- <div class="field">
29
- <label class="label">{t({ message: "Site Name", comment: "@context: Settings form field" })}</label>
30
- <input type="text" name="siteName" class="input" value={siteName} required />
31
- </div>
32
-
33
- <div class="field">
34
- <label class="label">{t({ message: "Site Description", comment: "@context: Settings form field" })}</label>
35
- <textarea name="siteDescription" class="textarea" rows={3}>
36
- {siteDescription}
37
- </textarea>
38
- </div>
39
-
40
- <div class="field">
41
- <label class="label">{t({ message: "Language", comment: "@context: Settings form field" })}</label>
42
- <select name="siteLanguage" class="select">
43
- <option value="en" selected={siteLanguage === "en"}>
44
- English
45
- </option>
46
- <option value="zh-Hans" selected={siteLanguage === "zh-Hans"}>
47
- 简体中文
48
- </option>
49
- <option value="zh-Hant" selected={siteLanguage === "zh-Hant"}>
50
- 繁體中文
51
- </option>
52
- </select>
53
- </div>
54
- </section>
37
+ <h1 class="text-2xl font-semibold mb-6">
38
+ {t({ message: "Settings", comment: "@context: Dashboard heading" })}
39
+ </h1>
40
+
41
+ {saved && (
42
+ <div
43
+ id="settings-saved-toast"
44
+ class="mb-4 max-w-lg rounded-md border border-green-200 bg-green-50 p-3 text-sm text-green-800 transition-opacity duration-300 dark:border-green-800 dark:bg-green-950 dark:text-green-200"
45
+ data-init={`console.log('[toast] init fired at', Date.now()); history.replaceState({}, '', '/dash/settings'); setTimeout(() => { console.log('[toast] hiding at', Date.now()); const el = document.getElementById('settings-saved-toast'); if (el) { el.style.opacity = '0'; setTimeout(() => el.remove(), 300) } }, 3000)`}
46
+ >
47
+ {t({
48
+ message: "Settings saved successfully.",
49
+ comment: "@context: Toast message after saving settings",
50
+ })}
55
51
  </div>
52
+ )}
53
+
54
+ <div class="flex flex-col gap-6 max-w-lg">
55
+ <form
56
+ data-signals={generalSignals}
57
+ data-on:submit__prevent="@post('/dash/settings')"
58
+ >
59
+ <div id="settings-message"></div>
60
+ <div class="card">
61
+ <header>
62
+ <h2>
63
+ {t({
64
+ message: "General",
65
+ comment: "@context: Settings section heading",
66
+ })}
67
+ </h2>
68
+ </header>
69
+ <section class="flex flex-col gap-4">
70
+ <div class="field">
71
+ <label class="label">
72
+ {t({
73
+ message: "Site Name",
74
+ comment: "@context: Settings form field",
75
+ })}
76
+ </label>
77
+ <input
78
+ type="text"
79
+ data-bind="siteName"
80
+ class="input"
81
+ required
82
+ />
83
+ </div>
84
+
85
+ <div class="field">
86
+ <label class="label">
87
+ {t({
88
+ message: "Site Description",
89
+ comment: "@context: Settings form field",
90
+ })}
91
+ </label>
92
+ <textarea data-bind="siteDescription" class="textarea" rows={3}>
93
+ {siteDescription}
94
+ </textarea>
95
+ </div>
96
+
97
+ <div class="field">
98
+ <label class="label">
99
+ {t({
100
+ message: "Language",
101
+ comment: "@context: Settings form field",
102
+ })}
103
+ </label>
104
+ <select data-bind="siteLanguage" class="select">
105
+ <option value="en" selected={siteLanguage === "en"}>
106
+ English
107
+ </option>
108
+ <option value="zh-Hans" selected={siteLanguage === "zh-Hans"}>
109
+ 简体中文
110
+ </option>
111
+ <option value="zh-Hant" selected={siteLanguage === "zh-Hant"}>
112
+ 繁體中文
113
+ </option>
114
+ </select>
115
+ </div>
116
+ </section>
117
+ </div>
118
+
119
+ <button type="submit" class="btn mt-4">
120
+ {t({
121
+ message: "Save Settings",
122
+ comment: "@context: Button to save settings",
123
+ })}
124
+ </button>
125
+ </form>
126
+
127
+ <form
128
+ data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
129
+ data-on:submit__prevent="@post('/dash/settings/password')"
130
+ >
131
+ <div id="password-message"></div>
132
+ <div class="card">
133
+ <header>
134
+ <h2>
135
+ {t({
136
+ message: "Change Password",
137
+ comment: "@context: Settings section heading",
138
+ })}
139
+ </h2>
140
+ </header>
141
+ <section class="flex flex-col gap-4">
142
+ <div class="field">
143
+ <label class="label">
144
+ {t({
145
+ message: "Current Password",
146
+ comment: "@context: Password form field",
147
+ })}
148
+ </label>
149
+ <input
150
+ type="password"
151
+ data-bind="currentPassword"
152
+ class="input"
153
+ required
154
+ autocomplete="current-password"
155
+ />
156
+ </div>
157
+
158
+ <div class="field">
159
+ <label class="label">
160
+ {t({
161
+ message: "New Password",
162
+ comment: "@context: Password form field",
163
+ })}
164
+ </label>
165
+ <input
166
+ type="password"
167
+ data-bind="newPassword"
168
+ class="input"
169
+ required
170
+ minlength={8}
171
+ autocomplete="new-password"
172
+ />
173
+ </div>
174
+
175
+ <div class="field">
176
+ <label class="label">
177
+ {t({
178
+ message: "Confirm New Password",
179
+ comment: "@context: Password form field",
180
+ })}
181
+ </label>
182
+ <input
183
+ type="password"
184
+ data-bind="confirmPassword"
185
+ class="input"
186
+ required
187
+ minlength={8}
188
+ autocomplete="new-password"
189
+ />
190
+ </div>
191
+ </section>
192
+ </div>
56
193
 
57
- <button type="submit" class="btn">
58
- {t({ message: "Save Settings", comment: "@context: Button to save settings" })}
59
- </button>
60
- </form>
194
+ <button type="submit" class="btn mt-4">
195
+ {t({
196
+ message: "Change Password",
197
+ comment: "@context: Button to change password",
198
+ })}
199
+ </button>
200
+ </form>
201
+ </div>
61
202
  </>
62
203
  );
63
204
  }
64
205
 
65
206
  // Settings page
66
207
  settingsRoutes.get("/", async (c) => {
67
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
68
- const siteDescription = (await c.var.services.settings.get("SITE_DESCRIPTION")) ?? "";
69
- const siteLanguage = (await c.var.services.settings.get("SITE_LANGUAGE")) ?? "en";
208
+ const all = await c.var.services.settings.getAll();
209
+ const siteName = all["SITE_NAME"] ?? "Jant";
210
+ const siteDescription = all["SITE_DESCRIPTION"] ?? "";
211
+ const siteLanguage = all["SITE_LANGUAGE"] ?? "en";
212
+ const saved = c.req.query("saved") !== undefined;
70
213
 
71
214
  return c.html(
72
- <DashLayout c={c} title="Settings" siteName={siteName} currentPath="/dash/settings">
73
- <SettingsContent siteName={siteName} siteDescription={siteDescription} siteLanguage={siteLanguage} />
74
- </DashLayout>
215
+ <DashLayout
216
+ c={c}
217
+ title="Settings"
218
+ siteName={siteName}
219
+ currentPath="/dash/settings"
220
+ >
221
+ <SettingsContent
222
+ siteName={siteName}
223
+ siteDescription={siteDescription}
224
+ siteLanguage={siteLanguage}
225
+ saved={saved}
226
+ />
227
+ </DashLayout>,
75
228
  );
76
229
  });
77
230
 
78
231
  // Update settings
79
232
  settingsRoutes.post("/", async (c) => {
80
- const formData = await c.req.formData();
233
+ const body = await c.req.json<{
234
+ siteName: string;
235
+ siteDescription: string;
236
+ siteLanguage: string;
237
+ }>();
81
238
 
82
- const siteName = formData.get("siteName") as string;
83
- const siteDescription = formData.get("siteDescription") as string;
84
- const siteLanguage = formData.get("siteLanguage") as string;
239
+ const oldLanguage =
240
+ (await c.var.services.settings.get("SITE_LANGUAGE")) ?? "en";
85
241
 
86
242
  await c.var.services.settings.setMany({
87
- SITE_NAME: siteName,
88
- SITE_DESCRIPTION: siteDescription,
89
- SITE_LANGUAGE: siteLanguage,
243
+ SITE_NAME: body.siteName,
244
+ SITE_DESCRIPTION: body.siteDescription,
245
+ SITE_LANGUAGE: body.siteLanguage,
90
246
  });
91
247
 
92
- return c.redirect("/dash/settings?saved=1");
248
+ const languageChanged = oldLanguage !== body.siteLanguage;
249
+
250
+ return sse(c, async (stream) => {
251
+ if (languageChanged) {
252
+ // Language changed - full reload needed to update all UI text
253
+ await stream.redirect("/dash/settings?saved");
254
+ } else {
255
+ // No language change - show inline success message
256
+ await stream.patchElements(
257
+ '<div id="settings-message"><div class="rounded-md border border-green-200 bg-green-50 p-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200 mb-4 transition-opacity duration-300" data-init="setTimeout(() => { el.style.opacity = \'0\'; setTimeout(() => el.remove(), 300) }, 3000)">Settings saved successfully.</div></div>',
258
+ );
259
+ }
260
+ });
261
+ });
262
+
263
+ // Change password
264
+ settingsRoutes.post("/password", async (c) => {
265
+ const body = await c.req.json<{
266
+ currentPassword: string;
267
+ newPassword: string;
268
+ confirmPassword: string;
269
+ }>();
270
+
271
+ if (body.newPassword !== body.confirmPassword) {
272
+ return sse(c, async (stream) => {
273
+ await stream.patchElements(
274
+ '<div id="password-message"><div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200 mb-4">Passwords do not match.</div></div>',
275
+ );
276
+ });
277
+ }
278
+
279
+ try {
280
+ await c.var.auth.api.changePassword({
281
+ body: {
282
+ currentPassword: body.currentPassword,
283
+ newPassword: body.newPassword,
284
+ revokeOtherSessions: false,
285
+ },
286
+ headers: c.req.raw.headers,
287
+ });
288
+ } catch {
289
+ return sse(c, async (stream) => {
290
+ await stream.patchElements(
291
+ '<div id="password-message"><div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200 mb-4">Current password is incorrect.</div></div>',
292
+ );
293
+ });
294
+ }
295
+
296
+ return sse(c, async (stream) => {
297
+ await stream.patchElements(
298
+ '<div id="password-message"><div class="rounded-md border border-green-200 bg-green-50 p-3 text-sm text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200 mb-4">Password changed successfully.</div></div>',
299
+ );
300
+ await stream.patchSignals({
301
+ currentPassword: "",
302
+ newPassword: "",
303
+ confirmPassword: "",
304
+ });
305
+ });
93
306
  });
@@ -14,8 +14,9 @@ export const rssRoutes = new Hono<Env>();
14
14
 
15
15
  // RSS 2.0 Feed - main feed at /feed
16
16
  rssRoutes.get("/", async (c) => {
17
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
18
- const siteDescription = (await c.var.services.settings.get("SITE_DESCRIPTION")) ?? "";
17
+ const all = await c.var.services.settings.getAll();
18
+ const siteName = all["SITE_NAME"] ?? "Jant";
19
+ const siteDescription = all["SITE_DESCRIPTION"] ?? "";
19
20
  const siteUrl = c.env.SITE_URL;
20
21
 
21
22
  const posts = await c.var.services.posts.list({
@@ -61,8 +62,9 @@ rssRoutes.get("/", async (c) => {
61
62
 
62
63
  // Atom Feed
63
64
  rssRoutes.get("/atom.xml", async (c) => {
64
- const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
65
- const siteDescription = (await c.var.services.settings.get("SITE_DESCRIPTION")) ?? "";
65
+ const all = await c.var.services.settings.getAll();
66
+ const siteName = all["SITE_NAME"] ?? "Jant";
67
+ const siteDescription = all["SITE_DESCRIPTION"] ?? "";
66
68
  const siteUrl = c.env.SITE_URL;
67
69
 
68
70
  const posts = await c.var.services.posts.list({
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import { useLingui } from "../../i18n/index.js";
8
+ import { useLingui } from "@lingui/react/macro";
9
9
  import type { Bindings, Post, PostType } from "../../types.js";
10
10
  import type { AppVariables } from "../../app.js";
11
11
  import { BaseLayout } from "../../theme/layouts/index.js";
@@ -24,10 +24,19 @@ function getTypeLabel(type: string): string {
24
24
  const { t } = useLingui();
25
25
  const labels: Record<string, string> = {
26
26
  note: t({ message: "Note", comment: "@context: Post type label - note" }),
27
- article: t({ message: "Article", comment: "@context: Post type label - article" }),
27
+ article: t({
28
+ message: "Article",
29
+ comment: "@context: Post type label - article",
30
+ }),
28
31
  link: t({ message: "Link", comment: "@context: Post type label - link" }),
29
- quote: t({ message: "Quote", comment: "@context: Post type label - quote" }),
30
- image: t({ message: "Image", comment: "@context: Post type label - image" }),
32
+ quote: t({
33
+ message: "Quote",
34
+ comment: "@context: Post type label - quote",
35
+ }),
36
+ image: t({
37
+ message: "Image",
38
+ comment: "@context: Post type label - image",
39
+ }),
31
40
  page: t({ message: "Page", comment: "@context: Post type label - page" }),
32
41
  };
33
42
  return labels[type] ?? type;
@@ -36,12 +45,30 @@ function getTypeLabel(type: string): string {
36
45
  function getTypeLabelPlural(type: string): string {
37
46
  const { t } = useLingui();
38
47
  const labels: Record<string, string> = {
39
- note: t({ message: "Notes", comment: "@context: Post type label plural - notes" }),
40
- article: t({ message: "Articles", comment: "@context: Post type label plural - articles" }),
41
- link: t({ message: "Links", comment: "@context: Post type label plural - links" }),
42
- quote: t({ message: "Quotes", comment: "@context: Post type label plural - quotes" }),
43
- image: t({ message: "Images", comment: "@context: Post type label plural - images" }),
44
- page: t({ message: "Pages", comment: "@context: Post type label plural - pages" }),
48
+ note: t({
49
+ message: "Notes",
50
+ comment: "@context: Post type label plural - notes",
51
+ }),
52
+ article: t({
53
+ message: "Articles",
54
+ comment: "@context: Post type label plural - articles",
55
+ }),
56
+ link: t({
57
+ message: "Links",
58
+ comment: "@context: Post type label plural - links",
59
+ }),
60
+ quote: t({
61
+ message: "Quotes",
62
+ comment: "@context: Post type label plural - quotes",
63
+ }),
64
+ image: t({
65
+ message: "Images",
66
+ comment: "@context: Post type label plural - images",
67
+ }),
68
+ page: t({
69
+ message: "Pages",
70
+ comment: "@context: Post type label plural - pages",
71
+ }),
45
72
  };
46
73
  return labels[type] ?? `${type}s`;
47
74
  }
@@ -84,7 +111,10 @@ function ArchiveContent({
84
111
  href="/archive"
85
112
  class={`badge ${!type ? "badge-primary" : "badge-outline"}`}
86
113
  >
87
- {t({ message: "All", comment: "@context: Archive filter - all types" })}
114
+ {t({
115
+ message: "All",
116
+ comment: "@context: Archive filter - all types",
117
+ })}
88
118
  </a>
89
119
  {POST_TYPES.filter((t) => t !== "page").map((typeKey) => (
90
120
  <a
@@ -101,7 +131,10 @@ function ArchiveContent({
101
131
  <main>
102
132
  {displayPosts.length === 0 ? (
103
133
  <p class="text-muted-foreground">
104
- {t({ message: "No posts found.", comment: "@context: Archive empty state" })}
134
+ {t({
135
+ message: "No posts found.",
136
+ comment: "@context: Archive empty state",
137
+ })}
105
138
  </p>
106
139
  ) : (
107
140
  Array.from(grouped.entries()).map(([yearMonth, monthPosts]) => (
@@ -125,16 +158,31 @@ function ArchiveContent({
125
158
  href={`/p/${sqid.encode(post.id)}`}
126
159
  class="hover:underline"
127
160
  >
128
- {post.title || post.content?.slice(0, 80) || `Post #${post.id}`}
161
+ {post.title ||
162
+ post.content?.slice(0, 80) ||
163
+ `Post #${post.id}`}
129
164
  </a>
130
165
  {!type && (
131
- <span class="ml-2 badge-outline text-xs">{getTypeLabel(post.type)}</span>
166
+ <span class="ml-2 badge-outline text-xs">
167
+ {getTypeLabel(post.type)}
168
+ </span>
132
169
  )}
133
170
  {replyCount && replyCount > 0 && (
134
171
  <span class="ml-2 text-xs text-muted-foreground">
135
- ({replyCount === 1
136
- ? t({ message: "1 reply", comment: "@context: Archive post reply indicator - single" })
137
- : t({ message: "{count} replies", comment: "@context: Archive post reply indicator - plural", values: { count: String(replyCount) } })})
172
+ (
173
+ {replyCount === 1
174
+ ? t({
175
+ message: "1 reply",
176
+ comment:
177
+ "@context: Archive post reply indicator - single",
178
+ })
179
+ : t({
180
+ message: "{count} replies",
181
+ comment:
182
+ "@context: Archive post reply indicator - plural",
183
+ values: { count: String(replyCount) },
184
+ })}
185
+ )
138
186
  </span>
139
187
  )}
140
188
  </div>
@@ -156,7 +204,11 @@ function ArchiveContent({
156
204
 
157
205
  <nav class="mt-4">
158
206
  <a href="/" class="text-sm hover:underline">
159
- {t({ message: "Back to home", comment: "@context: Navigation link back to home page" })}
207
+ ←{" "}
208
+ {t({
209
+ message: "Back to home",
210
+ comment: "@context: Navigation link back to home page",
211
+ })}
160
212
  </a>
161
213
  </nav>
162
214
  </div>
@@ -166,7 +218,8 @@ function ArchiveContent({
166
218
  // Archive page - all posts
167
219
  archiveRoutes.get("/", async (c) => {
168
220
  const typeParam = c.req.query("type") as PostType | undefined;
169
- const type = typeParam && POST_TYPES.includes(typeParam) ? typeParam : undefined;
221
+ const type =
222
+ typeParam && POST_TYPES.includes(typeParam) ? typeParam : undefined;
170
223
 
171
224
  // Parse cursor
172
225
  const cursorParam = c.req.query("cursor");
@@ -191,10 +244,11 @@ archiveRoutes.get("/", async (c) => {
191
244
  const replyCounts = await c.var.services.posts.getReplyCounts(postIds);
192
245
 
193
246
  // Get next cursor
194
- const nextCursor = hasMore && displayPosts.length > 0
195
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Length check above guarantees element exists
196
- ? displayPosts[displayPosts.length - 1]!.id
197
- : undefined;
247
+ const nextCursor =
248
+ hasMore && displayPosts.length > 0
249
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Length check above guarantees element exists
250
+ displayPosts[displayPosts.length - 1]!.id
251
+ : undefined;
198
252
 
199
253
  // Group posts by year-month
200
254
  const grouped = new Map<string, typeof displayPosts>();
@@ -218,6 +272,6 @@ archiveRoutes.get("/", async (c) => {
218
272
  grouped={grouped}
219
273
  replyCounts={replyCounts}
220
274
  />
221
- </BaseLayout>
275
+ </BaseLayout>,
222
276
  );
223
277
  });
@@ -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 { BaseLayout } from "../../theme/layouts/index.js";
@@ -14,7 +14,13 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
14
14
 
15
15
  export const collectionRoutes = new Hono<Env>();
16
16
 
17
- function CollectionContent({ collection, posts }: { collection: Collection; posts: Post[] }) {
17
+ function CollectionContent({
18
+ collection,
19
+ posts,
20
+ }: {
21
+ collection: Collection;
22
+ posts: Post[];
23
+ }) {
18
24
  const { t } = useLingui();
19
25
 
20
26
  return (
@@ -28,13 +34,21 @@ function CollectionContent({ collection, posts }: { collection: Collection; post
28
34
 
29
35
  <main class="flex flex-col gap-6">
30
36
  {posts.length === 0 ? (
31
- <p class="text-muted-foreground">{t({ message: "No posts in this collection.", comment: "@context: Empty state message" })}</p>
37
+ <p class="text-muted-foreground">
38
+ {t({
39
+ message: "No posts in this collection.",
40
+ comment: "@context: Empty state message",
41
+ })}
42
+ </p>
32
43
  ) : (
33
44
  posts.map((post) => (
34
45
  <article key={post.id} class="h-entry">
35
46
  {post.title && (
36
47
  <h2 class="p-name text-lg font-medium mb-2">
37
- <a href={`/p/${sqid.encode(post.id)}`} class="u-url hover:underline">
48
+ <a
49
+ href={`/p/${sqid.encode(post.id)}`}
50
+ class="u-url hover:underline"
51
+ >
38
52
  {post.title}
39
53
  </a>
40
54
  </h2>
@@ -44,7 +58,10 @@ function CollectionContent({ collection, posts }: { collection: Collection; post
44
58
  dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
45
59
  />
46
60
  <footer class="mt-2 text-sm text-muted-foreground">
47
- <time class="dt-published" datetime={time.toISOString(post.publishedAt)}>
61
+ <time
62
+ class="dt-published"
63
+ datetime={time.toISOString(post.publishedAt)}
64
+ >
48
65
  {time.formatDate(post.publishedAt)}
49
66
  </time>
50
67
  </footer>
@@ -55,7 +72,10 @@ function CollectionContent({ collection, posts }: { collection: Collection; post
55
72
 
56
73
  <nav class="mt-8">
57
74
  <a href="/" class="text-sm hover:underline">
58
- {t({ message: "← Back to home", comment: "@context: Navigation link" })}
75
+ {t({
76
+ message: "← Back to home",
77
+ comment: "@context: Navigation link",
78
+ })}
59
79
  </a>
60
80
  </nav>
61
81
  </div>
@@ -72,8 +92,12 @@ collectionRoutes.get("/:path", async (c) => {
72
92
  const siteName = (await c.var.services.settings.get("SITE_NAME")) ?? "Jant";
73
93
 
74
94
  return c.html(
75
- <BaseLayout title={`${collection.title} - ${siteName}`} description={collection.description ?? undefined} c={c}>
95
+ <BaseLayout
96
+ title={`${collection.title} - ${siteName}`}
97
+ description={collection.description ?? undefined}
98
+ c={c}
99
+ >
76
100
  <CollectionContent collection={collection} posts={posts} />
77
- </BaseLayout>
101
+ </BaseLayout>,
78
102
  );
79
103
  });