@jant/core 0.3.27 → 0.3.29

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/bin/reset-password.js +22 -0
  2. package/dist/client/client.css +1 -0
  3. package/dist/client/client.js +31561 -0
  4. package/dist/index.js +15209 -15
  5. package/package.json +25 -15
  6. package/src/__tests__/helpers/app.ts +19 -3
  7. package/src/__tests__/helpers/db.ts +44 -0
  8. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  9. package/src/app.tsx +111 -174
  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 -267
  172. package/dist/auth.js +0 -39
  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
@@ -1,42 +1,23 @@
1
1
  /**
2
2
  * Dashboard Settings Routes
3
3
  *
4
- * Sub-pages: General, Appearance, Account
4
+ * Sub-pages: General, Account
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
+ import { msg } from "@lingui/core/macro";
8
9
  import type { Bindings } from "../../types.js";
9
- import type { AppVariables } from "../../app.js";
10
+ import type { AppVariables } from "../../types/app-context.js";
10
11
  import { DashLayout } from "../../ui/layouts/DashLayout.js";
11
12
  import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
13
+ import { getI18n } from "../../i18n/index.js";
12
14
  import { arrayBufferToBase64 } from "../../lib/favicon.js";
13
- import {
14
- getSiteLanguage,
15
- getSiteName,
16
- getHomeDefaultView,
17
- getTimeZone,
18
- getSiteFooter,
19
- isNoIndex,
20
- getConfigFallback,
21
- } from "../../lib/config.js";
22
- import { SETTINGS_KEYS } from "../../lib/constants.js";
23
- import { getAvailableThemes } from "../../lib/theme.js";
24
- import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
25
15
  import { TIMEZONES } from "../../lib/timezones.js";
26
- import { BUILTIN_FONT_THEMES } from "../../ui/font-themes.js";
16
+ import { escapeHtml } from "../../lib/html.js";
17
+ import { validateUploadFile, generateStorageKey } from "../../lib/upload.js";
27
18
  import { GeneralContent } from "../../ui/dash/settings/GeneralContent.js";
28
- import { AppearanceContent } from "../../ui/dash/settings/AppearanceContent.js";
29
19
  import { AccountContent } from "../../ui/dash/settings/AccountContent.js";
30
20
 
31
- /** Escape HTML special characters for safe insertion into HTML strings */
32
- function escapeHtml(str: string): string {
33
- return str
34
- .replace(/&/g, "&")
35
- .replace(/</g, "&lt;")
36
- .replace(/>/g, "&gt;")
37
- .replace(/"/g, "&quot;");
38
- }
39
-
40
21
  type Env = { Bindings: Bindings; Variables: AppVariables };
41
22
 
42
23
  export const settingsRoutes = new Hono<Env>();
@@ -45,41 +26,11 @@ export const settingsRoutes = new Hono<Env>();
45
26
  // General settings
46
27
  // ===========================================================================
47
28
 
48
- /** Resolve the avatar storage key to a URL */
49
- async function resolveAvatarUrl(c: {
50
- var: { services: AppVariables["services"] };
51
- env: Bindings;
52
- }): Promise<string> {
53
- const avatarKey = await c.var.services.settings.get("SITE_AVATAR");
54
- if (!avatarKey) return "";
55
- const publicUrl = getPublicUrlForProvider(
56
- c.env.STORAGE_DRIVER || "r2",
57
- c.env.R2_PUBLIC_URL,
58
- c.env.S3_PUBLIC_URL,
59
- );
60
- return getMediaUrl(avatarKey, publicUrl);
61
- }
62
-
63
29
  settingsRoutes.get("/", async (c) => {
64
- const { settings } = c.var.services;
30
+ const { allSettings, appConfig } = c.var;
65
31
 
66
- const dbSiteName = await settings.get("SITE_NAME");
67
- const dbSiteDescription = await settings.get("SITE_DESCRIPTION");
68
- const [siteLanguage, homeDefaultView, timeZone, siteFooter, noindex] =
69
- await Promise.all([
70
- getSiteLanguage(c),
71
- getHomeDefaultView(c),
72
- getTimeZone(c),
73
- getSiteFooter(c),
74
- isNoIndex(c),
75
- ]);
76
-
77
- const siteNameFallback = getConfigFallback(c, "SITE_NAME");
78
- const siteDescriptionFallback = getConfigFallback(c, "SITE_DESCRIPTION");
79
-
80
- const siteAvatarUrl = await resolveAvatarUrl(c);
81
- const showHeaderAvatar =
82
- (await settings.get("SHOW_HEADER_AVATAR")) === "true";
32
+ const dbSiteName = allSettings["SITE_NAME"] ?? "";
33
+ const dbSiteDescription = allSettings["SITE_DESCRIPTION"] ?? "";
83
34
 
84
35
  const saved = c.req.query("saved") !== undefined;
85
36
 
@@ -87,22 +38,22 @@ settingsRoutes.get("/", async (c) => {
87
38
  <DashLayout
88
39
  c={c}
89
40
  title="Settings"
90
- siteName={dbSiteName || siteNameFallback}
41
+ siteName={dbSiteName || appConfig.fallbacks.siteName}
91
42
  currentPath="/dash/settings"
92
43
  toast={saved ? { message: "Settings saved successfully." } : undefined}
93
44
  >
94
45
  <GeneralContent
95
46
  siteName={dbSiteName || ""}
96
47
  siteDescription={dbSiteDescription || ""}
97
- siteLanguage={siteLanguage}
98
- homeDefaultView={homeDefaultView}
99
- siteNameFallback={siteNameFallback}
100
- siteDescriptionFallback={siteDescriptionFallback}
101
- siteAvatarUrl={siteAvatarUrl}
102
- showHeaderAvatar={showHeaderAvatar}
103
- timeZone={timeZone}
104
- siteFooter={siteFooter}
105
- noindex={noindex}
48
+ siteLanguage={appConfig.siteLanguage}
49
+ homeDefaultView={appConfig.homeDefaultView}
50
+ siteNameFallback={appConfig.fallbacks.siteName}
51
+ siteDescriptionFallback={appConfig.fallbacks.siteDescription}
52
+ siteAvatarUrl={appConfig.siteAvatarUrl}
53
+ showHeaderAvatar={appConfig.showHeaderAvatar}
54
+ timeZone={appConfig.timeZone}
55
+ siteFooter={appConfig.siteFooter}
56
+ noindex={appConfig.noindex}
106
57
  timezones={TIMEZONES}
107
58
  />
108
59
  </DashLayout>,
@@ -110,9 +61,11 @@ settingsRoutes.get("/", async (c) => {
110
61
  });
111
62
 
112
63
  settingsRoutes.post("/", async (c) => {
64
+ const i18n = getI18n(c);
113
65
  const body = await c.req.json<{
114
66
  siteName: string;
115
67
  siteDescription: string;
68
+ siteFooter: string;
116
69
  siteLanguage: string;
117
70
  homeDefaultView: string;
118
71
  timeZone: string;
@@ -120,7 +73,7 @@ settingsRoutes.post("/", async (c) => {
120
73
 
121
74
  const { settings } = c.var.services;
122
75
 
123
- const oldLanguage = (await settings.get("SITE_LANGUAGE")) ?? "en";
76
+ const oldLanguage = c.var.allSettings["SITE_LANGUAGE"] ?? "en";
124
77
 
125
78
  if (body.siteName.trim()) {
126
79
  await settings.set("SITE_NAME", body.siteName.trim());
@@ -134,6 +87,13 @@ settingsRoutes.post("/", async (c) => {
134
87
  await settings.remove("SITE_DESCRIPTION");
135
88
  }
136
89
 
90
+ // Footer
91
+ if (body.siteFooter?.trim()) {
92
+ await settings.set("SITE_FOOTER", body.siteFooter.trim());
93
+ } else {
94
+ await settings.remove("SITE_FOOTER");
95
+ }
96
+
137
97
  await settings.set("SITE_LANGUAGE", body.siteLanguage);
138
98
 
139
99
  // Save homepage default view (only store if non-default)
@@ -151,7 +111,29 @@ settingsRoutes.post("/", async (c) => {
151
111
  }
152
112
 
153
113
  const languageChanged = oldLanguage !== body.siteLanguage;
154
- const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
114
+ const displayName =
115
+ body.siteName.trim() || c.var.appConfig.fallbacks.siteName;
116
+
117
+ // ── JSON response mode (used by Lit settings bridge) ──────────────
118
+ const wantsJson = c.req.header("accept")?.includes("application/json");
119
+ if (wantsJson) {
120
+ if (languageChanged) {
121
+ return c.json({
122
+ status: "redirect" as const,
123
+ url: "/dash/settings?saved",
124
+ });
125
+ }
126
+ return c.json({
127
+ status: "ok" as const,
128
+ toast: i18n._(
129
+ msg({
130
+ message: "Settings saved successfully.",
131
+ comment: "@context: Toast after saving general settings",
132
+ }),
133
+ ),
134
+ siteName: displayName,
135
+ });
136
+ }
155
137
 
156
138
  return sse(c, async (stream) => {
157
139
  if (languageChanged) {
@@ -165,10 +147,18 @@ settingsRoutes.post("/", async (c) => {
165
147
  mode: "inner",
166
148
  selector: "title",
167
149
  });
168
- await stream.toast("Settings saved successfully.");
150
+ await stream.toast(
151
+ i18n._(
152
+ msg({
153
+ message: "Settings saved successfully.",
154
+ comment: "@context: Toast after saving general settings",
155
+ }),
156
+ ),
157
+ );
169
158
  await stream.patchSignals({
170
159
  _orig_siteName: body.siteName,
171
160
  _orig_siteDescription: body.siteDescription,
161
+ _orig_siteFooter: body.siteFooter,
172
162
  _orig_siteLanguage: body.siteLanguage,
173
163
  _orig_homeDefaultView: body.homeDefaultView,
174
164
  _orig_timeZone: body.timeZone,
@@ -178,26 +168,8 @@ settingsRoutes.post("/", async (c) => {
178
168
  });
179
169
  });
180
170
 
181
- settingsRoutes.post("/footer", async (c) => {
182
- const body = await c.req.json<{ siteFooter: string }>();
183
- const { settings } = c.var.services;
184
-
185
- if (body.siteFooter?.trim()) {
186
- await settings.set("SITE_FOOTER", body.siteFooter.trim());
187
- } else {
188
- await settings.remove("SITE_FOOTER");
189
- }
190
-
191
- return sse(c, async (stream) => {
192
- await stream.toast("Footer saved successfully.");
193
- await stream.patchSignals({
194
- _orig_siteFooter: body.siteFooter,
195
- _footerDirty: false,
196
- });
197
- });
198
- });
199
-
200
171
  settingsRoutes.post("/seo", async (c) => {
172
+ const i18n = getI18n(c);
201
173
  const body = await c.req.json<{ noindex: string }>();
202
174
  const { settings } = c.var.services;
203
175
 
@@ -210,8 +182,29 @@ settingsRoutes.post("/seo", async (c) => {
210
182
  await settings.set("NOINDEX", "true");
211
183
  }
212
184
 
185
+ // ── JSON response mode (used by Lit settings bridge) ──────────────
186
+ const wantsJson = c.req.header("accept")?.includes("application/json");
187
+ if (wantsJson) {
188
+ return c.json({
189
+ status: "ok" as const,
190
+ toast: i18n._(
191
+ msg({
192
+ message: "SEO settings saved successfully.",
193
+ comment: "@context: Toast after saving SEO settings",
194
+ }),
195
+ ),
196
+ });
197
+ }
198
+
213
199
  return sse(c, async (stream) => {
214
- await stream.toast("SEO settings saved successfully.");
200
+ await stream.toast(
201
+ i18n._(
202
+ msg({
203
+ message: "SEO settings saved successfully.",
204
+ comment: "@context: Toast after saving SEO settings",
205
+ }),
206
+ ),
207
+ );
215
208
  await stream.patchSignals({
216
209
  _orig_noindex: body.noindex,
217
210
  _seoDirty: false,
@@ -224,41 +217,40 @@ settingsRoutes.post("/seo", async (c) => {
224
217
  // ===========================================================================
225
218
 
226
219
  settingsRoutes.post("/avatar", async (c) => {
220
+ const i18n = getI18n(c);
227
221
  const storage = c.var.storage;
228
222
  if (!storage) {
229
- return dsToast("Storage not configured.", "error");
223
+ return dsToast(
224
+ i18n._(
225
+ msg({
226
+ message: "Storage not configured.",
227
+ comment: "@context: Error toast when file storage is not set up",
228
+ }),
229
+ ),
230
+ "error",
231
+ );
230
232
  }
231
233
 
232
234
  const formData = await c.req.formData();
233
235
  const file = formData.get("file") as File | null;
234
236
  if (!file) {
235
- return dsToast("No file provided.", "error");
236
- }
237
-
238
- const allowedTypes = [
239
- "image/jpeg",
240
- "image/png",
241
- "image/gif",
242
- "image/webp",
243
- "image/svg+xml",
244
- ];
245
- if (!allowedTypes.includes(file.type)) {
246
- return dsToast("File type not allowed.", "error");
237
+ return dsToast(
238
+ i18n._(
239
+ msg({
240
+ message: "No file provided.",
241
+ comment: "@context: Error toast when no file was selected for upload",
242
+ }),
243
+ ),
244
+ "error",
245
+ );
247
246
  }
248
247
 
249
- const maxSize = 10 * 1024 * 1024;
250
- if (file.size > maxSize) {
251
- return dsToast("File too large (max 10MB).", "error");
248
+ const uploadError = validateUploadFile(file);
249
+ if (uploadError) {
250
+ return dsToast(uploadError, "error");
252
251
  }
253
252
 
254
- const { uuidv7 } = await import("uuidv7");
255
- const ext = file.name.split(".").pop() || "bin";
256
- const id = uuidv7();
257
- const date = new Date();
258
- const year = date.getUTCFullYear();
259
- const month = String(date.getUTCMonth() + 1).padStart(2, "0");
260
- const filename = `${id}.${ext}`;
261
- const storageKey = `media/${year}/${month}/${filename}`;
253
+ const { id, filename, storageKey } = generateStorageKey(file.name);
262
254
 
263
255
  try {
264
256
  await storage.put(storageKey, file.stream(), {
@@ -272,39 +264,80 @@ settingsRoutes.post("/avatar", async (c) => {
272
264
  mimeType: file.type,
273
265
  size: file.size,
274
266
  storageKey,
275
- provider: c.env.STORAGE_DRIVER || "r2",
267
+ provider: c.var.appConfig.storageDriver,
276
268
  });
277
269
 
278
270
  await c.var.services.settings.set("SITE_AVATAR", storageKey);
279
271
 
280
- // Store favicon variants as base64 in settings (small files, accessed every page load)
272
+ // Store favicon ICO as base64 in settings (tiny file, accessed every page load)
281
273
  const faviconFile = formData.get("favicon") as File | null;
282
- const appleTouchFile = formData.get("appleTouch") as File | null;
283
-
284
274
  if (faviconFile) {
285
275
  const b64 = arrayBufferToBase64(await faviconFile.arrayBuffer());
286
276
  await c.var.services.settings.set("SITE_FAVICON_ICO", b64);
287
277
  }
288
278
 
279
+ // Store apple-touch-icon in R2 (180x180 PNG, not tiny enough for base64)
280
+ const appleTouchFile = formData.get("appleTouch") as File | null;
289
281
  if (appleTouchFile) {
290
- const b64 = arrayBufferToBase64(await appleTouchFile.arrayBuffer());
291
- await c.var.services.settings.set("SITE_FAVICON_APPLE_TOUCH", b64);
282
+ const appleTouchKey = "favicon/apple-touch-icon.png";
283
+ await storage.put(
284
+ appleTouchKey,
285
+ new Uint8Array(await appleTouchFile.arrayBuffer()),
286
+ { contentType: "image/png" },
287
+ );
288
+ await c.var.services.settings.set(
289
+ "SITE_FAVICON_APPLE_TOUCH",
290
+ appleTouchKey,
291
+ );
292
292
  }
293
293
 
294
+ // Set favicon version for cache-busting
295
+ const now = new Date();
296
+ const version =
297
+ String(now.getUTCFullYear()) +
298
+ String(now.getUTCMonth() + 1).padStart(2, "0") +
299
+ String(now.getUTCDate()).padStart(2, "0") +
300
+ String(now.getUTCHours()).padStart(2, "0") +
301
+ String(now.getUTCMinutes()).padStart(2, "0");
302
+ await c.var.services.settings.set("SITE_FAVICON_VERSION", version);
303
+
294
304
  return dsRedirect("/dash/settings?saved");
295
305
  } catch {
296
- return dsToast("Upload failed. Please try again.", "error");
306
+ return dsToast(
307
+ i18n._(
308
+ msg({
309
+ message: "Upload failed. Please try again.",
310
+ comment: "@context: Error toast when avatar upload fails",
311
+ }),
312
+ ),
313
+ "error",
314
+ );
297
315
  }
298
316
  });
299
317
 
300
318
  settingsRoutes.post("/avatar/remove", async (c) => {
319
+ const storage = c.var.storage;
320
+ const appleTouchKey = c.var.allSettings["SITE_FAVICON_APPLE_TOUCH"];
321
+ if (storage && appleTouchKey) {
322
+ await storage.delete(appleTouchKey);
323
+ }
324
+
301
325
  await c.var.services.settings.remove("SITE_AVATAR");
302
326
  await c.var.services.settings.remove("SITE_FAVICON_ICO");
303
327
  await c.var.services.settings.remove("SITE_FAVICON_APPLE_TOUCH");
328
+ await c.var.services.settings.remove("SITE_FAVICON_VERSION");
329
+
330
+ // ── JSON response mode (used by Lit settings bridge) ──────────────
331
+ const wantsJson = c.req.header("accept")?.includes("application/json");
332
+ if (wantsJson) {
333
+ return c.json({ status: "redirect" as const, url: "/dash/settings?saved" });
334
+ }
335
+
304
336
  return dsRedirect("/dash/settings?saved");
305
337
  });
306
338
 
307
339
  settingsRoutes.post("/avatar/display", async (c) => {
340
+ const i18n = getI18n(c);
308
341
  const body = await c.req.json<{ showHeaderAvatar: string }>();
309
342
  const { settings } = c.var.services;
310
343
 
@@ -314,8 +347,29 @@ settingsRoutes.post("/avatar/display", async (c) => {
314
347
  await settings.remove("SHOW_HEADER_AVATAR");
315
348
  }
316
349
 
350
+ // ── JSON response mode (used by Lit settings bridge) ──────────────
351
+ const wantsJson = c.req.header("accept")?.includes("application/json");
352
+ if (wantsJson) {
353
+ return c.json({
354
+ status: "ok" as const,
355
+ toast: i18n._(
356
+ msg({
357
+ message: "Avatar display setting saved successfully.",
358
+ comment: "@context: Toast after saving avatar display preference",
359
+ }),
360
+ ),
361
+ });
362
+ }
363
+
317
364
  return sse(c, async (stream) => {
318
- await stream.toast("Avatar display setting saved successfully.");
365
+ await stream.toast(
366
+ i18n._(
367
+ msg({
368
+ message: "Avatar display setting saved successfully.",
369
+ comment: "@context: Toast after saving avatar display preference",
370
+ }),
371
+ ),
372
+ );
319
373
  await stream.patchSignals({
320
374
  _orig_showHeaderAvatar: body.showHeaderAvatar,
321
375
  _avatarDisplayDirty: false,
@@ -323,96 +377,12 @@ settingsRoutes.post("/avatar/display", async (c) => {
323
377
  });
324
378
  });
325
379
 
326
- // ===========================================================================
327
- // Appearance
328
- // ===========================================================================
329
-
330
- settingsRoutes.get("/appearance", async (c) => {
331
- const { settings } = c.var.services;
332
- const siteName = await getSiteName(c);
333
- const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
334
- const currentFontThemeId = (await settings.get("FONT_THEME")) ?? "default";
335
- const customCSS = (await settings.get(SETTINGS_KEYS.CUSTOM_CSS)) ?? "";
336
- const themes = getAvailableThemes(c.var.config);
337
- const saved = c.req.query("saved") !== undefined;
338
-
339
- return c.html(
340
- <DashLayout
341
- c={c}
342
- title="Settings"
343
- siteName={siteName}
344
- currentPath="/dash/settings"
345
- toast={saved ? { message: "Theme saved successfully." } : undefined}
346
- >
347
- <AppearanceContent
348
- themes={themes}
349
- currentThemeId={currentThemeId}
350
- fontThemes={BUILTIN_FONT_THEMES}
351
- currentFontThemeId={currentFontThemeId}
352
- customCSS={customCSS}
353
- />
354
- </DashLayout>,
355
- );
356
- });
357
-
358
- settingsRoutes.post("/appearance", async (c) => {
359
- const body = await c.req.json<{ theme: string }>();
360
- const { settings } = c.var.services;
361
- const themes = getAvailableThemes(c.var.config);
362
-
363
- const validTheme = themes.find((t) => t.id === body.theme);
364
- if (!validTheme) {
365
- return dsToast("Invalid theme selected.", "error");
366
- }
367
-
368
- if (validTheme.id === "default") {
369
- await settings.remove(SETTINGS_KEYS.THEME);
370
- } else {
371
- await settings.set(SETTINGS_KEYS.THEME, validTheme.id);
372
- }
373
-
374
- return dsRedirect("/dash/settings/appearance?saved");
375
- });
376
-
377
- settingsRoutes.post("/font-theme", async (c) => {
378
- const body = await c.req.json<{ fontTheme: string }>();
379
- const { settings } = c.var.services;
380
-
381
- const validFont = BUILTIN_FONT_THEMES.find((f) => f.id === body.fontTheme);
382
- if (!validFont) {
383
- return dsToast("Invalid font theme selected.", "error");
384
- }
385
-
386
- if (validFont.id === "default") {
387
- await settings.remove("FONT_THEME");
388
- } else {
389
- await settings.set("FONT_THEME", validFont.id);
390
- }
391
-
392
- return dsRedirect("/dash/settings/appearance?saved");
393
- });
394
-
395
- settingsRoutes.post("/custom-css", async (c) => {
396
- const body = await c.req.json<{ customCSS: string }>();
397
- const { settings } = c.var.services;
398
-
399
- const css = body.customCSS?.trim() ?? "";
400
-
401
- if (css) {
402
- await settings.set(SETTINGS_KEYS.CUSTOM_CSS, css);
403
- } else {
404
- await settings.remove(SETTINGS_KEYS.CUSTOM_CSS);
405
- }
406
-
407
- return dsToast("Custom CSS saved successfully.");
408
- });
409
-
410
380
  // ===========================================================================
411
381
  // Account
412
382
  // ===========================================================================
413
383
 
414
384
  settingsRoutes.get("/account", async (c) => {
415
- const siteName = await getSiteName(c);
385
+ const siteName = c.var.appConfig.siteName;
416
386
  const session = await c.var.auth.api.getSession({
417
387
  headers: c.req.raw.headers,
418
388
  });
@@ -433,11 +403,20 @@ settingsRoutes.get("/account", async (c) => {
433
403
  });
434
404
 
435
405
  settingsRoutes.post("/account", async (c) => {
406
+ const i18n = getI18n(c);
436
407
  const body = await c.req.json<{ userName: string }>();
437
408
  const name = body.userName?.trim();
438
409
 
439
410
  if (!name) {
440
- return dsToast("Name is required.", "error");
411
+ return dsToast(
412
+ i18n._(
413
+ msg({
414
+ message: "Name is required.",
415
+ comment: "@context: Error toast when display name is empty",
416
+ }),
417
+ ),
418
+ "error",
419
+ );
441
420
  }
442
421
 
443
422
  try {
@@ -446,13 +425,29 @@ settingsRoutes.post("/account", async (c) => {
446
425
  headers: c.req.raw.headers,
447
426
  });
448
427
  } catch {
449
- return dsToast("Failed to update profile.", "error");
428
+ return dsToast(
429
+ i18n._(
430
+ msg({
431
+ message: "Failed to update profile.",
432
+ comment: "@context: Error toast when profile update fails",
433
+ }),
434
+ ),
435
+ "error",
436
+ );
450
437
  }
451
438
 
452
- return dsToast("Profile saved successfully.");
439
+ return dsToast(
440
+ i18n._(
441
+ msg({
442
+ message: "Profile saved successfully.",
443
+ comment: "@context: Toast after saving user profile",
444
+ }),
445
+ ),
446
+ );
453
447
  });
454
448
 
455
449
  settingsRoutes.post("/password", async (c) => {
450
+ const i18n = getI18n(c);
456
451
  const body = await c.req.json<{
457
452
  currentPassword: string;
458
453
  newPassword: string;
@@ -460,7 +455,16 @@ settingsRoutes.post("/password", async (c) => {
460
455
  }>();
461
456
 
462
457
  if (body.newPassword !== body.confirmPassword) {
463
- return dsToast("Passwords do not match.", "error");
458
+ return dsToast(
459
+ i18n._(
460
+ msg({
461
+ message: "Passwords do not match.",
462
+ comment:
463
+ "@context: Error toast when new password and confirmation differ",
464
+ }),
465
+ ),
466
+ "error",
467
+ );
464
468
  }
465
469
 
466
470
  try {
@@ -473,11 +477,27 @@ settingsRoutes.post("/password", async (c) => {
473
477
  headers: c.req.raw.headers,
474
478
  });
475
479
  } catch {
476
- return dsToast("Current password is incorrect.", "error");
480
+ return dsToast(
481
+ i18n._(
482
+ msg({
483
+ message: "Current password is incorrect.",
484
+ comment:
485
+ "@context: Error toast when current password verification fails",
486
+ }),
487
+ ),
488
+ "error",
489
+ );
477
490
  }
478
491
 
479
492
  return sse(c, async (stream) => {
480
- await stream.toast("Password changed successfully.");
493
+ await stream.toast(
494
+ i18n._(
495
+ msg({
496
+ message: "Password changed successfully.",
497
+ comment: "@context: Toast after changing account password",
498
+ }),
499
+ ),
500
+ );
481
501
  await stream.patchSignals({
482
502
  currentPassword: "",
483
503
  newPassword: "",
@@ -1,11 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { Hono } from "hono";
3
3
  import type { Bindings } from "../../../types.js";
4
- import type { AppVariables } from "../../../app.js";
4
+ import type { AppVariables } from "../../../types/app-context.js";
5
5
  import { createTestDatabase } from "../../../__tests__/helpers/db.js";
6
6
  import { createPostService } from "../../../services/post.js";
7
7
  import { createSettingsService } from "../../../services/settings.js";
8
8
  import { createMediaService } from "../../../services/media.js";
9
+ import { resolveConfig } from "../../../lib/resolve-config.js";
9
10
  import { rssRoutes } from "../rss.js";
10
11
 
11
12
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -22,13 +23,16 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
22
23
  const app = new Hono<Env>();
23
24
 
24
25
  app.use("*", async (c, next) => {
25
- c.env = {
26
+ const env = {
26
27
  SITE_URL: "http://localhost:9019",
27
28
  ...envOverrides,
28
29
  } as Bindings;
30
+ c.env = env;
29
31
 
30
32
  c.set("services", services as AppVariables["services"]);
31
- c.set("config", {});
33
+ const allSettings = await services.settings.getAll();
34
+ c.set("allSettings", allSettings);
35
+ c.set("appConfig", resolveConfig(env, allSettings));
32
36
  await next();
33
37
  });
34
38