@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
@@ -5,19 +5,28 @@
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import { useLingui } from "@lingui/react/macro";
9
8
  import type { Bindings } from "../../types.js";
10
9
  import type { AppVariables } from "../../app.js";
11
- import { DashLayout } from "../../theme/layouts/index.js";
10
+ import { DashLayout } from "../../ui/layouts/DashLayout.js";
12
11
  import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
12
+ import { arrayBufferToBase64 } from "../../lib/favicon.js";
13
13
  import {
14
14
  getSiteLanguage,
15
15
  getSiteName,
16
+ getHomeDefaultView,
17
+ getTimeZone,
18
+ getSiteFooter,
19
+ isNoIndex,
16
20
  getConfigFallback,
17
21
  } from "../../lib/config.js";
18
22
  import { SETTINGS_KEYS } from "../../lib/constants.js";
19
23
  import { getAvailableThemes } from "../../lib/theme.js";
20
- import type { ColorTheme } from "../../theme/color-themes.js";
24
+ import { getMediaUrl, getPublicUrlForProvider } from "../../lib/image.js";
25
+ import { TIMEZONES } from "../../lib/timezones.js";
26
+ import { BUILTIN_FONT_THEMES } from "../../ui/font-themes.js";
27
+ import { GeneralContent } from "../../ui/dash/settings/GeneralContent.js";
28
+ import { AppearanceContent } from "../../ui/dash/settings/AppearanceContent.js";
29
+ import { AccountContent } from "../../ui/dash/settings/AccountContent.js";
21
30
 
22
31
  /** Escape HTML special characters for safe insertion into HTML strings */
23
32
  function escapeHtml(str: string): string {
@@ -32,487 +41,46 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
32
41
 
33
42
  export const settingsRoutes = new Hono<Env>();
34
43
 
35
- // ---------------------------------------------------------------------------
36
- // Shared sub-navigation
37
- // ---------------------------------------------------------------------------
38
-
39
- type SettingsTab = "general" | "appearance" | "account";
40
-
41
- function SettingsNav({ currentTab }: { currentTab: SettingsTab }) {
42
- const { t } = useLingui();
43
-
44
- const tabs: { id: SettingsTab; label: string; href: string }[] = [
45
- {
46
- id: "general",
47
- label: t({
48
- message: "General",
49
- comment: "@context: Settings sub-navigation tab",
50
- }),
51
- href: "/dash/settings",
52
- },
53
- {
54
- id: "appearance",
55
- label: t({
56
- message: "Appearance",
57
- comment: "@context: Settings sub-navigation tab",
58
- }),
59
- href: "/dash/settings/appearance",
60
- },
61
- {
62
- id: "account",
63
- label: t({
64
- message: "Account",
65
- comment: "@context: Settings sub-navigation tab",
66
- }),
67
- href: "/dash/settings/account",
68
- },
69
- ];
70
-
71
- return (
72
- <nav class="flex gap-1 mb-6">
73
- {tabs.map((tab) => (
74
- <a
75
- key={tab.id}
76
- href={tab.href}
77
- class={`px-3 py-2 text-sm rounded-md ${
78
- tab.id === currentTab
79
- ? "bg-accent text-accent-foreground font-medium"
80
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
81
- }`}
82
- >
83
- {tab.label}
84
- </a>
85
- ))}
86
- </nav>
87
- );
88
- }
89
-
90
- // ---------------------------------------------------------------------------
91
- // General tab
92
- // ---------------------------------------------------------------------------
93
-
94
- function GeneralContent({
95
- siteName,
96
- siteDescription,
97
- siteLanguage,
98
- siteNameFallback,
99
- siteDescriptionFallback,
100
- }: {
101
- siteName: string;
102
- siteDescription: string;
103
- siteLanguage: string;
104
- siteNameFallback: string;
105
- siteDescriptionFallback: string;
106
- }) {
107
- const { t } = useLingui();
108
-
109
- const generalSignals = JSON.stringify({
110
- siteName,
111
- siteDescription,
112
- siteLanguage,
113
- }).replace(/</g, "\\u003c");
114
-
115
- return (
116
- <>
117
- <h1 class="text-2xl font-semibold mb-2">
118
- {t({ message: "Settings", comment: "@context: Dashboard heading" })}
119
- </h1>
120
- <SettingsNav currentTab="general" />
121
-
122
- <div class="flex flex-col gap-6 max-w-lg">
123
- <form
124
- data-signals={generalSignals}
125
- data-on:submit__prevent="@post('/dash/settings')"
126
- data-indicator="_loading"
127
- >
128
- <div class="card">
129
- <header>
130
- <h2>
131
- {t({
132
- message: "General",
133
- comment: "@context: Settings section heading",
134
- })}
135
- </h2>
136
- </header>
137
- <section class="flex flex-col gap-4">
138
- <div class="field">
139
- <label class="label">
140
- {t({
141
- message: "Site Name",
142
- comment: "@context: Settings form field",
143
- })}
144
- </label>
145
- <input
146
- type="text"
147
- data-bind="siteName"
148
- class="input"
149
- placeholder={siteNameFallback}
150
- />
151
- </div>
152
-
153
- <div class="field">
154
- <label class="label">
155
- {t({
156
- message: "Site Description",
157
- comment: "@context: Settings form field",
158
- })}
159
- </label>
160
- <textarea
161
- data-bind="siteDescription"
162
- class="textarea"
163
- rows={3}
164
- placeholder={siteDescriptionFallback}
165
- >
166
- {siteDescription}
167
- </textarea>
168
- </div>
169
-
170
- <div class="field">
171
- <label class="label">
172
- {t({
173
- message: "Language",
174
- comment: "@context: Settings form field",
175
- })}
176
- </label>
177
- <select data-bind="siteLanguage" class="select">
178
- <option value="en" selected={siteLanguage === "en"}>
179
- English
180
- </option>
181
- <option value="zh-Hans" selected={siteLanguage === "zh-Hans"}>
182
- 简体中文
183
- </option>
184
- <option value="zh-Hant" selected={siteLanguage === "zh-Hant"}>
185
- 繁體中文
186
- </option>
187
- </select>
188
- </div>
189
- </section>
190
- </div>
191
-
192
- <button type="submit" class="btn mt-4" data-attr-disabled="$_loading">
193
- <span data-show="!$_loading">
194
- {t({
195
- message: "Save Settings",
196
- comment: "@context: Button to save settings",
197
- })}
198
- </span>
199
- <span data-show="$_loading">
200
- {t({
201
- message: "Processing...",
202
- comment:
203
- "@context: Loading text shown on submit button while request is in progress",
204
- })}
205
- </span>
206
- </button>
207
- </form>
208
- </div>
209
- </>
210
- );
211
- }
212
-
213
- // ---------------------------------------------------------------------------
214
- // Appearance tab
215
- // ---------------------------------------------------------------------------
216
-
217
- function ThemeCard({
218
- theme,
219
- selected,
220
- }: {
221
- theme: ColorTheme;
222
- selected: boolean;
223
- }) {
224
- const expr = `$theme === '${theme.id}'`;
225
- const { preview } = theme;
226
-
227
- return (
228
- <label
229
- class={`block cursor-pointer rounded-lg border overflow-hidden transition-colors ${selected ? "border-primary" : "border-border"}`}
230
- data-class:border-primary={expr}
231
- data-class:border-border={`$theme !== '${theme.id}'`}
232
- >
233
- <div class="grid grid-cols-2">
234
- <div
235
- class="p-5"
236
- style={`background-color:${preview.lightBg};color:${preview.lightText}`}
237
- >
238
- <input
239
- type="radio"
240
- name="theme"
241
- value={theme.id}
242
- data-bind="theme"
243
- checked={selected || undefined}
244
- class="mb-1"
245
- />
246
- <h3 class="font-bold text-lg">{theme.name}</h3>
247
- <p class="text-sm mt-2 leading-relaxed">
248
- This is the {theme.name} theme in light mode. Links{" "}
249
- <a
250
- tabIndex={-1}
251
- class="underline"
252
- style={`color:${preview.lightLink}`}
253
- >
254
- look like this
255
- </a>
256
- . We'll show the correct light or dark mode based on your visitor's
257
- settings.
258
- </p>
259
- </div>
260
- <div
261
- class="p-5"
262
- style={`background-color:${preview.darkBg};color:${preview.darkText}`}
263
- >
264
- <h3 class="font-bold text-lg">{theme.name}</h3>
265
- <p class="text-sm mt-2 leading-relaxed">
266
- This is the {theme.name} theme in dark mode. Links{" "}
267
- <a
268
- tabIndex={-1}
269
- class="underline"
270
- style={`color:${preview.darkLink}`}
271
- >
272
- look like this
273
- </a>
274
- . We'll show the correct light or dark mode based on your visitor's
275
- settings.
276
- </p>
277
- </div>
278
- </div>
279
- </label>
280
- );
281
- }
282
-
283
- function AppearanceContent({
284
- themes,
285
- currentThemeId,
286
- }: {
287
- themes: ColorTheme[];
288
- currentThemeId: string;
289
- }) {
290
- const { t } = useLingui();
291
-
292
- const signals = JSON.stringify({ theme: currentThemeId }).replace(
293
- /</g,
294
- "\\u003c",
295
- );
296
-
297
- return (
298
- <>
299
- <h1 class="text-2xl font-semibold mb-2">
300
- {t({ message: "Settings", comment: "@context: Dashboard heading" })}
301
- </h1>
302
- <SettingsNav currentTab="appearance" />
303
-
304
- <div
305
- data-signals={signals}
306
- data-on:change="@post('/dash/settings/appearance')"
307
- class="max-w-3xl"
308
- >
309
- <fieldset>
310
- <legend class="text-lg font-semibold">
311
- {t({
312
- message: "Color theme",
313
- comment: "@context: Appearance settings heading",
314
- })}
315
- </legend>
316
- <p class="text-sm text-muted-foreground mb-4">
317
- {t({
318
- message:
319
- "This will theme both your site and your dashboard. All color themes support dark mode.",
320
- comment: "@context: Appearance settings description",
321
- })}
322
- </p>
323
-
324
- <div class="flex flex-col gap-4">
325
- {themes.map((theme) => (
326
- <ThemeCard
327
- key={theme.id}
328
- theme={theme}
329
- selected={theme.id === currentThemeId}
330
- />
331
- ))}
332
- </div>
333
- </fieldset>
334
- </div>
335
- </>
336
- );
337
- }
44
+ // ===========================================================================
45
+ // General settings
46
+ // ===========================================================================
338
47
 
339
- // ---------------------------------------------------------------------------
340
- // Account tab
341
- // ---------------------------------------------------------------------------
342
-
343
- function AccountContent({ userName }: { userName: string }) {
344
- const { t } = useLingui();
345
-
346
- const profileSignals = JSON.stringify({ userName }).replace(/</g, "\\u003c");
347
-
348
- return (
349
- <>
350
- <h1 class="text-2xl font-semibold mb-2">
351
- {t({ message: "Settings", comment: "@context: Dashboard heading" })}
352
- </h1>
353
- <SettingsNav currentTab="account" />
354
-
355
- <div class="flex flex-col gap-6 max-w-lg">
356
- <form
357
- data-signals={profileSignals}
358
- data-on:submit__prevent="@post('/dash/settings/account')"
359
- data-indicator="_profileLoading"
360
- >
361
- <div class="card">
362
- <header>
363
- <h2>
364
- {t({
365
- message: "Profile",
366
- comment: "@context: Account settings section heading",
367
- })}
368
- </h2>
369
- </header>
370
- <section class="flex flex-col gap-4">
371
- <div class="field">
372
- <label class="label">
373
- {t({
374
- message: "Name",
375
- comment: "@context: Account settings form field",
376
- })}
377
- </label>
378
- <input
379
- type="text"
380
- data-bind="userName"
381
- class="input"
382
- required
383
- />
384
- </div>
385
- </section>
386
- </div>
387
-
388
- <button
389
- type="submit"
390
- class="btn mt-4"
391
- data-attr-disabled="$_profileLoading"
392
- >
393
- <span data-show="!$_profileLoading">
394
- {t({
395
- message: "Save Profile",
396
- comment: "@context: Button to save profile",
397
- })}
398
- </span>
399
- <span data-show="$_profileLoading">
400
- {t({
401
- message: "Processing...",
402
- comment:
403
- "@context: Loading text shown on submit button while request is in progress",
404
- })}
405
- </span>
406
- </button>
407
- </form>
408
-
409
- <form
410
- data-signals="{currentPassword: '', newPassword: '', confirmPassword: ''}"
411
- data-on:submit__prevent="@post('/dash/settings/password')"
412
- data-indicator="_passwordLoading"
413
- >
414
- <div class="card">
415
- <header>
416
- <h2>
417
- {t({
418
- message: "Change Password",
419
- comment: "@context: Settings section heading",
420
- })}
421
- </h2>
422
- </header>
423
- <section class="flex flex-col gap-4">
424
- <div class="field">
425
- <label class="label">
426
- {t({
427
- message: "Current Password",
428
- comment: "@context: Password form field",
429
- })}
430
- </label>
431
- <input
432
- type="password"
433
- data-bind="currentPassword"
434
- class="input"
435
- required
436
- autocomplete="current-password"
437
- />
438
- </div>
439
-
440
- <div class="field">
441
- <label class="label">
442
- {t({
443
- message: "New Password",
444
- comment: "@context: Password form field",
445
- })}
446
- </label>
447
- <input
448
- type="password"
449
- data-bind="newPassword"
450
- class="input"
451
- required
452
- minlength={8}
453
- autocomplete="new-password"
454
- />
455
- </div>
456
-
457
- <div class="field">
458
- <label class="label">
459
- {t({
460
- message: "Confirm New Password",
461
- comment: "@context: Password form field",
462
- })}
463
- </label>
464
- <input
465
- type="password"
466
- data-bind="confirmPassword"
467
- class="input"
468
- required
469
- minlength={8}
470
- autocomplete="new-password"
471
- />
472
- </div>
473
- </section>
474
- </div>
475
-
476
- <button
477
- type="submit"
478
- class="btn mt-4"
479
- data-attr-disabled="$_passwordLoading"
480
- >
481
- <span data-show="!$_passwordLoading">
482
- {t({
483
- message: "Change Password",
484
- comment: "@context: Button to change password",
485
- })}
486
- </span>
487
- <span data-show="$_passwordLoading">
488
- {t({
489
- message: "Processing...",
490
- comment:
491
- "@context: Loading text shown on submit button while request is in progress",
492
- })}
493
- </span>
494
- </button>
495
- </form>
496
- </div>
497
- </>
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,
498
59
  );
60
+ return getMediaUrl(avatarKey, publicUrl);
499
61
  }
500
62
 
501
- // ===========================================================================
502
- // Route handlers
503
- // ===========================================================================
504
-
505
- // General settings page
506
63
  settingsRoutes.get("/", async (c) => {
507
64
  const { settings } = c.var.services;
508
65
 
509
66
  const dbSiteName = await settings.get("SITE_NAME");
510
67
  const dbSiteDescription = await settings.get("SITE_DESCRIPTION");
511
- const siteLanguage = await getSiteLanguage(c);
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
+ ]);
512
76
 
513
77
  const siteNameFallback = getConfigFallback(c, "SITE_NAME");
514
78
  const siteDescriptionFallback = getConfigFallback(c, "SITE_DESCRIPTION");
515
79
 
80
+ const siteAvatarUrl = await resolveAvatarUrl(c);
81
+ const showHeaderAvatar =
82
+ (await settings.get("SHOW_HEADER_AVATAR")) === "true";
83
+
516
84
  const saved = c.req.query("saved") !== undefined;
517
85
 
518
86
  return c.html(
@@ -527,19 +95,27 @@ settingsRoutes.get("/", async (c) => {
527
95
  siteName={dbSiteName || ""}
528
96
  siteDescription={dbSiteDescription || ""}
529
97
  siteLanguage={siteLanguage}
98
+ homeDefaultView={homeDefaultView}
530
99
  siteNameFallback={siteNameFallback}
531
100
  siteDescriptionFallback={siteDescriptionFallback}
101
+ siteAvatarUrl={siteAvatarUrl}
102
+ showHeaderAvatar={showHeaderAvatar}
103
+ timeZone={timeZone}
104
+ siteFooter={siteFooter}
105
+ noindex={noindex}
106
+ timezones={TIMEZONES}
532
107
  />
533
108
  </DashLayout>,
534
109
  );
535
110
  });
536
111
 
537
- // Save general settings
538
112
  settingsRoutes.post("/", async (c) => {
539
113
  const body = await c.req.json<{
540
114
  siteName: string;
541
115
  siteDescription: string;
542
116
  siteLanguage: string;
117
+ homeDefaultView: string;
118
+ timeZone: string;
543
119
  }>();
544
120
 
545
121
  const { settings } = c.var.services;
@@ -560,6 +136,20 @@ settingsRoutes.post("/", async (c) => {
560
136
 
561
137
  await settings.set("SITE_LANGUAGE", body.siteLanguage);
562
138
 
139
+ // Save homepage default view (only store if non-default)
140
+ if (body.homeDefaultView === "featured") {
141
+ await settings.set("HOME_DEFAULT_VIEW", body.homeDefaultView);
142
+ } else {
143
+ await settings.remove("HOME_DEFAULT_VIEW");
144
+ }
145
+
146
+ // Timezone
147
+ if (body.timeZone && body.timeZone !== "UTC") {
148
+ await settings.set("TIME_ZONE", body.timeZone);
149
+ } else {
150
+ await settings.remove("TIME_ZONE");
151
+ }
152
+
563
153
  const languageChanged = oldLanguage !== body.siteLanguage;
564
154
  const displayName = body.siteName.trim() || getConfigFallback(c, "SITE_NAME");
565
155
 
@@ -576,15 +166,173 @@ settingsRoutes.post("/", async (c) => {
576
166
  selector: "title",
577
167
  });
578
168
  await stream.toast("Settings saved successfully.");
169
+ await stream.patchSignals({
170
+ _orig_siteName: body.siteName,
171
+ _orig_siteDescription: body.siteDescription,
172
+ _orig_siteLanguage: body.siteLanguage,
173
+ _orig_homeDefaultView: body.homeDefaultView,
174
+ _orig_timeZone: body.timeZone,
175
+ _generalDirty: false,
176
+ });
579
177
  }
580
178
  });
581
179
  });
582
180
 
583
- // Appearance page
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
+ settingsRoutes.post("/seo", async (c) => {
201
+ const body = await c.req.json<{ noindex: string }>();
202
+ const { settings } = c.var.services;
203
+
204
+ // Checkbox "noindex" is the allow-indexing signal:
205
+ // checked (value "true") = indexing allowed -> remove NOINDEX
206
+ // unchecked (value "") = indexing blocked -> set NOINDEX=true
207
+ if (body.noindex === "true") {
208
+ await settings.remove("NOINDEX");
209
+ } else {
210
+ await settings.set("NOINDEX", "true");
211
+ }
212
+
213
+ return sse(c, async (stream) => {
214
+ await stream.toast("SEO settings saved successfully.");
215
+ await stream.patchSignals({
216
+ _orig_noindex: body.noindex,
217
+ _seoDirty: false,
218
+ });
219
+ });
220
+ });
221
+
222
+ // ===========================================================================
223
+ // Avatar upload & removal
224
+ // ===========================================================================
225
+
226
+ settingsRoutes.post("/avatar", async (c) => {
227
+ const storage = c.var.storage;
228
+ if (!storage) {
229
+ return dsToast("Storage not configured.", "error");
230
+ }
231
+
232
+ const formData = await c.req.formData();
233
+ const file = formData.get("file") as File | null;
234
+ 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");
247
+ }
248
+
249
+ const maxSize = 10 * 1024 * 1024;
250
+ if (file.size > maxSize) {
251
+ return dsToast("File too large (max 10MB).", "error");
252
+ }
253
+
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}`;
262
+
263
+ try {
264
+ await storage.put(storageKey, file.stream(), {
265
+ contentType: file.type,
266
+ });
267
+
268
+ await c.var.services.media.create({
269
+ id,
270
+ filename,
271
+ originalName: file.name,
272
+ mimeType: file.type,
273
+ size: file.size,
274
+ storageKey,
275
+ provider: c.env.STORAGE_DRIVER || "r2",
276
+ });
277
+
278
+ await c.var.services.settings.set("SITE_AVATAR", storageKey);
279
+
280
+ // Store favicon variants as base64 in settings (small files, accessed every page load)
281
+ const faviconFile = formData.get("favicon") as File | null;
282
+ const appleTouchFile = formData.get("appleTouch") as File | null;
283
+
284
+ if (faviconFile) {
285
+ const b64 = arrayBufferToBase64(await faviconFile.arrayBuffer());
286
+ await c.var.services.settings.set("SITE_FAVICON_ICO", b64);
287
+ }
288
+
289
+ if (appleTouchFile) {
290
+ const b64 = arrayBufferToBase64(await appleTouchFile.arrayBuffer());
291
+ await c.var.services.settings.set("SITE_FAVICON_APPLE_TOUCH", b64);
292
+ }
293
+
294
+ return dsRedirect("/dash/settings?saved");
295
+ } catch {
296
+ return dsToast("Upload failed. Please try again.", "error");
297
+ }
298
+ });
299
+
300
+ settingsRoutes.post("/avatar/remove", async (c) => {
301
+ await c.var.services.settings.remove("SITE_AVATAR");
302
+ await c.var.services.settings.remove("SITE_FAVICON_ICO");
303
+ await c.var.services.settings.remove("SITE_FAVICON_APPLE_TOUCH");
304
+ return dsRedirect("/dash/settings?saved");
305
+ });
306
+
307
+ settingsRoutes.post("/avatar/display", async (c) => {
308
+ const body = await c.req.json<{ showHeaderAvatar: string }>();
309
+ const { settings } = c.var.services;
310
+
311
+ if (body.showHeaderAvatar === "true") {
312
+ await settings.set("SHOW_HEADER_AVATAR", "true");
313
+ } else {
314
+ await settings.remove("SHOW_HEADER_AVATAR");
315
+ }
316
+
317
+ return sse(c, async (stream) => {
318
+ await stream.toast("Avatar display setting saved successfully.");
319
+ await stream.patchSignals({
320
+ _orig_showHeaderAvatar: body.showHeaderAvatar,
321
+ _avatarDisplayDirty: false,
322
+ });
323
+ });
324
+ });
325
+
326
+ // ===========================================================================
327
+ // Appearance
328
+ // ===========================================================================
329
+
584
330
  settingsRoutes.get("/appearance", async (c) => {
585
331
  const { settings } = c.var.services;
586
332
  const siteName = await getSiteName(c);
587
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)) ?? "";
588
336
  const themes = getAvailableThemes(c.var.config);
589
337
  const saved = c.req.query("saved") !== undefined;
590
338
 
@@ -596,12 +344,17 @@ settingsRoutes.get("/appearance", async (c) => {
596
344
  currentPath="/dash/settings"
597
345
  toast={saved ? { message: "Theme saved successfully." } : undefined}
598
346
  >
599
- <AppearanceContent themes={themes} currentThemeId={currentThemeId} />
347
+ <AppearanceContent
348
+ themes={themes}
349
+ currentThemeId={currentThemeId}
350
+ fontThemes={BUILTIN_FONT_THEMES}
351
+ currentFontThemeId={currentFontThemeId}
352
+ customCSS={customCSS}
353
+ />
600
354
  </DashLayout>,
601
355
  );
602
356
  });
603
357
 
604
- // Save theme
605
358
  settingsRoutes.post("/appearance", async (c) => {
606
359
  const body = await c.req.json<{ theme: string }>();
607
360
  const { settings } = c.var.services;
@@ -621,7 +374,43 @@ settingsRoutes.post("/appearance", async (c) => {
621
374
  return dsRedirect("/dash/settings/appearance?saved");
622
375
  });
623
376
 
624
- // Account page
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
+ // ===========================================================================
411
+ // Account
412
+ // ===========================================================================
413
+
625
414
  settingsRoutes.get("/account", async (c) => {
626
415
  const siteName = await getSiteName(c);
627
416
  const session = await c.var.auth.api.getSession({
@@ -643,7 +432,6 @@ settingsRoutes.get("/account", async (c) => {
643
432
  );
644
433
  });
645
434
 
646
- // Save account profile
647
435
  settingsRoutes.post("/account", async (c) => {
648
436
  const body = await c.req.json<{ userName: string }>();
649
437
  const name = body.userName?.trim();
@@ -664,7 +452,6 @@ settingsRoutes.post("/account", async (c) => {
664
452
  return dsToast("Profile saved successfully.");
665
453
  });
666
454
 
667
- // Change password
668
455
  settingsRoutes.post("/password", async (c) => {
669
456
  const body = await c.req.json<{
670
457
  currentPassword: string;