@jant/core 0.3.24 → 0.3.26

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 (277) hide show
  1. package/dist/app.js +101 -571
  2. package/dist/client.js +1 -0
  3. package/dist/db/schema.js +1 -1
  4. package/dist/i18n/locales/en.js +1 -1
  5. package/dist/i18n/locales/zh-Hans.js +1 -1
  6. package/dist/i18n/locales/zh-Hant.js +1 -1
  7. package/dist/index.js +3 -9
  8. package/dist/lib/avatar-upload.js +134 -0
  9. package/dist/lib/config.js +39 -0
  10. package/dist/lib/constants.js +10 -9
  11. package/dist/lib/favicon.js +102 -0
  12. package/dist/lib/image.js +13 -17
  13. package/dist/lib/media-helpers.js +2 -2
  14. package/dist/lib/nav-reorder.js +1 -1
  15. package/dist/lib/navigation.js +48 -3
  16. package/dist/lib/pagination.js +44 -0
  17. package/dist/lib/render.js +16 -11
  18. package/dist/lib/schemas.js +34 -3
  19. package/dist/lib/theme.js +4 -4
  20. package/dist/lib/timeline.js +24 -48
  21. package/dist/lib/timezones.js +388 -0
  22. package/dist/lib/view.js +3 -3
  23. package/dist/routes/api/collections.js +124 -0
  24. package/dist/routes/api/nav-items.js +104 -0
  25. package/dist/routes/api/pages.js +91 -0
  26. package/dist/routes/api/posts.js +3 -3
  27. package/dist/routes/api/search.js +2 -2
  28. package/dist/routes/api/settings.js +68 -0
  29. package/dist/routes/api/upload.js +3 -3
  30. package/dist/routes/auth/reset.js +221 -0
  31. package/dist/routes/auth/setup.js +194 -0
  32. package/dist/routes/auth/signin.js +176 -0
  33. package/dist/routes/compose.js +48 -0
  34. package/dist/routes/dash/collections.js +24 -416
  35. package/dist/routes/dash/index.js +1 -1
  36. package/dist/routes/dash/media.js +13 -393
  37. package/dist/routes/dash/pages.js +112 -86
  38. package/dist/routes/dash/posts.js +3 -5
  39. package/dist/routes/dash/redirects.js +20 -14
  40. package/dist/routes/dash/settings.js +213 -518
  41. package/dist/routes/feed/rss.js +4 -3
  42. package/dist/routes/feed/sitemap.js +5 -3
  43. package/dist/routes/pages/archive.js +3 -6
  44. package/dist/routes/pages/collection.js +3 -6
  45. package/dist/routes/pages/collections.js +28 -0
  46. package/dist/routes/pages/featured.js +36 -0
  47. package/dist/routes/pages/home.js +33 -49
  48. package/dist/routes/pages/latest.js +45 -0
  49. package/dist/routes/pages/page.js +29 -32
  50. package/dist/routes/pages/post.js +3 -6
  51. package/dist/routes/pages/search.js +3 -6
  52. package/dist/services/page.js +5 -1
  53. package/dist/services/post.js +45 -31
  54. package/dist/services/search.js +1 -1
  55. package/dist/types/bindings.js +3 -0
  56. package/dist/types/config.js +147 -0
  57. package/dist/types/constants.js +27 -0
  58. package/dist/types/entities.js +3 -0
  59. package/dist/types/operations.js +3 -0
  60. package/dist/types/props.js +3 -0
  61. package/dist/types/views.js +5 -0
  62. package/dist/types.js +8 -111
  63. package/dist/{theme → ui}/color-themes.js +33 -33
  64. package/dist/ui/compose/ComposeDialog.js +467 -0
  65. package/dist/ui/compose/ComposePrompt.js +55 -0
  66. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +1 -2
  67. package/dist/{theme/components → ui/dash}/PageForm.js +21 -15
  68. package/dist/{theme/components → ui/dash}/PostForm.js +22 -43
  69. package/dist/{theme/components → ui/dash}/PostList.js +6 -6
  70. package/dist/{theme/components/VisibilityBadge.js → ui/dash/StatusBadge.js} +1 -2
  71. package/dist/ui/dash/collections/CollectionForm.js +152 -0
  72. package/dist/ui/dash/collections/CollectionsListContent.js +68 -0
  73. package/dist/ui/dash/collections/ViewCollectionContent.js +96 -0
  74. package/dist/{theme/components → ui/dash}/index.js +3 -6
  75. package/dist/ui/dash/media/MediaListContent.js +166 -0
  76. package/dist/ui/dash/media/ViewMediaContent.js +212 -0
  77. package/dist/ui/dash/pages/LinkFormContent.js +130 -0
  78. package/dist/ui/dash/pages/UnifiedPagesContent.js +193 -0
  79. package/dist/ui/dash/settings/AccountContent.js +209 -0
  80. package/dist/ui/dash/settings/AppearanceContent.js +259 -0
  81. package/dist/ui/dash/settings/GeneralContent.js +536 -0
  82. package/dist/ui/dash/settings/SettingsNav.js +41 -0
  83. package/dist/{themes/threads/timeline → ui/feed}/LinkCard.js +6 -2
  84. package/dist/{themes/threads/timeline → ui/feed}/NoteCard.js +11 -6
  85. package/dist/{themes/threads/timeline → ui/feed}/QuoteCard.js +10 -6
  86. package/dist/{themes/threads/timeline → ui/feed}/ThreadPreview.js +7 -9
  87. package/dist/ui/feed/TimelineFeed.js +41 -0
  88. package/dist/ui/feed/TimelineItem.js +27 -0
  89. package/dist/ui/font-themes.js +36 -0
  90. package/dist/{theme → ui}/layouts/BaseLayout.js +34 -2
  91. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  92. package/dist/ui/layouts/SiteLayout.js +169 -0
  93. package/dist/{themes/threads → ui}/pages/ArchivePage.js +16 -14
  94. package/dist/{themes/threads → ui}/pages/CollectionPage.js +6 -1
  95. package/dist/ui/pages/CollectionsPage.js +76 -0
  96. package/dist/ui/pages/FeaturedPage.js +24 -0
  97. package/dist/ui/pages/HomePage.js +24 -0
  98. package/dist/{themes/threads → ui}/pages/PostPage.js +13 -8
  99. package/dist/{themes/threads → ui}/pages/SearchPage.js +9 -7
  100. package/dist/{themes/threads → ui}/pages/SinglePage.js +3 -2
  101. package/dist/{theme/components → ui/shared}/MediaGallery.js +1 -1
  102. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  103. package/dist/{theme/components → ui/shared}/ThreadView.js +2 -2
  104. package/dist/ui/shared/index.js +5 -0
  105. package/package.json +1 -9
  106. package/src/__tests__/helpers/db.ts +3 -0
  107. package/src/app.tsx +131 -561
  108. package/src/client.ts +1 -0
  109. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  110. package/src/db/migrations/meta/_journal.json +7 -0
  111. package/src/db/schema.ts +1 -1
  112. package/src/i18n/locales/en.po +477 -261
  113. package/src/i18n/locales/en.ts +1 -1
  114. package/src/i18n/locales/zh-Hans.po +477 -261
  115. package/src/i18n/locales/zh-Hans.ts +1 -1
  116. package/src/i18n/locales/zh-Hant.po +477 -261
  117. package/src/i18n/locales/zh-Hant.ts +1 -1
  118. package/src/index.ts +7 -36
  119. package/src/lib/__tests__/config.test.ts +192 -0
  120. package/src/lib/__tests__/favicon.test.ts +151 -0
  121. package/src/lib/__tests__/image.test.ts +2 -6
  122. package/src/lib/__tests__/schemas.test.ts +60 -19
  123. package/src/lib/__tests__/timeline.test.ts +45 -81
  124. package/src/lib/__tests__/timezones.test.ts +61 -0
  125. package/src/lib/__tests__/view.test.ts +15 -9
  126. package/src/lib/avatar-upload.ts +165 -0
  127. package/src/lib/config.ts +47 -0
  128. package/src/lib/constants.ts +19 -10
  129. package/src/lib/favicon.ts +115 -0
  130. package/src/lib/image.ts +13 -21
  131. package/src/lib/media-helpers.ts +2 -2
  132. package/src/lib/nav-reorder.ts +1 -1
  133. package/src/lib/navigation.ts +73 -4
  134. package/src/lib/pagination.ts +50 -0
  135. package/src/lib/render.tsx +22 -15
  136. package/src/lib/schemas.ts +47 -6
  137. package/src/lib/theme.ts +5 -5
  138. package/src/lib/timeline.ts +28 -57
  139. package/src/lib/timezones.ts +325 -0
  140. package/src/lib/view.ts +3 -3
  141. package/src/preset.css +2 -1
  142. package/src/routes/__tests__/compose.test.ts +199 -0
  143. package/src/routes/api/__tests__/collections.test.ts +249 -0
  144. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  145. package/src/routes/api/__tests__/pages.test.ts +218 -0
  146. package/src/routes/api/__tests__/settings.test.ts +132 -0
  147. package/src/routes/api/collections.ts +143 -0
  148. package/src/routes/api/nav-items.ts +115 -0
  149. package/src/routes/api/pages.ts +101 -0
  150. package/src/routes/api/posts.ts +3 -3
  151. package/src/routes/api/search.ts +2 -2
  152. package/src/routes/api/settings.ts +91 -0
  153. package/src/routes/api/upload.ts +2 -3
  154. package/src/routes/auth/reset.tsx +239 -0
  155. package/src/routes/auth/setup.tsx +189 -0
  156. package/src/routes/auth/signin.tsx +163 -0
  157. package/src/routes/compose.ts +63 -0
  158. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  159. package/src/routes/dash/__tests__/settings-avatar.test.ts +89 -0
  160. package/src/routes/dash/collections.tsx +18 -367
  161. package/src/routes/dash/index.tsx +1 -1
  162. package/src/routes/dash/media.tsx +13 -415
  163. package/src/routes/dash/pages.tsx +131 -98
  164. package/src/routes/dash/posts.tsx +3 -7
  165. package/src/routes/dash/redirects.tsx +22 -16
  166. package/src/routes/dash/settings.tsx +265 -478
  167. package/src/routes/feed/__tests__/rss.test.ts +141 -0
  168. package/src/routes/feed/rss.ts +5 -3
  169. package/src/routes/feed/sitemap.ts +5 -3
  170. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  171. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  172. package/src/routes/pages/archive.tsx +2 -6
  173. package/src/routes/pages/collection.tsx +2 -6
  174. package/src/routes/pages/collections.tsx +36 -0
  175. package/src/routes/pages/featured.tsx +44 -0
  176. package/src/routes/pages/home.tsx +30 -53
  177. package/src/routes/pages/latest.tsx +59 -0
  178. package/src/routes/pages/page.tsx +28 -30
  179. package/src/routes/pages/post.tsx +2 -5
  180. package/src/routes/pages/search.tsx +2 -6
  181. package/src/services/__tests__/page.test.ts +106 -0
  182. package/src/services/__tests__/post.test.ts +114 -15
  183. package/src/services/page.ts +13 -1
  184. package/src/services/post.ts +58 -40
  185. package/src/services/search.ts +2 -2
  186. package/src/styles/components.css +0 -65
  187. package/src/styles/tokens.css +47 -0
  188. package/src/styles/ui.css +475 -0
  189. package/src/types/bindings.ts +30 -0
  190. package/src/types/config.ts +183 -0
  191. package/src/types/constants.ts +26 -0
  192. package/src/types/entities.ts +109 -0
  193. package/src/types/operations.ts +88 -0
  194. package/src/types/props.ts +115 -0
  195. package/src/types/views.ts +172 -0
  196. package/src/types.ts +8 -774
  197. package/src/ui/__tests__/font-themes.test.ts +34 -0
  198. package/src/{theme → ui}/color-themes.ts +34 -34
  199. package/src/ui/compose/ComposeDialog.tsx +414 -0
  200. package/src/ui/compose/ComposePrompt.tsx +55 -0
  201. package/src/{theme/components/TypeBadge.tsx → ui/dash/FormatBadge.tsx} +2 -3
  202. package/src/{theme/components → ui/dash}/PageForm.tsx +25 -19
  203. package/src/{theme/components → ui/dash}/PostForm.tsx +26 -45
  204. package/src/{theme/components → ui/dash}/PostList.tsx +7 -7
  205. package/src/{theme/components/VisibilityBadge.tsx → ui/dash/StatusBadge.tsx} +2 -3
  206. package/src/ui/dash/collections/CollectionForm.tsx +153 -0
  207. package/src/ui/dash/collections/CollectionsListContent.tsx +85 -0
  208. package/src/ui/dash/collections/ViewCollectionContent.tsx +92 -0
  209. package/src/ui/dash/index.ts +10 -0
  210. package/src/ui/dash/media/MediaListContent.tsx +201 -0
  211. package/src/ui/dash/media/ViewMediaContent.tsx +208 -0
  212. package/src/ui/dash/pages/LinkFormContent.tsx +119 -0
  213. package/src/ui/dash/pages/UnifiedPagesContent.tsx +203 -0
  214. package/src/ui/dash/settings/AccountContent.tsx +176 -0
  215. package/src/ui/dash/settings/AppearanceContent.tsx +254 -0
  216. package/src/ui/dash/settings/GeneralContent.tsx +533 -0
  217. package/src/ui/dash/settings/SettingsNav.tsx +56 -0
  218. package/src/{themes/threads/timeline → ui/feed}/LinkCard.tsx +9 -4
  219. package/src/{themes/threads/timeline → ui/feed}/NoteCard.tsx +13 -8
  220. package/src/{themes/threads/timeline → ui/feed}/QuoteCard.tsx +13 -8
  221. package/src/{themes/threads/timeline → ui/feed}/ThreadPreview.tsx +7 -8
  222. package/src/ui/feed/TimelineFeed.tsx +49 -0
  223. package/src/ui/feed/TimelineItem.tsx +45 -0
  224. package/src/ui/font-themes.ts +54 -0
  225. package/src/{theme → ui}/layouts/BaseLayout.tsx +28 -1
  226. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  227. package/src/ui/layouts/SiteLayout.tsx +164 -0
  228. package/src/{themes/threads → ui}/pages/ArchivePage.tsx +22 -17
  229. package/src/{themes/threads → ui}/pages/CollectionPage.tsx +14 -5
  230. package/src/ui/pages/CollectionsPage.tsx +73 -0
  231. package/src/ui/pages/FeaturedPage.tsx +31 -0
  232. package/src/{themes/threads → ui}/pages/HomePage.tsx +11 -15
  233. package/src/{themes/threads → ui}/pages/PostPage.tsx +23 -14
  234. package/src/{themes/threads → ui}/pages/SearchPage.tsx +13 -11
  235. package/src/{themes/threads → ui}/pages/SinglePage.tsx +4 -4
  236. package/src/{theme/components → ui/shared}/MediaGallery.tsx +1 -1
  237. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  238. package/src/{theme/components → ui/shared}/ThreadView.tsx +2 -2
  239. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  240. package/src/ui/shared/index.ts +12 -0
  241. package/bin/jant.js +0 -185
  242. package/dist/lib/theme-components.js +0 -46
  243. package/dist/routes/dash/navigation.js +0 -289
  244. package/dist/theme/index.js +0 -18
  245. package/dist/theme/layouts/index.js +0 -2
  246. package/dist/themes/threads/ThreadsSiteLayout.js +0 -172
  247. package/dist/themes/threads/index.js +0 -81
  248. package/dist/themes/threads/pages/HomePage.js +0 -25
  249. package/dist/themes/threads/timeline/TimelineFeed.js +0 -58
  250. package/dist/themes/threads/timeline/TimelineItem.js +0 -36
  251. package/dist/themes/threads/timeline/TimelineLoadMore.js +0 -23
  252. package/dist/themes/threads/timeline/groupByDate.js +0 -22
  253. package/dist/themes/threads/timeline/timelineMore.js +0 -107
  254. package/src/lib/__tests__/theme-components.test.ts +0 -105
  255. package/src/lib/theme-components.ts +0 -65
  256. package/src/routes/dash/navigation.tsx +0 -317
  257. package/src/theme/components/index.ts +0 -23
  258. package/src/theme/index.ts +0 -22
  259. package/src/theme/layouts/index.ts +0 -7
  260. package/src/themes/threads/ThreadsSiteLayout.tsx +0 -194
  261. package/src/themes/threads/index.ts +0 -100
  262. package/src/themes/threads/style.css +0 -336
  263. package/src/themes/threads/timeline/TimelineFeed.tsx +0 -62
  264. package/src/themes/threads/timeline/TimelineItem.tsx +0 -67
  265. package/src/themes/threads/timeline/TimelineLoadMore.tsx +0 -35
  266. package/src/themes/threads/timeline/groupByDate.ts +0 -30
  267. package/src/themes/threads/timeline/timelineMore.tsx +0 -130
  268. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  269. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  270. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  271. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  272. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  273. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  274. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  275. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  276. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  277. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -12,9 +12,8 @@ import { createTestDatabase } from "../../__tests__/helpers/db.js";
12
12
  import { createPostService } from "../../services/post.js";
13
13
  import { createMediaService } from "../../services/media.js";
14
14
  import { buildMediaMap } from "../media-helpers.js";
15
- import { groupByDate } from "../timeline.js";
16
15
  import type { Database } from "../../db/index.js";
17
- import type { PostWithMedia, TimelineItemView } from "../../types.js";
16
+ import type { PostWithMedia } from "../../types.js";
18
17
 
19
18
  describe("Timeline data assembly", () => {
20
19
  let db: Database;
@@ -184,101 +183,66 @@ describe("Timeline data assembly", () => {
184
183
  expect(page2.every((p) => p.id < (lastPost?.id ?? 0))).toBe(true);
185
184
  });
186
185
 
187
- it("correctly determines hasMore flag", async () => {
188
- for (let i = 0; i < 3; i++) {
186
+ it("supports offset-based pagination for page navigation", async () => {
187
+ for (let i = 0; i < 5; i++) {
189
188
  await postService.create({
190
189
  format: "note",
191
190
  body: `Post ${i}`,
191
+ publishedAt: 1000 + i,
192
192
  });
193
193
  }
194
194
 
195
- // Request limit + 1 to check for more
196
195
  const pageSize = 2;
197
- const posts = await postService.list({
196
+
197
+ // Page 1
198
+ const page1 = await postService.list({
198
199
  status: "published",
199
200
  excludeReplies: true,
200
- limit: pageSize + 1,
201
+ limit: pageSize,
202
+ offset: 0,
201
203
  });
204
+ expect(page1).toHaveLength(2);
205
+ expect(page1[0]?.body).toBe("Post 4");
206
+ expect(page1[1]?.body).toBe("Post 3");
202
207
 
203
- const hasMore = posts.length > pageSize;
204
- expect(hasMore).toBe(true);
208
+ // Page 2
209
+ const page2 = await postService.list({
210
+ status: "published",
211
+ excludeReplies: true,
212
+ limit: pageSize,
213
+ offset: 2,
214
+ });
215
+ expect(page2).toHaveLength(2);
216
+ expect(page2[0]?.body).toBe("Post 2");
217
+ expect(page2[1]?.body).toBe("Post 1");
205
218
 
206
- const displayPosts = posts.slice(0, pageSize);
207
- expect(displayPosts).toHaveLength(2);
219
+ // Page 3 (partial)
220
+ const page3 = await postService.list({
221
+ status: "published",
222
+ excludeReplies: true,
223
+ limit: pageSize,
224
+ offset: 4,
225
+ });
226
+ expect(page3).toHaveLength(1);
227
+ expect(page3[0]?.body).toBe("Post 0");
208
228
  });
209
- });
210
229
 
211
- describe("groupByDate", () => {
212
- function makeItem(dateStr: string, formatted: string): TimelineItemView {
213
- return {
214
- post: {
215
- id: 1,
216
- permalink: "/p/1",
230
+ it("computes total pages from count", async () => {
231
+ for (let i = 0; i < 5; i++) {
232
+ await postService.create({
217
233
  format: "note",
218
- status: "published",
219
- featured: true,
220
- pinned: false,
221
- publishedAt: `${dateStr}T12:00:00.000Z`,
222
- publishedAtFormatted: formatted,
223
- publishedAtTime: "12:00",
224
- publishedAtRelative: "1d",
225
- updatedAt: `${dateStr}T12:00:00.000Z`,
226
- media: [],
227
- },
228
- };
229
- }
230
-
231
- it("returns empty array for empty input", () => {
232
- expect(groupByDate([])).toEqual([]);
233
- });
234
-
235
- it("groups items by YYYY-MM-DD date key", () => {
236
- const items = [
237
- makeItem("2024-02-01", "Feb 1, 2024"),
238
- makeItem("2024-02-01", "Feb 1, 2024"),
239
- makeItem("2024-02-02", "Feb 2, 2024"),
240
- ];
241
-
242
- const groups = groupByDate(items);
243
- expect(groups).toHaveLength(2);
244
- expect(groups[0]?.dateKey).toBe("2024-02-01");
245
- expect(groups[0]?.label).toBe("Feb 1, 2024");
246
- expect(groups[0]?.items).toHaveLength(2);
247
- expect(groups[1]?.dateKey).toBe("2024-02-02");
248
- expect(groups[1]?.items).toHaveLength(1);
249
- });
250
-
251
- it("creates separate groups for non-contiguous same dates", () => {
252
- const items = [
253
- makeItem("2024-02-01", "Feb 1, 2024"),
254
- makeItem("2024-02-02", "Feb 2, 2024"),
255
- makeItem("2024-02-01", "Feb 1, 2024"),
256
- ];
257
-
258
- const groups = groupByDate(items);
259
- expect(groups).toHaveLength(3);
260
- expect(groups[0]?.dateKey).toBe("2024-02-01");
261
- expect(groups[1]?.dateKey).toBe("2024-02-02");
262
- expect(groups[2]?.dateKey).toBe("2024-02-01");
263
- });
264
-
265
- it("handles a single item", () => {
266
- const items = [makeItem("2024-06-15", "Jun 15, 2024")];
267
- const groups = groupByDate(items);
268
- expect(groups).toHaveLength(1);
269
- expect(groups[0]?.dateKey).toBe("2024-06-15");
270
- expect(groups[0]?.items).toHaveLength(1);
271
- });
234
+ body: `Post ${i}`,
235
+ });
236
+ }
272
237
 
273
- it("uses the first item's formatted date as the group label", () => {
274
- const items = [
275
- makeItem("2024-03-10", "Mar 10, 2024"),
276
- makeItem("2024-03-10", "March 10"),
277
- ];
238
+ const pageSize = 2;
239
+ const totalCount = await postService.count({
240
+ status: "published",
241
+ excludeReplies: true,
242
+ });
278
243
 
279
- const groups = groupByDate(items);
280
- expect(groups).toHaveLength(1);
281
- // Label comes from first item in the group
282
- expect(groups[0]?.label).toBe("Mar 10, 2024");
244
+ expect(totalCount).toBe(5);
245
+ const totalPages = Math.ceil(totalCount / pageSize);
246
+ expect(totalPages).toBe(3);
283
247
  });
284
248
  });
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { TIMEZONES, mapIanaToTimezone } from "../timezones.js";
3
+
4
+ describe("TIMEZONES", () => {
5
+ it("contains expected timezone entries", () => {
6
+ expect(TIMEZONES.length).toBeGreaterThan(30);
7
+ const utc = TIMEZONES.find((tz) => tz.value === "UTC");
8
+ expect(utc).toBeDefined();
9
+ expect(utc!.offset).toBe("+00:00");
10
+ });
11
+
12
+ it("each entry has required fields", () => {
13
+ for (const tz of TIMEZONES) {
14
+ expect(tz.value).toBeTruthy();
15
+ expect(tz.label).toBeTruthy();
16
+ expect(tz.offset).toBeTruthy();
17
+ expect(tz.iana.length).toBeGreaterThan(0);
18
+ }
19
+ });
20
+
21
+ it("has no duplicate values", () => {
22
+ const values = TIMEZONES.map((tz) => tz.value);
23
+ expect(new Set(values).size).toBe(values.length);
24
+ });
25
+ });
26
+
27
+ describe("mapIanaToTimezone", () => {
28
+ it("maps Asia/Shanghai to Beijing", () => {
29
+ expect(mapIanaToTimezone("Asia/Shanghai")).toBe("Beijing");
30
+ });
31
+
32
+ it("maps America/New_York to Eastern Time", () => {
33
+ expect(mapIanaToTimezone("America/New_York")).toBe(
34
+ "Eastern Time (US & Canada)",
35
+ );
36
+ });
37
+
38
+ it("maps Europe/London to London", () => {
39
+ expect(mapIanaToTimezone("Europe/London")).toBe("London");
40
+ });
41
+
42
+ it("maps Asia/Tokyo to Tokyo", () => {
43
+ expect(mapIanaToTimezone("Asia/Tokyo")).toBe("Tokyo");
44
+ });
45
+
46
+ it("returns UTC for unknown timezone", () => {
47
+ expect(mapIanaToTimezone("Unknown/Zone")).toBe("UTC");
48
+ });
49
+
50
+ it("returns UTC for empty string", () => {
51
+ expect(mapIanaToTimezone("")).toBe("UTC");
52
+ });
53
+
54
+ it("maps Pacific/Honolulu to Hawaii", () => {
55
+ expect(mapIanaToTimezone("Pacific/Honolulu")).toBe("Hawaii");
56
+ });
57
+
58
+ it("maps Australia/Sydney to Sydney", () => {
59
+ expect(mapIanaToTimezone("Australia/Sydney")).toBe("Sydney");
60
+ });
61
+ });
@@ -34,7 +34,7 @@ function makePost(overrides: Partial<Post> = {}): Post {
34
34
  status: "published",
35
35
  featured: 0,
36
36
  pinned: 0,
37
- slug: null,
37
+ path: null,
38
38
  title: null,
39
39
  url: null,
40
40
  body: "Hello world",
@@ -100,19 +100,25 @@ function makeNavItem(overrides: Partial<NavItem> = {}): NavItem {
100
100
  // =============================================================================
101
101
 
102
102
  describe("toPostView", () => {
103
- it("generates permalink from post id when no slug", () => {
104
- const post = makePostWithMedia({ id: 123, slug: null });
103
+ it("generates permalink from post id when no path", () => {
104
+ const post = makePostWithMedia({ id: 123, path: null });
105
105
  const view = toPostView(post, EMPTY_CTX);
106
106
  expect(view.permalink).toMatch(/^\/p\/.+$/);
107
107
  expect(view.permalink.length).toBeGreaterThan(3);
108
108
  });
109
109
 
110
- it("generates permalink from slug when slug is set", () => {
111
- const post = makePostWithMedia({ id: 123, slug: "my-post" });
110
+ it("generates permalink from path when path is set", () => {
111
+ const post = makePostWithMedia({ id: 123, path: "my-post" });
112
112
  const view = toPostView(post, EMPTY_CTX);
113
113
  expect(view.permalink).toBe("/my-post");
114
114
  });
115
115
 
116
+ it("generates permalink from multi-level path", () => {
117
+ const post = makePostWithMedia({ id: 123, path: "2024/01/my-post" });
118
+ const view = toPostView(post, EMPTY_CTX);
119
+ expect(view.permalink).toBe("/2024/01/my-post");
120
+ });
121
+
116
122
  it("formats dates correctly", () => {
117
123
  const post = makePostWithMedia({ publishedAt: 1706745600 });
118
124
  const view = toPostView(post, EMPTY_CTX);
@@ -202,7 +208,7 @@ describe("toPostView", () => {
202
208
  it("converts null fields to undefined", () => {
203
209
  const view = toPostView(makePostWithMedia(), EMPTY_CTX);
204
210
  expect(view.title).toBeUndefined();
205
- expect(view.slug).toBeUndefined();
211
+ expect(view.path).toBeUndefined();
206
212
  expect(view.url).toBeUndefined();
207
213
  expect(view.quoteText).toBeUndefined();
208
214
  expect(view.rating).toBeUndefined();
@@ -322,8 +328,8 @@ describe("toMediaView", () => {
322
328
  it("generates local proxy URL without public URL", () => {
323
329
  const media = makeMedia();
324
330
  const view = toMediaView(media, EMPTY_CTX);
325
- expect(view.url).toBe("/media/01902a9f-1a2b-7c3d.webp");
326
- expect(view.thumbnailUrl).toBe("/media/01902a9f-1a2b-7c3d.webp");
331
+ expect(view.url).toBe("/media/2025/01/01902a9f-1a2b-7c3d.webp");
332
+ expect(view.thumbnailUrl).toBe("/media/2025/01/01902a9f-1a2b-7c3d.webp");
327
333
  });
328
334
 
329
335
  it("generates CDN URL with public URL", () => {
@@ -465,7 +471,7 @@ describe("toSearchResultView", () => {
465
471
  featured: 1,
466
472
  pinned: 0,
467
473
  url: "https://example.com",
468
- slug: "my-link",
474
+ path: "my-link",
469
475
  }),
470
476
  rank: 0.8,
471
477
  };
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Client-side Avatar Upload Handler
3
+ *
4
+ * Intercepts avatar file selection to generate favicon variants
5
+ * before uploading. Generates:
6
+ * - favicon.ico (ICO containing 16x16 + 32x32 PNGs)
7
+ * - apple-touch-icon.png (180x180 PNG)
8
+ *
9
+ * Uses the `[data-avatar-upload]` attribute on file inputs.
10
+ */
11
+
12
+ import { encodeIco } from "./favicon.js";
13
+
14
+ /**
15
+ * Load an image from a File object
16
+ */
17
+ function loadImage(file: File): Promise<HTMLImageElement> {
18
+ return new Promise((resolve, reject) => {
19
+ const img = new Image();
20
+ img.onload = () => {
21
+ URL.revokeObjectURL(img.src);
22
+ resolve(img);
23
+ };
24
+ img.onerror = () => reject(new Error("Failed to load image"));
25
+ img.src = URL.createObjectURL(file);
26
+ });
27
+ }
28
+
29
+ /**
30
+ * Resize image to a square PNG using center crop.
31
+ *
32
+ * @param img - Source HTMLImageElement
33
+ * @param size - Target width and height in pixels
34
+ * @returns PNG Blob at the target size
35
+ */
36
+ function resizeToSquarePng(img: HTMLImageElement, size: number): Promise<Blob> {
37
+ const canvas = document.createElement("canvas");
38
+ canvas.width = size;
39
+ canvas.height = size;
40
+
41
+ const ctx = canvas.getContext("2d");
42
+ if (!ctx) throw new Error("Failed to get canvas context");
43
+
44
+ // Cover crop: scale to fill square, crop center
45
+ const scale = Math.max(size / img.width, size / img.height);
46
+ const sw = size / scale;
47
+ const sh = size / scale;
48
+ const sx = (img.width - sw) / 2;
49
+ const sy = (img.height - sh) / 2;
50
+
51
+ ctx.drawImage(img, sx, sy, sw, sh, 0, 0, size, size);
52
+
53
+ return new Promise((resolve, reject) => {
54
+ canvas.toBlob(
55
+ (blob) => {
56
+ if (blob) resolve(blob);
57
+ else reject(new Error("Failed to create PNG blob"));
58
+ },
59
+ "image/png",
60
+ );
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Process avatar file and upload with favicon variants.
66
+ *
67
+ * @param input - The file input element with `data-avatar-upload` attribute
68
+ * @param file - The selected file
69
+ */
70
+ async function handleAvatarUpload(
71
+ input: HTMLInputElement,
72
+ file: File,
73
+ ): Promise<void> {
74
+ // Find the parent form for the loading button
75
+ const form = input.closest("form");
76
+ const label = form?.querySelector("label");
77
+ const originalText = label?.textContent ?? "";
78
+
79
+ try {
80
+ // Show processing state
81
+ if (label)
82
+ label.textContent = input.dataset.textProcessing || "Processing...";
83
+
84
+ // Load the image
85
+ const img = await loadImage(file);
86
+
87
+ // Generate variants in parallel
88
+ const [png16, png32, png180] = await Promise.all([
89
+ resizeToSquarePng(img, 16),
90
+ resizeToSquarePng(img, 32),
91
+ resizeToSquarePng(img, 180),
92
+ ]);
93
+
94
+ // Encode ICO with 16x16 and 32x32
95
+ const [png16Buf, png32Buf] = await Promise.all([
96
+ png16.arrayBuffer(),
97
+ png32.arrayBuffer(),
98
+ ]);
99
+ const icoBlob = encodeIco([
100
+ { size: 16, png: png16Buf },
101
+ { size: 32, png: png32Buf },
102
+ ]);
103
+
104
+ // Show uploading state
105
+ if (label)
106
+ label.textContent = input.dataset.textUploading || "Uploading...";
107
+
108
+ // Build FormData with original + variants
109
+ const formData = new FormData();
110
+ formData.append("file", file);
111
+ formData.append("favicon", icoBlob, "favicon.ico");
112
+ formData.append("appleTouch", png180, "apple-touch-icon.png");
113
+
114
+ // Upload
115
+ const response = await fetch("/dash/settings/avatar", {
116
+ method: "POST",
117
+ body: formData,
118
+ });
119
+
120
+ if (!response.ok) {
121
+ throw new Error("Upload failed");
122
+ }
123
+
124
+ // Redirect on success
125
+ window.location.href = "/dash/settings?saved";
126
+ } catch {
127
+ // Restore button text on error
128
+ if (label) label.textContent = originalText;
129
+ // Show error toast
130
+ const errorMsg =
131
+ input.dataset.textError || "Upload failed. Please try again.";
132
+ const container = document.getElementById("toast-container");
133
+ if (container) {
134
+ const toast = document.createElement("div");
135
+ toast.className = "toast toast-error";
136
+ toast.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg><span>${errorMsg}</span>`;
137
+ container.appendChild(toast);
138
+ setTimeout(() => {
139
+ toast.classList.add("toast-out");
140
+ toast.addEventListener("animationend", () => toast.remove());
141
+ }, 3000);
142
+ }
143
+ }
144
+
145
+ // Reset file input so the same file can be re-selected
146
+ input.value = "";
147
+ }
148
+
149
+ /**
150
+ * Initialize avatar upload via event delegation
151
+ */
152
+ function initAvatarUpload(): void {
153
+ document.addEventListener("change", (e) => {
154
+ const input = (e.target as HTMLElement).closest(
155
+ "[data-avatar-upload]",
156
+ ) as HTMLInputElement | null;
157
+ if (!input?.files?.[0]) return;
158
+
159
+ // Prevent default form submission (Datastar data-on:change)
160
+ e.stopPropagation();
161
+ handleAvatarUpload(input, input.files[0]);
162
+ });
163
+ }
164
+
165
+ initAvatarUpload();
package/src/lib/config.ts CHANGED
@@ -118,3 +118,50 @@ export async function getSiteDescription(c: Context): Promise<string> {
118
118
  export async function getSiteLanguage(c: Context): Promise<string> {
119
119
  return getConfig(c, "SITE_LANGUAGE");
120
120
  }
121
+
122
+ /**
123
+ * Get home default view with fallback chain: DB > ENV > Default
124
+ *
125
+ * @param c - Hono context
126
+ * @returns Home default view ("latest" or "featured")
127
+ *
128
+ * @example
129
+ * ```typescript
130
+ * const view = await getHomeDefaultView(c);
131
+ * // Returns: (DB: HOME_DEFAULT_VIEW) ?? "latest"
132
+ * ```
133
+ */
134
+ export async function getHomeDefaultView(c: Context): Promise<string> {
135
+ return getConfig(c, "HOME_DEFAULT_VIEW");
136
+ }
137
+
138
+ /**
139
+ * Get timezone with fallback chain: DB > ENV > Default
140
+ *
141
+ * @param c - Hono context
142
+ * @returns Timezone string (e.g. "Beijing", "UTC")
143
+ */
144
+ export async function getTimeZone(c: Context): Promise<string> {
145
+ return getConfig(c, "TIME_ZONE");
146
+ }
147
+
148
+ /**
149
+ * Get site footer markdown with fallback chain: DB > ENV > Default
150
+ *
151
+ * @param c - Hono context
152
+ * @returns Footer markdown string (empty string if not set)
153
+ */
154
+ export async function getSiteFooter(c: Context): Promise<string> {
155
+ return getConfig(c, "SITE_FOOTER");
156
+ }
157
+
158
+ /**
159
+ * Check if search engine indexing is disabled
160
+ *
161
+ * @param c - Hono context
162
+ * @returns true if NOINDEX is set to "true"
163
+ */
164
+ export async function isNoIndex(c: Context): Promise<boolean> {
165
+ const value = await getConfig(c, "NOINDEX");
166
+ return value === "true";
167
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  export const RESERVED_PATHS = [
9
9
  "featured",
10
+ "latest",
10
11
  "collections",
11
12
  "signin",
12
13
  "signout",
@@ -42,18 +43,26 @@ export function isReservedPath(path: string): boolean {
42
43
  export const DEFAULT_PAGE_SIZE = 100;
43
44
 
44
45
  /**
45
- * Settings keys (match environment variable naming)
46
+ * Settings keys - derived from CONFIG_FIELDS (Single Source of Truth)
47
+ *
48
+ * Only non-envOnly fields and internal fields are stored in DB settings.
49
+ * Environment-only fields (SITE_URL, AUTH_SECRET, etc.) are never in the DB.
46
50
  */
47
- export const SETTINGS_KEYS = {
48
- ONBOARDING_STATUS: "ONBOARDING_STATUS",
49
- SITE_NAME: "SITE_NAME",
50
- SITE_DESCRIPTION: "SITE_DESCRIPTION",
51
- SITE_LANGUAGE: "SITE_LANGUAGE",
52
- THEME: "THEME",
53
- PASSWORD_RESET_TOKEN: "PASSWORD_RESET_TOKEN",
54
- } as const;
51
+ import { CONFIG_FIELDS, type ConfigKey } from "../types.js";
52
+
53
+ type SettingsFieldKey = {
54
+ [K in ConfigKey]: (typeof CONFIG_FIELDS)[K] extends { envOnly: false }
55
+ ? K
56
+ : never;
57
+ }[ConfigKey];
58
+
59
+ export const SETTINGS_KEYS = Object.fromEntries(
60
+ Object.entries(CONFIG_FIELDS)
61
+ .filter(([, field]) => !field.envOnly || "internal" in field)
62
+ .map(([key]) => [key, key]),
63
+ ) as { [K in SettingsFieldKey]: K };
55
64
 
56
- export type SettingsKey = (typeof SETTINGS_KEYS)[keyof typeof SETTINGS_KEYS];
65
+ export type SettingsKey = SettingsFieldKey;
57
66
 
58
67
  /**
59
68
  * Onboarding status values
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Favicon Utilities
3
+ *
4
+ * Sizes and ICO encoding for generated favicon variants.
5
+ * Favicon data is stored as base64 in the settings table (not R2)
6
+ * since the files are tiny and accessed on every page load.
7
+ */
8
+
9
+ /**
10
+ * Favicon variant sizes (width x height in pixels)
11
+ */
12
+ export const FAVICON_SIZES = {
13
+ ICO_16: 16,
14
+ ICO_32: 32,
15
+ APPLE_TOUCH: 180,
16
+ } as const;
17
+
18
+ /**
19
+ * Encode PNG images into an ICO file.
20
+ *
21
+ * ICO format (with PNG payloads):
22
+ * - Header: 6 bytes (reserved=0, type=1, count=N)
23
+ * - Directory: 16 bytes per entry (width, height, colors, reserved, planes, bpp, size, offset)
24
+ * - Data: raw PNG bytes for each entry
25
+ *
26
+ * @param entries - Array of { size, png } where png is an ArrayBuffer of PNG data
27
+ * @returns ICO file as a Blob
28
+ *
29
+ * @example
30
+ * ```ts
31
+ * const ico = encodeIco([
32
+ * { size: 16, png: png16ArrayBuffer },
33
+ * { size: 32, png: png32ArrayBuffer },
34
+ * ]);
35
+ * ```
36
+ */
37
+ export function encodeIco(
38
+ entries: { size: number; png: ArrayBuffer }[],
39
+ ): Blob {
40
+ const headerSize = 6;
41
+ const dirEntrySize = 16;
42
+ const dirSize = entries.length * dirEntrySize;
43
+
44
+ let dataOffset = headerSize + dirSize;
45
+
46
+ // Build header + directory
47
+ const header = new ArrayBuffer(headerSize + dirSize);
48
+ const view = new DataView(header);
49
+
50
+ // ICO header
51
+ view.setUint16(0, 0, true); // reserved
52
+ view.setUint16(2, 1, true); // type = icon
53
+ view.setUint16(4, entries.length, true); // count
54
+
55
+ const pngBuffers: ArrayBuffer[] = [];
56
+ for (let i = 0; i < entries.length; i++) {
57
+ const entry = entries[i]!;
58
+ const offset = headerSize + i * dirEntrySize;
59
+
60
+ // Width/height: 0 means 256
61
+ view.setUint8(offset + 0, entry.size < 256 ? entry.size : 0);
62
+ view.setUint8(offset + 1, entry.size < 256 ? entry.size : 0);
63
+ view.setUint8(offset + 2, 0); // color count (0 for >256 colors)
64
+ view.setUint8(offset + 3, 0); // reserved
65
+ view.setUint16(offset + 4, 1, true); // color planes
66
+ view.setUint16(offset + 6, 32, true); // bits per pixel
67
+ view.setUint32(offset + 8, entry.png.byteLength, true); // image size
68
+ view.setUint32(offset + 12, dataOffset, true); // image offset
69
+
70
+ dataOffset += entry.png.byteLength;
71
+ pngBuffers.push(entry.png);
72
+ }
73
+
74
+ return new Blob([header, ...pngBuffers], { type: "image/x-icon" });
75
+ }
76
+
77
+ /**
78
+ * Convert an ArrayBuffer to a base64 string.
79
+ *
80
+ * @param buffer - The ArrayBuffer to encode
81
+ * @returns base64-encoded string
82
+ *
83
+ * @example
84
+ * ```ts
85
+ * const b64 = arrayBufferToBase64(await blob.arrayBuffer());
86
+ * ```
87
+ */
88
+ export function arrayBufferToBase64(buffer: ArrayBuffer): string {
89
+ const bytes = new Uint8Array(buffer);
90
+ let binary = "";
91
+ for (let i = 0; i < bytes.byteLength; i++) {
92
+ binary += String.fromCharCode(bytes[i]!);
93
+ }
94
+ return btoa(binary);
95
+ }
96
+
97
+ /**
98
+ * Convert a base64 string to a Uint8Array.
99
+ *
100
+ * @param base64 - The base64 string to decode
101
+ * @returns decoded Uint8Array
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * const bytes = base64ToUint8Array(storedBase64);
106
+ * ```
107
+ */
108
+ export function base64ToUint8Array(base64: string): Uint8Array {
109
+ const binary = atob(base64);
110
+ const bytes = new Uint8Array(binary.length);
111
+ for (let i = 0; i < binary.length; i++) {
112
+ bytes[i] = binary.charCodeAt(i);
113
+ }
114
+ return bytes;
115
+ }