@jant/core 0.3.26 → 0.3.28

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 (314) hide show
  1. package/dist/client/client.css +1 -0
  2. package/dist/client/client.js +31561 -0
  3. package/dist/index.js +15209 -15
  4. package/package.json +21 -15
  5. package/src/__tests__/helpers/app.ts +19 -3
  6. package/src/__tests__/helpers/db.ts +44 -0
  7. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  8. package/src/app.tsx +112 -173
  9. package/src/auth.ts +4 -1
  10. package/src/client.ts +13 -0
  11. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  12. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  13. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  14. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  15. package/src/db/schema.ts +24 -4
  16. package/src/i18n/locales/en.po +810 -385
  17. package/src/i18n/locales/en.ts +1 -1
  18. package/src/i18n/locales/zh-Hans.po +733 -522
  19. package/src/i18n/locales/zh-Hans.ts +1 -1
  20. package/src/i18n/locales/zh-Hant.po +733 -522
  21. package/src/i18n/locales/zh-Hant.ts +1 -1
  22. package/src/i18n/middleware.ts +7 -11
  23. package/src/index.ts +1 -1
  24. package/src/lib/__tests__/icons.test.ts +178 -0
  25. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  26. package/src/lib/__tests__/schemas.test.ts +12 -6
  27. package/src/lib/__tests__/theme.test.ts +62 -0
  28. package/src/lib/__tests__/timezones.test.ts +1 -1
  29. package/src/lib/__tests__/url.test.ts +12 -0
  30. package/src/lib/__tests__/view.test.ts +1 -5
  31. package/src/lib/avatar-upload.ts +18 -10
  32. package/src/lib/collection-form-bridge.ts +52 -0
  33. package/src/lib/collections-reorder.ts +28 -0
  34. package/src/lib/compose-bridge.ts +251 -0
  35. package/src/lib/errors.ts +116 -0
  36. package/src/lib/excerpt.ts +1 -1
  37. package/src/lib/favicon.ts +3 -5
  38. package/src/lib/html.ts +22 -0
  39. package/src/lib/icon-catalog.ts +181 -0
  40. package/src/lib/icons.ts +202 -0
  41. package/src/lib/navigation.ts +18 -33
  42. package/src/lib/pagination.ts +3 -2
  43. package/src/lib/post-form-bridge.ts +136 -0
  44. package/src/lib/render.tsx +11 -4
  45. package/src/lib/resolve-config.ts +157 -0
  46. package/src/lib/schemas.ts +76 -12
  47. package/src/lib/settings-bridge.ts +139 -0
  48. package/src/lib/storage.ts +37 -16
  49. package/src/lib/theme.ts +5 -7
  50. package/src/lib/timeline.ts +4 -8
  51. package/src/lib/toast.ts +134 -0
  52. package/src/lib/upload.ts +71 -0
  53. package/src/lib/url.ts +9 -1
  54. package/src/lib/version.ts +16 -0
  55. package/src/lib/view.ts +9 -10
  56. package/src/middleware/__tests__/auth.test.ts +6 -28
  57. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  58. package/src/middleware/auth.ts +6 -12
  59. package/src/middleware/config.ts +51 -0
  60. package/src/middleware/error-handler.ts +56 -0
  61. package/src/middleware/onboarding.ts +1 -1
  62. package/src/preset.css +6 -0
  63. package/src/routes/__tests__/compose.test.ts +104 -17
  64. package/src/routes/api/__tests__/collections.test.ts +93 -2
  65. package/src/routes/api/__tests__/posts.test.ts +2 -1
  66. package/src/routes/api/__tests__/settings.test.ts +1 -1
  67. package/src/routes/api/collections.ts +64 -68
  68. package/src/routes/api/nav-items.ts +21 -59
  69. package/src/routes/api/pages.ts +18 -46
  70. package/src/routes/api/posts.ts +64 -86
  71. package/src/routes/api/search.ts +6 -4
  72. package/src/routes/api/settings.ts +8 -24
  73. package/src/routes/api/upload.ts +55 -53
  74. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  75. package/src/routes/auth/reset.tsx +17 -66
  76. package/src/routes/auth/setup.tsx +67 -11
  77. package/src/routes/auth/signin.tsx +44 -8
  78. package/src/routes/compose.tsx +194 -0
  79. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  80. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  81. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  82. package/src/routes/dash/appearance.tsx +173 -0
  83. package/src/routes/dash/collections.tsx +80 -14
  84. package/src/routes/dash/index.tsx +12 -14
  85. package/src/routes/dash/media.tsx +46 -49
  86. package/src/routes/dash/pages.tsx +85 -37
  87. package/src/routes/dash/posts.tsx +60 -23
  88. package/src/routes/dash/redirects.tsx +43 -33
  89. package/src/routes/dash/settings.tsx +234 -214
  90. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  91. package/src/routes/feed/rss.ts +11 -16
  92. package/src/routes/feed/sitemap.ts +15 -9
  93. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  94. package/src/routes/pages/archive.tsx +2 -2
  95. package/src/routes/pages/collection.tsx +76 -9
  96. package/src/routes/pages/collections.tsx +3 -1
  97. package/src/routes/pages/featured.tsx +2 -2
  98. package/src/routes/pages/home.tsx +3 -3
  99. package/src/routes/pages/latest.tsx +2 -2
  100. package/src/routes/pages/page.tsx +2 -2
  101. package/src/routes/pages/post.tsx +2 -2
  102. package/src/routes/pages/search.tsx +2 -2
  103. package/src/services/__tests__/collection.test.ts +324 -34
  104. package/src/services/__tests__/media.test.ts +1 -1
  105. package/src/services/__tests__/page.test.ts +116 -1
  106. package/src/services/auth.ts +88 -0
  107. package/src/services/collection.ts +169 -30
  108. package/src/services/index.ts +8 -3
  109. package/src/services/media.ts +39 -12
  110. package/src/services/navigation.ts +17 -5
  111. package/src/services/page.ts +24 -4
  112. package/src/services/post.ts +87 -19
  113. package/src/services/search.ts +0 -1
  114. package/src/services/settings.ts +21 -13
  115. package/src/style.css +3 -0
  116. package/src/styles/components.css +42 -1
  117. package/src/styles/tokens.css +4 -0
  118. package/src/styles/ui.css +902 -73
  119. package/src/types/app-context.ts +25 -0
  120. package/src/types/bindings.ts +1 -0
  121. package/src/types/config.ts +60 -23
  122. package/src/types/entities.ts +12 -2
  123. package/src/types/lingui-react-macro.d.ts +3 -3
  124. package/src/types/operations.ts +2 -4
  125. package/src/types/views.ts +1 -3
  126. package/src/ui/__tests__/font-themes.test.ts +27 -8
  127. package/src/ui/color-themes.ts +1 -1
  128. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  129. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  130. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  131. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  132. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  133. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  134. package/src/ui/components/collection-types.ts +45 -0
  135. package/src/ui/components/compose-types.ts +75 -0
  136. package/src/ui/components/jant-collection-form.ts +512 -0
  137. package/src/ui/components/jant-compose-dialog.ts +494 -0
  138. package/src/ui/components/jant-compose-editor.ts +799 -0
  139. package/src/ui/components/jant-post-form.ts +290 -0
  140. package/src/ui/components/jant-settings-avatar.ts +231 -0
  141. package/src/ui/components/jant-settings-general.ts +436 -0
  142. package/src/ui/components/post-form-template.ts +260 -0
  143. package/src/ui/components/post-form-types.ts +87 -0
  144. package/src/ui/components/settings-types.ts +62 -0
  145. package/src/ui/compose/ComposeDialog.tsx +141 -385
  146. package/src/ui/compose/ComposePrompt.tsx +3 -3
  147. package/src/ui/dash/PostList.tsx +55 -61
  148. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  149. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  150. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  151. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  152. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  153. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  154. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  155. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  156. package/src/ui/dash/index.ts +1 -1
  157. package/src/ui/dash/posts/PostForm.tsx +248 -0
  158. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  159. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  160. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  161. package/src/ui/font-themes.ts +115 -32
  162. package/src/ui/layouts/BaseLayout.tsx +49 -19
  163. package/src/ui/layouts/DashLayout.tsx +14 -9
  164. package/src/ui/layouts/SiteLayout.tsx +38 -23
  165. package/src/ui/pages/CollectionPage.tsx +12 -2
  166. package/src/ui/pages/CollectionsPage.tsx +27 -27
  167. package/src/ui/pages/HomePage.tsx +15 -6
  168. package/src/ui/pages/SearchPage.tsx +1 -2
  169. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  170. package/src/ui/shared/Pagination.tsx +2 -2
  171. package/dist/app.js +0 -265
  172. package/dist/auth.js +0 -36
  173. package/dist/client.js +0 -13
  174. package/dist/db/index.js +0 -10
  175. package/dist/db/schema.js +0 -224
  176. package/dist/i18n/Trans.js +0 -24
  177. package/dist/i18n/context.js +0 -58
  178. package/dist/i18n/detect.js +0 -26
  179. package/dist/i18n/i18n.js +0 -49
  180. package/dist/i18n/index.js +0 -44
  181. package/dist/i18n/locales/en.js +0 -1
  182. package/dist/i18n/locales/zh-Hans.js +0 -1
  183. package/dist/i18n/locales/zh-Hant.js +0 -1
  184. package/dist/i18n/locales.js +0 -13
  185. package/dist/i18n/middleware.js +0 -30
  186. package/dist/lib/avatar-upload.js +0 -134
  187. package/dist/lib/config.js +0 -143
  188. package/dist/lib/constants.js +0 -50
  189. package/dist/lib/excerpt.js +0 -76
  190. package/dist/lib/favicon.js +0 -102
  191. package/dist/lib/feed.js +0 -123
  192. package/dist/lib/image-processor.js +0 -187
  193. package/dist/lib/image.js +0 -97
  194. package/dist/lib/index.js +0 -7
  195. package/dist/lib/markdown.js +0 -83
  196. package/dist/lib/media-helpers.js +0 -49
  197. package/dist/lib/media-upload.js +0 -104
  198. package/dist/lib/nav-reorder.js +0 -27
  199. package/dist/lib/navigation.js +0 -79
  200. package/dist/lib/pagination.js +0 -44
  201. package/dist/lib/render.js +0 -53
  202. package/dist/lib/schemas.js +0 -174
  203. package/dist/lib/sqid.js +0 -72
  204. package/dist/lib/sse.js +0 -218
  205. package/dist/lib/storage.js +0 -164
  206. package/dist/lib/theme.js +0 -65
  207. package/dist/lib/time.js +0 -159
  208. package/dist/lib/timeline.js +0 -95
  209. package/dist/lib/timezones.js +0 -388
  210. package/dist/lib/url.js +0 -89
  211. package/dist/lib/view.js +0 -217
  212. package/dist/middleware/auth.js +0 -52
  213. package/dist/middleware/onboarding.js +0 -41
  214. package/dist/routes/api/collections.js +0 -124
  215. package/dist/routes/api/nav-items.js +0 -104
  216. package/dist/routes/api/pages.js +0 -91
  217. package/dist/routes/api/posts.js +0 -218
  218. package/dist/routes/api/search.js +0 -48
  219. package/dist/routes/api/settings.js +0 -68
  220. package/dist/routes/api/upload.js +0 -246
  221. package/dist/routes/auth/reset.js +0 -221
  222. package/dist/routes/auth/setup.js +0 -194
  223. package/dist/routes/auth/signin.js +0 -176
  224. package/dist/routes/compose.js +0 -48
  225. package/dist/routes/dash/collections.js +0 -115
  226. package/dist/routes/dash/index.js +0 -118
  227. package/dist/routes/dash/media.js +0 -106
  228. package/dist/routes/dash/pages.js +0 -294
  229. package/dist/routes/dash/posts.js +0 -244
  230. package/dist/routes/dash/redirects.js +0 -257
  231. package/dist/routes/dash/settings.js +0 -379
  232. package/dist/routes/feed/rss.js +0 -62
  233. package/dist/routes/feed/sitemap.js +0 -49
  234. package/dist/routes/pages/archive.js +0 -62
  235. package/dist/routes/pages/collection.js +0 -34
  236. package/dist/routes/pages/collections.js +0 -28
  237. package/dist/routes/pages/featured.js +0 -36
  238. package/dist/routes/pages/home.js +0 -64
  239. package/dist/routes/pages/latest.js +0 -45
  240. package/dist/routes/pages/page.js +0 -68
  241. package/dist/routes/pages/post.js +0 -44
  242. package/dist/routes/pages/search.js +0 -54
  243. package/dist/services/collection.js +0 -109
  244. package/dist/services/index.js +0 -24
  245. package/dist/services/media.js +0 -117
  246. package/dist/services/navigation.js +0 -91
  247. package/dist/services/page.js +0 -84
  248. package/dist/services/post.js +0 -229
  249. package/dist/services/redirect.js +0 -48
  250. package/dist/services/search.js +0 -67
  251. package/dist/services/settings.js +0 -68
  252. package/dist/types/bindings.js +0 -3
  253. package/dist/types/config.js +0 -147
  254. package/dist/types/constants.js +0 -27
  255. package/dist/types/entities.js +0 -3
  256. package/dist/types/lingui-react-macro.d.js +0 -9
  257. package/dist/types/operations.js +0 -3
  258. package/dist/types/props.js +0 -3
  259. package/dist/types/sortablejs.d.js +0 -5
  260. package/dist/types/views.js +0 -5
  261. package/dist/types.js +0 -11
  262. package/dist/ui/color-themes.js +0 -268
  263. package/dist/ui/compose/ComposeDialog.js +0 -467
  264. package/dist/ui/compose/ComposePrompt.js +0 -55
  265. package/dist/ui/dash/ActionButtons.js +0 -46
  266. package/dist/ui/dash/CrudPageHeader.js +0 -22
  267. package/dist/ui/dash/DangerZone.js +0 -36
  268. package/dist/ui/dash/FormatBadge.js +0 -27
  269. package/dist/ui/dash/ListItemRow.js +0 -21
  270. package/dist/ui/dash/PageForm.js +0 -195
  271. package/dist/ui/dash/PostForm.js +0 -395
  272. package/dist/ui/dash/PostList.js +0 -83
  273. package/dist/ui/dash/StatusBadge.js +0 -46
  274. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  275. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  276. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  277. package/dist/ui/dash/index.js +0 -10
  278. package/dist/ui/dash/media/MediaListContent.js +0 -166
  279. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  280. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  281. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  282. package/dist/ui/dash/settings/AccountContent.js +0 -209
  283. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  284. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  285. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  286. package/dist/ui/feed/LinkCard.js +0 -72
  287. package/dist/ui/feed/NoteCard.js +0 -58
  288. package/dist/ui/feed/QuoteCard.js +0 -63
  289. package/dist/ui/feed/ThreadPreview.js +0 -48
  290. package/dist/ui/feed/TimelineFeed.js +0 -41
  291. package/dist/ui/feed/TimelineItem.js +0 -27
  292. package/dist/ui/font-themes.js +0 -36
  293. package/dist/ui/layouts/BaseLayout.js +0 -153
  294. package/dist/ui/layouts/DashLayout.js +0 -141
  295. package/dist/ui/layouts/SiteLayout.js +0 -169
  296. package/dist/ui/pages/ArchivePage.js +0 -143
  297. package/dist/ui/pages/CollectionPage.js +0 -70
  298. package/dist/ui/pages/CollectionsPage.js +0 -76
  299. package/dist/ui/pages/FeaturedPage.js +0 -24
  300. package/dist/ui/pages/HomePage.js +0 -24
  301. package/dist/ui/pages/PostPage.js +0 -55
  302. package/dist/ui/pages/SearchPage.js +0 -122
  303. package/dist/ui/pages/SinglePage.js +0 -23
  304. package/dist/ui/shared/EmptyState.js +0 -27
  305. package/dist/ui/shared/MediaGallery.js +0 -35
  306. package/dist/ui/shared/Pagination.js +0 -195
  307. package/dist/ui/shared/ThreadView.js +0 -108
  308. package/dist/ui/shared/index.js +0 -5
  309. package/dist/vendor/datastar.js +0 -1606
  310. package/src/lib/__tests__/config.test.ts +0 -192
  311. package/src/lib/config.ts +0 -167
  312. package/src/routes/compose.ts +0 -63
  313. package/src/ui/dash/PostForm.tsx +0 -360
  314. package/src/ui/dash/settings/AppearanceContent.tsx +0 -254
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Font theme save & read flow test.
3
+ *
4
+ * Verifies that FONT_THEME setting persists and buildThemeStyle generates
5
+ * the correct CSS overrides for --font-body and --font-heading.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach } from "vitest";
9
+ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
10
+ import { createSettingsService } from "../../../services/settings.js";
11
+ import { BUILTIN_FONT_THEMES } from "../../../ui/font-themes.js";
12
+ import { buildThemeStyle } from "../../../lib/theme.js";
13
+ import type { Database } from "../../../db/index.js";
14
+
15
+ describe("Font theme save & CSS generation", () => {
16
+ let db: Database;
17
+ let settings: ReturnType<typeof createSettingsService>;
18
+
19
+ beforeEach(() => {
20
+ const testDb = createTestDatabase();
21
+ db = testDb.db as unknown as Database;
22
+ settings = createSettingsService(db);
23
+ });
24
+
25
+ it("saves and reads FONT_THEME setting", async () => {
26
+ // Initially null
27
+ const initial = await settings.get("FONT_THEME");
28
+ expect(initial).toBeNull();
29
+
30
+ // Save classic-editorial
31
+ await settings.set("FONT_THEME", "classic-editorial");
32
+ expect(await settings.get("FONT_THEME")).toBe("classic-editorial");
33
+
34
+ // Update to geometric
35
+ await settings.set("FONT_THEME", "geometric");
36
+ expect(await settings.get("FONT_THEME")).toBe("geometric");
37
+
38
+ // Remove (reset to default)
39
+ await settings.remove("FONT_THEME");
40
+ expect(await settings.get("FONT_THEME")).toBeNull();
41
+ });
42
+
43
+ it("generates correct CSS with both --font-body and --font-heading", async () => {
44
+ // Save classic-editorial, then switch to geometric — simulates the middleware flow
45
+ await settings.set("FONT_THEME", "classic-editorial");
46
+ await settings.set("FONT_THEME", "geometric");
47
+
48
+ const fontThemeId = await settings.get("FONT_THEME");
49
+ expect(fontThemeId).toBe("geometric");
50
+
51
+ const fontTheme = BUILTIN_FONT_THEMES.find(
52
+ (f) => f.id === fontThemeId,
53
+ ) as (typeof BUILTIN_FONT_THEMES)[number];
54
+ expect(fontTheme).toBeDefined();
55
+ expect(fontTheme.headingFontFamily).toContain("Futura");
56
+ expect(fontTheme.bodyFontFamily).toContain("system-ui");
57
+
58
+ const fontOverrides = {
59
+ "--font-body": fontTheme.bodyFontFamily,
60
+ "--font-heading": fontTheme.headingFontFamily,
61
+ };
62
+ const css = buildThemeStyle(undefined, fontOverrides);
63
+
64
+ expect(css).toContain("--font-body:");
65
+ expect(css).toContain("--font-heading:");
66
+ expect(css).toContain("Futura");
67
+ expect(css).not.toContain("Charter");
68
+ });
69
+
70
+ it("generates no font override when default theme is selected", async () => {
71
+ // Default theme -> no FONT_THEME setting -> no font override
72
+ const fontThemeId = await settings.get("FONT_THEME");
73
+ expect(fontThemeId).toBeNull();
74
+
75
+ const fontTheme = fontThemeId
76
+ ? BUILTIN_FONT_THEMES.find((f) => f.id === fontThemeId)
77
+ : undefined;
78
+ expect(fontTheme).toBeUndefined();
79
+
80
+ const fontOverrides: Record<string, string> = {};
81
+ if (fontTheme) {
82
+ fontOverrides["--font-body"] = fontTheme.bodyFontFamily;
83
+ fontOverrides["--font-heading"] = fontTheme.headingFontFamily;
84
+ }
85
+
86
+ const css = buildThemeStyle(undefined, fontOverrides);
87
+ expect(css).toBe("");
88
+ });
89
+
90
+ it("classic-editorial has serif heading and sans body", async () => {
91
+ await settings.set("FONT_THEME", "classic-editorial");
92
+
93
+ const fontThemeId = await settings.get("FONT_THEME");
94
+ const fontTheme = BUILTIN_FONT_THEMES.find(
95
+ (f) => f.id === fontThemeId,
96
+ ) as (typeof BUILTIN_FONT_THEMES)[number];
97
+
98
+ expect(fontTheme.headingFontFamily).toContain("Charter");
99
+ expect(fontTheme.bodyFontFamily).toContain("system-ui");
100
+
101
+ const fontOverrides = {
102
+ "--font-body": fontTheme.bodyFontFamily,
103
+ "--font-heading": fontTheme.headingFontFamily,
104
+ };
105
+ const css = buildThemeStyle(undefined, fontOverrides);
106
+
107
+ expect(css).toContain("--font-heading:");
108
+ expect(css).toContain("Charter");
109
+ });
110
+ });
@@ -94,7 +94,7 @@ describe("Dashboard Pages - Nav Management Logic", () => {
94
94
  slug: "about",
95
95
  title: "About",
96
96
  });
97
- const navItem = await navItemService.create({
97
+ await navItemService.create({
98
98
  type: "page",
99
99
  label: "About",
100
100
  url: "/about",
@@ -105,7 +105,7 @@ describe("Dashboard Pages - Nav Management Logic", () => {
105
105
  const allNavItems = await navItemService.list();
106
106
  const found = allNavItems.find((item) => item.pageId === page.id);
107
107
  expect(found).toBeDefined();
108
- await navItemService.delete(found!.id);
108
+ await navItemService.delete(found?.id as number);
109
109
 
110
110
  // Nav item should be gone
111
111
  const navItems = await navItemService.list();
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tests for avatar upload with favicon variant storage in DB settings.
2
+ * Tests for avatar upload with favicon variant storage.
3
3
  *
4
4
  * Note: Route handlers that import JSX components with @lingui/react/macro
5
5
  * cannot run in vitest (requires SWC plugin). These tests verify the
@@ -28,7 +28,7 @@ describe("Dashboard Settings - Avatar Upload Logic", () => {
28
28
  mediaService = createMediaService(db);
29
29
  });
30
30
 
31
- describe("avatar upload with favicon variants in DB", () => {
31
+ describe("avatar upload with favicon variants", () => {
32
32
  it("stores avatar media and sets SITE_AVATAR to storageKey", async () => {
33
33
  const storageKey = "media/2026/02/test-avatar-id.png";
34
34
  await mediaService.create({
@@ -54,36 +54,47 @@ describe("Dashboard Settings - Avatar Upload Logic", () => {
54
54
 
55
55
  const stored = await settingsService.get("SITE_FAVICON_ICO");
56
56
  expect(stored).not.toBeNull();
57
- const decoded = base64ToUint8Array(stored!);
57
+ const decoded = base64ToUint8Array(stored as string);
58
58
  expect(Array.from(decoded)).toEqual(Array.from(fakeIcoData));
59
59
  });
60
60
 
61
- it("stores apple-touch-icon as base64 in settings", async () => {
62
- const fakePng = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
63
- const b64 = arrayBufferToBase64(fakePng.buffer);
64
- await settingsService.set("SITE_FAVICON_APPLE_TOUCH", b64);
61
+ it("stores apple-touch-icon as R2 storage key in settings", async () => {
62
+ const appleTouchKey = "favicon/apple-touch-icon.png";
63
+ await settingsService.set("SITE_FAVICON_APPLE_TOUCH", appleTouchKey);
65
64
 
66
65
  const stored = await settingsService.get("SITE_FAVICON_APPLE_TOUCH");
67
- expect(stored).not.toBeNull();
68
- const decoded = base64ToUint8Array(stored!);
69
- expect(Array.from(decoded)).toEqual(Array.from(fakePng));
66
+ expect(stored).toBe(appleTouchKey);
67
+ });
68
+
69
+ it("sets SITE_FAVICON_VERSION on upload", async () => {
70
+ const version = "202602191430";
71
+ await settingsService.set("SITE_FAVICON_VERSION", version);
72
+
73
+ const stored = await settingsService.get("SITE_FAVICON_VERSION");
74
+ expect(stored).toBe(version);
70
75
  });
71
76
  });
72
77
 
73
78
  describe("avatar removal cleans up favicon settings", () => {
74
- it("removes all favicon-related settings", async () => {
79
+ it("removes all favicon-related settings including version", async () => {
75
80
  await settingsService.set("SITE_AVATAR", "media/2026/02/some-id.png");
76
81
  await settingsService.set("SITE_FAVICON_ICO", "base64data");
77
- await settingsService.set("SITE_FAVICON_APPLE_TOUCH", "base64data");
82
+ await settingsService.set(
83
+ "SITE_FAVICON_APPLE_TOUCH",
84
+ "favicon/apple-touch-icon.png",
85
+ );
86
+ await settingsService.set("SITE_FAVICON_VERSION", "202602191430");
78
87
 
79
88
  // Simulate avatar removal
80
89
  await settingsService.remove("SITE_AVATAR");
81
90
  await settingsService.remove("SITE_FAVICON_ICO");
82
91
  await settingsService.remove("SITE_FAVICON_APPLE_TOUCH");
92
+ await settingsService.remove("SITE_FAVICON_VERSION");
83
93
 
84
94
  expect(await settingsService.get("SITE_AVATAR")).toBeNull();
85
95
  expect(await settingsService.get("SITE_FAVICON_ICO")).toBeNull();
86
96
  expect(await settingsService.get("SITE_FAVICON_APPLE_TOUCH")).toBeNull();
97
+ expect(await settingsService.get("SITE_FAVICON_VERSION")).toBeNull();
87
98
  });
88
99
  });
89
100
  });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Dashboard Appearance Routes
3
+ *
4
+ * Sub-pages: Color Theme, Font Theme, Advanced (Custom CSS)
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import { msg } from "@lingui/core/macro";
9
+ import type { Bindings } from "../../types.js";
10
+ import type { AppVariables } from "../../types/app-context.js";
11
+ import { DashLayout } from "../../ui/layouts/DashLayout.js";
12
+ import { dsRedirect, dsToast } from "../../lib/sse.js";
13
+ import { getI18n } from "../../i18n/index.js";
14
+ import { SETTINGS_KEYS } from "../../lib/constants.js";
15
+ import { getAvailableThemes } from "../../lib/theme.js";
16
+ import { BUILTIN_FONT_THEMES } from "../../ui/font-themes.js";
17
+ import { ColorThemeContent } from "../../ui/dash/appearance/ColorThemeContent.js";
18
+ import { FontThemeContent } from "../../ui/dash/appearance/FontThemeContent.js";
19
+ import { AdvancedContent } from "../../ui/dash/appearance/AdvancedContent.js";
20
+
21
+ type Env = { Bindings: Bindings; Variables: AppVariables };
22
+
23
+ export const appearanceRoutes = new Hono<Env>();
24
+
25
+ // ===========================================================================
26
+ // Color Theme
27
+ // ===========================================================================
28
+
29
+ appearanceRoutes.get("/", async (c) => {
30
+ const siteName = c.var.appConfig.siteName;
31
+ const defaultThemeId = c.var.appConfig.fallbacks.defaultTheme;
32
+ const currentThemeId =
33
+ c.var.allSettings[SETTINGS_KEYS.THEME] ?? defaultThemeId;
34
+ const themes = getAvailableThemes();
35
+ const saved = c.req.query("saved") !== undefined;
36
+
37
+ return c.html(
38
+ <DashLayout
39
+ c={c}
40
+ title="Appearance"
41
+ siteName={siteName}
42
+ currentPath="/dash/appearance"
43
+ toast={saved ? { message: "Theme saved successfully." } : undefined}
44
+ >
45
+ <ColorThemeContent themes={themes} currentThemeId={currentThemeId} />
46
+ </DashLayout>,
47
+ );
48
+ });
49
+
50
+ appearanceRoutes.post("/", async (c) => {
51
+ const i18n = getI18n(c);
52
+ const body = await c.req.json<{ theme: string }>();
53
+ const { settings } = c.var.services;
54
+ const themes = getAvailableThemes();
55
+
56
+ const validTheme = themes.find((t) => t.id === body.theme);
57
+ if (!validTheme) {
58
+ return dsToast(
59
+ i18n._(
60
+ msg({
61
+ message: "Invalid theme selected.",
62
+ comment: "@context: Error toast when selected theme is not valid",
63
+ }),
64
+ ),
65
+ "error",
66
+ );
67
+ }
68
+
69
+ const defaultThemeId = c.var.appConfig.fallbacks.defaultTheme;
70
+ if (validTheme.id === defaultThemeId) {
71
+ await settings.remove(SETTINGS_KEYS.THEME);
72
+ } else {
73
+ await settings.set(SETTINGS_KEYS.THEME, validTheme.id);
74
+ }
75
+
76
+ return dsRedirect("/dash/appearance?saved");
77
+ });
78
+
79
+ // ===========================================================================
80
+ // Font Theme
81
+ // ===========================================================================
82
+
83
+ appearanceRoutes.get("/fonts", async (c) => {
84
+ const siteName = c.var.appConfig.siteName;
85
+ const currentFontThemeId = c.var.allSettings["FONT_THEME"] ?? "default";
86
+ const saved = c.req.query("saved") !== undefined;
87
+
88
+ return c.html(
89
+ <DashLayout
90
+ c={c}
91
+ title="Appearance"
92
+ siteName={siteName}
93
+ currentPath="/dash/appearance"
94
+ toast={saved ? { message: "Font theme saved successfully." } : undefined}
95
+ >
96
+ <FontThemeContent
97
+ fontThemes={BUILTIN_FONT_THEMES}
98
+ currentFontThemeId={currentFontThemeId}
99
+ />
100
+ </DashLayout>,
101
+ );
102
+ });
103
+
104
+ appearanceRoutes.post("/font-theme", async (c) => {
105
+ const i18n = getI18n(c);
106
+ const body = await c.req.json<{ fontTheme: string }>();
107
+ const { settings } = c.var.services;
108
+
109
+ const validFont = BUILTIN_FONT_THEMES.find((f) => f.id === body.fontTheme);
110
+ if (!validFont) {
111
+ return dsToast(
112
+ i18n._(
113
+ msg({
114
+ message: "Invalid font theme selected.",
115
+ comment:
116
+ "@context: Error toast when selected font theme is not valid",
117
+ }),
118
+ ),
119
+ "error",
120
+ );
121
+ }
122
+
123
+ if (validFont.id === "default") {
124
+ await settings.remove("FONT_THEME");
125
+ } else {
126
+ await settings.set("FONT_THEME", validFont.id);
127
+ }
128
+
129
+ return dsRedirect("/dash/appearance/fonts?saved");
130
+ });
131
+
132
+ // ===========================================================================
133
+ // Advanced (Custom CSS)
134
+ // ===========================================================================
135
+
136
+ appearanceRoutes.get("/advanced", async (c) => {
137
+ const siteName = c.var.appConfig.siteName;
138
+ const customCSS = c.var.allSettings[SETTINGS_KEYS.CUSTOM_CSS] ?? "";
139
+
140
+ return c.html(
141
+ <DashLayout
142
+ c={c}
143
+ title="Appearance"
144
+ siteName={siteName}
145
+ currentPath="/dash/appearance"
146
+ >
147
+ <AdvancedContent customCSS={customCSS} />
148
+ </DashLayout>,
149
+ );
150
+ });
151
+
152
+ appearanceRoutes.post("/custom-css", async (c) => {
153
+ const i18n = getI18n(c);
154
+ const body = await c.req.json<{ customCSS: string }>();
155
+ const { settings } = c.var.services;
156
+
157
+ const css = body.customCSS?.trim() ?? "";
158
+
159
+ if (css) {
160
+ await settings.set(SETTINGS_KEYS.CUSTOM_CSS, css);
161
+ } else {
162
+ await settings.remove(SETTINGS_KEYS.CUSTOM_CSS);
163
+ }
164
+
165
+ return dsToast(
166
+ i18n._(
167
+ msg({
168
+ message: "Custom CSS saved successfully.",
169
+ comment: "@context: Toast after saving custom CSS",
170
+ }),
171
+ ),
172
+ );
173
+ });
@@ -3,16 +3,17 @@
3
3
  */
4
4
 
5
5
  import { Hono } from "hono";
6
- import type { Bindings } from "../../types.js";
7
- import type { AppVariables } from "../../app.js";
6
+ import type { Bindings, SortOrder } from "../../types.js";
7
+ import type { AppVariables } from "../../types/app-context.js";
8
8
  import { DashLayout } from "../../ui/layouts/DashLayout.js";
9
9
  import { DangerZone } from "../../ui/dash/index.js";
10
10
  import { dsRedirect } from "../../lib/sse.js";
11
- import { getSiteName } from "../../lib/config.js";
12
11
  import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
12
+ import { slugify } from "../../lib/url.js";
13
13
  import { CollectionsListContent } from "../../ui/dash/collections/CollectionsListContent.js";
14
14
  import { CollectionForm } from "../../ui/dash/collections/CollectionForm.js";
15
15
  import { ViewCollectionContent } from "../../ui/dash/collections/ViewCollectionContent.js";
16
+ import { IconPickerGrid } from "../../ui/dash/collections/IconPickerGrid.js";
16
17
 
17
18
  type Env = { Bindings: Bindings; Variables: AppVariables };
18
19
 
@@ -20,8 +21,12 @@ export const collectionsRoutes = new Hono<Env>();
20
21
 
21
22
  // List collections
22
23
  collectionsRoutes.get("/", async (c) => {
23
- const siteName = await getSiteName(c);
24
- const collections = await c.var.services.collections.list();
24
+ const siteName = c.var.appConfig.siteName;
25
+ const [collections, dividers, postCounts] = await Promise.all([
26
+ c.var.services.collections.list(),
27
+ c.var.services.collections.listDividers(),
28
+ c.var.services.collections.getPostCounts(),
29
+ ]);
25
30
 
26
31
  return c.html(
27
32
  <DashLayout
@@ -30,14 +35,18 @@ collectionsRoutes.get("/", async (c) => {
30
35
  siteName={siteName}
31
36
  currentPath="/dash/collections"
32
37
  >
33
- <CollectionsListContent collections={collections} />
38
+ <CollectionsListContent
39
+ collections={collections}
40
+ dividers={dividers}
41
+ postCounts={postCounts}
42
+ />
34
43
  </DashLayout>,
35
44
  );
36
45
  });
37
46
 
38
47
  // New collection form
39
48
  collectionsRoutes.get("/new", async (c) => {
40
- const siteName = await getSiteName(c);
49
+ const siteName = c.var.appConfig.siteName;
41
50
 
42
51
  return c.html(
43
52
  <DashLayout
@@ -53,19 +62,66 @@ collectionsRoutes.get("/new", async (c) => {
53
62
 
54
63
  // Create collection
55
64
  collectionsRoutes.post("/", async (c) => {
65
+ const wantsJson = c.req.header("Accept")?.includes("application/json");
56
66
  const body = await c.req.json<{
57
67
  title: string;
58
68
  slug: string;
59
69
  description?: string;
70
+ icon?: string;
71
+ sortOrder?: string;
60
72
  }>();
61
73
 
74
+ // Auto-generate slug from title if empty
75
+ const slug = body.slug || slugify(body.title);
76
+
62
77
  const collection = await c.var.services.collections.create({
63
78
  title: body.title,
64
- slug: body.slug,
79
+ slug,
65
80
  description: body.description || undefined,
81
+ icon: body.icon || undefined,
82
+ sortOrder: (body.sortOrder as SortOrder) || undefined,
66
83
  });
67
84
 
68
- return dsRedirect(`/dash/collections/${collection.id}`);
85
+ const redirectUrl = `/dash/collections/${collection.id}`;
86
+ if (wantsJson) {
87
+ return c.json({ status: "redirect" as const, url: redirectUrl });
88
+ }
89
+
90
+ return dsRedirect(redirectUrl);
91
+ });
92
+
93
+ // Reorder collections (accepts prefixed items)
94
+ collectionsRoutes.post("/reorder", async (c) => {
95
+ const body = await c.req.json<{ items?: string[]; ids?: number[] }>();
96
+
97
+ if (body.items) {
98
+ await c.var.services.collections.reorderAll(body.items);
99
+ } else if (body.ids) {
100
+ // Backward compat: plain numeric IDs
101
+ await c.var.services.collections.reorder(body.ids);
102
+ }
103
+
104
+ return c.json({ success: true });
105
+ });
106
+
107
+ // Create divider
108
+ collectionsRoutes.post("/dividers", async (c) => {
109
+ await c.var.services.collections.createDivider();
110
+ return dsRedirect("/dash/collections");
111
+ });
112
+
113
+ // Delete divider
114
+ collectionsRoutes.post("/dividers/:id/delete", async (c) => {
115
+ const id = parseInt(c.req.param("id"), 10);
116
+ if (!isNaN(id)) {
117
+ await c.var.services.collections.deleteDivider(id);
118
+ }
119
+ return dsRedirect("/dash/collections");
120
+ });
121
+
122
+ // Icon picker grid (HTML fragment)
123
+ collectionsRoutes.get("/icons", (c) => {
124
+ return c.html(<IconPickerGrid />);
69
125
  });
70
126
 
71
127
  // View single collection
@@ -77,9 +133,9 @@ collectionsRoutes.get("/:id", async (c) => {
77
133
  if (!collection) return c.notFound();
78
134
 
79
135
  const rawPosts = await c.var.services.posts.list({ collectionId: id });
80
- const ctx = createMediaContext(c);
136
+ const ctx = createMediaContext(c.var.appConfig);
81
137
  const posts = toPostViewsFromPosts(rawPosts, ctx);
82
- const siteName = await getSiteName(c);
138
+ const siteName = c.var.appConfig.siteName;
83
139
 
84
140
  return c.html(
85
141
  <DashLayout
@@ -101,7 +157,7 @@ collectionsRoutes.get("/:id/edit", async (c) => {
101
157
  const collection = await c.var.services.collections.getById(id);
102
158
  if (!collection) return c.notFound();
103
159
 
104
- const siteName = await getSiteName(c);
160
+ const siteName = c.var.appConfig.siteName;
105
161
 
106
162
  return c.html(
107
163
  <DashLayout
@@ -125,19 +181,29 @@ collectionsRoutes.post("/:id", async (c) => {
125
181
  const id = parseInt(c.req.param("id"), 10);
126
182
  if (isNaN(id)) return c.notFound();
127
183
 
184
+ const wantsJson = c.req.header("Accept")?.includes("application/json");
128
185
  const body = await c.req.json<{
129
186
  title: string;
130
187
  slug: string;
131
188
  description?: string;
189
+ icon?: string;
190
+ sortOrder?: string;
132
191
  }>();
133
192
 
134
193
  await c.var.services.collections.update(id, {
135
194
  title: body.title,
136
195
  slug: body.slug,
137
- description: body.description || undefined,
196
+ description: body.description || null,
197
+ icon: body.icon || null,
198
+ sortOrder: (body.sortOrder as SortOrder) || undefined,
138
199
  });
139
200
 
140
- return dsRedirect(`/dash/collections/${id}`);
201
+ const redirectUrl = `/dash/collections/${id}`;
202
+ if (wantsJson) {
203
+ return c.json({ status: "redirect" as const, url: redirectUrl });
204
+ }
205
+
206
+ return dsRedirect(redirectUrl);
141
207
  });
142
208
 
143
209
  // Delete collection
@@ -7,9 +7,8 @@
7
7
  import { Hono } from "hono";
8
8
  import { Trans, useLingui } from "@lingui/react/macro";
9
9
  import type { Bindings } from "../../types.js";
10
- import type { AppVariables } from "../../app.js";
10
+ import type { AppVariables } from "../../types/app-context.js";
11
11
  import { DashLayout } from "../../ui/layouts/DashLayout.js";
12
- import { getSiteName } from "../../lib/config.js";
13
12
 
14
13
  type Env = { Bindings: Bindings; Variables: AppVariables };
15
14
 
@@ -30,9 +29,8 @@ function DashboardContent({
30
29
  const { t } = useLingui();
31
30
 
32
31
  return (
33
- <div class="container py-8">
32
+ <>
34
33
  <h1 class="text-2xl font-semibold mb-6">
35
- {/* ✅ No more nesting! */}
36
34
  {t({
37
35
  message: "Dashboard",
38
36
  comment: "@context: Dashboard main heading",
@@ -64,7 +62,7 @@ function DashboardContent({
64
62
  comment: "@context: Dashboard section title",
65
63
  })}
66
64
  </p>
67
- <a href="/dash/posts/new" class="btn btn-primary w-full">
65
+ <a href="/dash/posts/new" class="btn-primary w-full">
68
66
  {t({
69
67
  message: "New Post",
70
68
  comment: "@context: Button to create new post",
@@ -73,7 +71,6 @@ function DashboardContent({
73
71
  </div>
74
72
  </div>
75
73
 
76
- {/* ✅ Trans component with embedded JSX! */}
77
74
  <p>
78
75
  <Trans comment="@context: Help text with link">
79
76
  Need help? Visit the{" "}
@@ -82,23 +79,24 @@ function DashboardContent({
82
79
  </a>
83
80
  </Trans>
84
81
  </p>
85
- </div>
82
+ </>
86
83
  );
87
84
  }
88
85
 
89
86
  dashIndexRoutes.get("/", async (c) => {
90
- const siteName = await getSiteName(c);
87
+ const siteName = c.var.appConfig.siteName;
91
88
 
92
- // Get some stats
93
- const allPosts = await c.var.services.posts.list({ limit: 1000 });
94
- const publishedPosts = allPosts.filter((p) => p.status !== "draft");
95
- const draftPosts = allPosts.filter((p) => p.status === "draft");
89
+ // Get stats via service-level counting (avoids loading all posts into memory)
90
+ const [publishedCount, draftCount] = await Promise.all([
91
+ c.var.services.posts.count({ status: "published" }),
92
+ c.var.services.posts.count({ status: "draft" }),
93
+ ]);
96
94
 
97
95
  return c.html(
98
96
  <DashLayout c={c} title="Dashboard" siteName={siteName} currentPath="/dash">
99
97
  <DashboardContent
100
- publishedCount={publishedPosts.length}
101
- draftCount={draftPosts.length}
98
+ publishedCount={publishedCount}
99
+ draftCount={draftCount}
102
100
  />
103
101
  </DashLayout>,
104
102
  );