@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
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Password Reset Routes
3
+ *
4
+ * One-time token-based password reset flow.
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import type { FC } from "hono/jsx";
9
+ import { useLingui } from "@lingui/react/macro";
10
+ import { hashPassword } from "better-auth/crypto";
11
+ import type { Bindings } from "../../types.js";
12
+ import type { AppVariables } from "../../app.js";
13
+ import { BaseLayout } from "../../ui/layouts/BaseLayout.js";
14
+ import { dsRedirect, dsToast } from "../../lib/sse.js";
15
+ import { SETTINGS_KEYS } from "../../lib/constants.js";
16
+ import { ResetPasswordSchema } from "../../lib/schemas.js";
17
+
18
+ type Env = { Bindings: Bindings; Variables: AppVariables };
19
+
20
+ const ResetContent: FC<{ token: string }> = ({ token }) => {
21
+ const { t } = useLingui();
22
+ const signals = JSON.stringify({
23
+ password: "",
24
+ confirmPassword: "",
25
+ token,
26
+ }).replace(/</g, "\\u003c");
27
+
28
+ return (
29
+ <div class="min-h-screen flex items-center justify-center">
30
+ <div class="card max-w-md w-full">
31
+ <header>
32
+ <h2>
33
+ {t({
34
+ message: "Reset Password",
35
+ comment: "@context: Password reset page heading",
36
+ })}
37
+ </h2>
38
+ <p>
39
+ {t({
40
+ message: "Enter your new password.",
41
+ comment: "@context: Password reset page description",
42
+ })}
43
+ </p>
44
+ </header>
45
+ <section>
46
+ <form
47
+ data-signals={signals}
48
+ data-on:submit__prevent="@post('/reset')"
49
+ data-indicator="_loading"
50
+ class="flex flex-col gap-4"
51
+ >
52
+ <div class="field">
53
+ <label class="label">
54
+ {t({
55
+ message: "New Password",
56
+ comment: "@context: Password reset form field",
57
+ })}
58
+ </label>
59
+ <input
60
+ type="password"
61
+ data-bind="password"
62
+ class="input"
63
+ required
64
+ minLength={8}
65
+ autocomplete="new-password"
66
+ />
67
+ </div>
68
+ <div class="field">
69
+ <label class="label">
70
+ {t({
71
+ message: "Confirm Password",
72
+ comment: "@context: Password reset form field",
73
+ })}
74
+ </label>
75
+ <input
76
+ type="password"
77
+ data-bind="confirmPassword"
78
+ class="input"
79
+ required
80
+ minLength={8}
81
+ autocomplete="new-password"
82
+ />
83
+ </div>
84
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
85
+ <svg
86
+ data-show="$_loading"
87
+ style="display:none"
88
+ class="animate-spin size-4"
89
+ xmlns="http://www.w3.org/2000/svg"
90
+ viewBox="0 0 24 24"
91
+ fill="none"
92
+ stroke="currentColor"
93
+ stroke-width="2"
94
+ stroke-linecap="round"
95
+ stroke-linejoin="round"
96
+ role="status"
97
+ >
98
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
99
+ </svg>
100
+ {t({
101
+ message: "Reset Password",
102
+ comment: "@context: Password reset form submit button",
103
+ })}
104
+ </button>
105
+ </form>
106
+ </section>
107
+ </div>
108
+ </div>
109
+ );
110
+ };
111
+
112
+ const ResetErrorContent: FC = () => {
113
+ const { t } = useLingui();
114
+
115
+ return (
116
+ <div class="min-h-screen flex items-center justify-center">
117
+ <div class="card max-w-md w-full">
118
+ <header>
119
+ <h2>
120
+ {t({
121
+ message: "Invalid or Expired Link",
122
+ comment: "@context: Password reset error heading",
123
+ })}
124
+ </h2>
125
+ </header>
126
+ <section>
127
+ <p class="text-muted-foreground">
128
+ {t({
129
+ message:
130
+ "This password reset link is invalid or has expired. Please generate a new one.",
131
+ comment: "@context: Password reset error description",
132
+ })}
133
+ </p>
134
+ </section>
135
+ </div>
136
+ </div>
137
+ );
138
+ };
139
+
140
+ /**
141
+ * Validate a password reset token against the stored value.
142
+ * Returns true if the token is valid and not expired.
143
+ */
144
+ async function validateResetToken(
145
+ settings: { get(key: string): Promise<string | null> },
146
+ token: string,
147
+ ): Promise<boolean> {
148
+ const stored = await settings.get(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
149
+ if (!stored) return false;
150
+
151
+ const separatorIndex = stored.lastIndexOf(":");
152
+ const storedToken = stored.substring(0, separatorIndex);
153
+ const expiry = parseInt(stored.substring(separatorIndex + 1), 10);
154
+ const now = Math.floor(Date.now() / 1000);
155
+
156
+ return token === storedToken && now <= expiry;
157
+ }
158
+
159
+ export const resetRoutes = new Hono<Env>();
160
+
161
+ resetRoutes.get("/reset", async (c) => {
162
+ const token = c.req.query("token");
163
+ if (!token) {
164
+ return c.html(
165
+ <BaseLayout title="Reset Password - Jant" c={c}>
166
+ <ResetErrorContent />
167
+ </BaseLayout>,
168
+ );
169
+ }
170
+
171
+ const isValid = await validateResetToken(c.var.services.settings, token);
172
+ if (!isValid) {
173
+ return c.html(
174
+ <BaseLayout title="Reset Password - Jant" c={c}>
175
+ <ResetErrorContent />
176
+ </BaseLayout>,
177
+ );
178
+ }
179
+
180
+ return c.html(
181
+ <BaseLayout title="Reset Password - Jant" c={c}>
182
+ <ResetContent token={token} />
183
+ </BaseLayout>,
184
+ );
185
+ });
186
+
187
+ resetRoutes.post("/reset", async (c) => {
188
+ const body = await c.req.json();
189
+ const parsed = ResetPasswordSchema.safeParse(body);
190
+
191
+ if (!parsed.success) {
192
+ const msg = parsed.error.errors[0]?.message ?? "Invalid input";
193
+ return dsToast(msg, "error");
194
+ }
195
+
196
+ const { password, token } = parsed.data;
197
+
198
+ // Validate token
199
+ const isValid = await validateResetToken(c.var.services.settings, token);
200
+ if (!isValid) {
201
+ return dsToast("Invalid or expired reset link.", "error");
202
+ }
203
+
204
+ try {
205
+ const hashedPassword = await hashPassword(password);
206
+ const db = c.env.DB.withSession() as unknown as D1Database;
207
+
208
+ // Get admin user
209
+ const userResult = await db
210
+ .prepare("SELECT id FROM user LIMIT 1")
211
+ .first<{ id: string }>();
212
+ if (!userResult) {
213
+ return dsToast("No user account found.", "error");
214
+ }
215
+
216
+ // Update password
217
+ await db
218
+ .prepare(
219
+ "UPDATE account SET password = ? WHERE user_id = ? AND provider_id = 'credential'",
220
+ )
221
+ .bind(hashedPassword, userResult.id)
222
+ .run();
223
+
224
+ // Delete all sessions
225
+ await db
226
+ .prepare("DELETE FROM session WHERE user_id = ?")
227
+ .bind(userResult.id)
228
+ .run();
229
+
230
+ // Delete the reset token
231
+ await c.var.services.settings.remove(SETTINGS_KEYS.PASSWORD_RESET_TOKEN);
232
+
233
+ return dsRedirect("/signin?reset");
234
+ } catch (err) {
235
+ // eslint-disable-next-line no-console -- Error logging is intentional
236
+ console.error("Password reset error:", err);
237
+ return dsToast("Failed to reset password.", "error");
238
+ }
239
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Setup Routes
3
+ *
4
+ * Initial admin account creation during first-time setup.
5
+ */
6
+
7
+ import { Hono } from "hono";
8
+ import type { FC } from "hono/jsx";
9
+ import { useLingui } from "@lingui/react/macro";
10
+ import type { Bindings } from "../../types.js";
11
+ import type { AppVariables } from "../../app.js";
12
+ import { BaseLayout } from "../../ui/layouts/BaseLayout.js";
13
+ import { dsRedirect, dsToast } from "../../lib/sse.js";
14
+ import { SetupSchema } from "../../lib/schemas.js";
15
+ import { mapIanaToTimezone } from "../../lib/timezones.js";
16
+
17
+ type Env = { Bindings: Bindings; Variables: AppVariables };
18
+
19
+ const SetupContent: FC = () => {
20
+ const { t } = useLingui();
21
+
22
+ return (
23
+ <div class="min-h-screen flex items-center justify-center">
24
+ <div class="card max-w-md w-full">
25
+ <header>
26
+ <h2>
27
+ {t({
28
+ message: "Welcome to Jant",
29
+ comment: "@context: Setup page welcome heading",
30
+ })}
31
+ </h2>
32
+ <p>
33
+ {t({
34
+ message: "Create your admin account.",
35
+ comment: "@context: Setup page description",
36
+ })}
37
+ </p>
38
+ </header>
39
+ <section>
40
+ <form
41
+ data-signals="{name: '', email: '', password: '', _timezone: ''}"
42
+ data-init="$_timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''"
43
+ data-on:submit__prevent="@post('/setup')"
44
+ data-indicator="_loading"
45
+ class="flex flex-col gap-4"
46
+ >
47
+ <div class="field">
48
+ <label class="label">
49
+ {t({
50
+ message: "Your Name",
51
+ comment: "@context: Setup form field - user name",
52
+ })}
53
+ </label>
54
+ <input
55
+ type="text"
56
+ data-bind="name"
57
+ class="input"
58
+ required
59
+ placeholder="John Doe"
60
+ />
61
+ </div>
62
+ <div class="field">
63
+ <label class="label">
64
+ {t({
65
+ message: "Email",
66
+ comment: "@context: Setup/signin form field - email",
67
+ })}
68
+ </label>
69
+ <input
70
+ type="email"
71
+ data-bind="email"
72
+ class="input"
73
+ required
74
+ placeholder="you@example.com"
75
+ />
76
+ </div>
77
+ <div class="field">
78
+ <label class="label">
79
+ {t({
80
+ message: "Password",
81
+ comment: "@context: Setup/signin form field - password",
82
+ })}
83
+ </label>
84
+ <input
85
+ type="password"
86
+ data-bind="password"
87
+ class="input"
88
+ required
89
+ minLength={8}
90
+ />
91
+ </div>
92
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
93
+ <svg
94
+ data-show="$_loading"
95
+ style="display:none"
96
+ class="animate-spin size-4"
97
+ xmlns="http://www.w3.org/2000/svg"
98
+ viewBox="0 0 24 24"
99
+ fill="none"
100
+ stroke="currentColor"
101
+ stroke-width="2"
102
+ stroke-linecap="round"
103
+ stroke-linejoin="round"
104
+ role="status"
105
+ >
106
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
107
+ </svg>
108
+ {t({
109
+ message: "Complete Setup",
110
+ comment: "@context: Setup form submit button",
111
+ })}
112
+ </button>
113
+ </form>
114
+ </section>
115
+ </div>
116
+ </div>
117
+ );
118
+ };
119
+
120
+ export const setupRoutes = new Hono<Env>();
121
+
122
+ setupRoutes.get("/setup", async (c) => {
123
+ const isComplete = await c.var.services.settings.isOnboardingComplete();
124
+ if (isComplete) return c.redirect("/");
125
+
126
+ return c.html(
127
+ <BaseLayout title="Setup - Jant" c={c}>
128
+ <SetupContent />
129
+ </BaseLayout>,
130
+ );
131
+ });
132
+
133
+ setupRoutes.post("/setup", async (c) => {
134
+ const isComplete = await c.var.services.settings.isOnboardingComplete();
135
+ if (isComplete) return c.redirect("/");
136
+
137
+ const body = await c.req.json<Record<string, string>>();
138
+ const parsed = SetupSchema.safeParse(body);
139
+ const browserTimezone = body._timezone;
140
+
141
+ if (!parsed.success) {
142
+ const msg = parsed.error.errors[0]?.message ?? "Invalid input";
143
+ return dsToast(msg, "error");
144
+ }
145
+
146
+ const { name, email, password } = parsed.data;
147
+
148
+ if (!c.var.auth) {
149
+ return dsToast("AUTH_SECRET not configured", "error");
150
+ }
151
+
152
+ try {
153
+ const signUpResponse = await c.var.auth.api.signUpEmail({
154
+ body: { name, email, password },
155
+ });
156
+
157
+ if (!signUpResponse || "error" in signUpResponse) {
158
+ return dsToast("Failed to create account", "error");
159
+ }
160
+
161
+ await c.var.services.settings.completeOnboarding();
162
+
163
+ // Save auto-detected timezone
164
+ if (browserTimezone) {
165
+ const tz = mapIanaToTimezone(browserTimezone);
166
+ if (tz !== "UTC") {
167
+ await c.var.services.settings.set("TIME_ZONE", tz);
168
+ }
169
+ }
170
+
171
+ // Seed default navigation items
172
+ await c.var.services.navItems.create({
173
+ type: "link",
174
+ label: "Featured",
175
+ url: "/featured",
176
+ });
177
+ await c.var.services.navItems.create({
178
+ type: "link",
179
+ label: "Collections",
180
+ url: "/collections",
181
+ });
182
+
183
+ return dsRedirect("/signin?setup");
184
+ } catch (err) {
185
+ // eslint-disable-next-line no-console -- Error logging is intentional
186
+ console.error("Setup error:", err);
187
+ return dsToast("Failed to create account", "error");
188
+ }
189
+ });
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Sign-in / Sign-out Routes
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { FC } from "hono/jsx";
7
+ import { useLingui } from "@lingui/react/macro";
8
+ import type { Bindings } from "../../types.js";
9
+ import type { AppVariables } from "../../app.js";
10
+ import { BaseLayout } from "../../ui/layouts/BaseLayout.js";
11
+ import { dsRedirect, dsToast } from "../../lib/sse.js";
12
+ import { SigninSchema } from "../../lib/schemas.js";
13
+
14
+ type Env = { Bindings: Bindings; Variables: AppVariables };
15
+
16
+ const SigninContent: FC<{
17
+ demoEmail?: string;
18
+ demoPassword?: string;
19
+ }> = ({ demoEmail, demoPassword }) => {
20
+ const { t } = useLingui();
21
+ const signals = JSON.stringify({
22
+ email: demoEmail || "",
23
+ password: demoPassword || "",
24
+ }).replace(/</g, "\\u003c");
25
+
26
+ return (
27
+ <div class="min-h-screen flex items-center justify-center">
28
+ <div class="card max-w-md w-full">
29
+ <header>
30
+ <h2>
31
+ {t({
32
+ message: "Sign In",
33
+ comment: "@context: Sign in page heading",
34
+ })}
35
+ </h2>
36
+ </header>
37
+ <section>
38
+ {demoEmail && demoPassword && (
39
+ <p class="text-muted-foreground text-sm mb-4">
40
+ {t({
41
+ message: "Demo account pre-filled. Just click Sign In.",
42
+ comment:
43
+ "@context: Hint shown on signin page when demo credentials are pre-filled",
44
+ })}
45
+ </p>
46
+ )}
47
+ <form
48
+ data-signals={signals}
49
+ data-on:submit__prevent="@post('/signin')"
50
+ data-indicator="_loading"
51
+ class="flex flex-col gap-4"
52
+ >
53
+ <div class="field">
54
+ <label class="label">
55
+ {t({
56
+ message: "Email",
57
+ comment: "@context: Setup/signin form field - email",
58
+ })}
59
+ </label>
60
+ <input type="email" data-bind="email" class="input" required />
61
+ </div>
62
+ <div class="field">
63
+ <label class="label">
64
+ {t({
65
+ message: "Password",
66
+ comment: "@context: Setup/signin form field - password",
67
+ })}
68
+ </label>
69
+ <input
70
+ type="password"
71
+ data-bind="password"
72
+ class="input"
73
+ required
74
+ />
75
+ </div>
76
+ <button type="submit" class="btn" data-attr:disabled="$_loading">
77
+ <svg
78
+ data-show="$_loading"
79
+ style="display:none"
80
+ class="animate-spin size-4"
81
+ xmlns="http://www.w3.org/2000/svg"
82
+ viewBox="0 0 24 24"
83
+ fill="none"
84
+ stroke="currentColor"
85
+ stroke-width="2"
86
+ stroke-linecap="round"
87
+ stroke-linejoin="round"
88
+ role="status"
89
+ >
90
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
91
+ </svg>
92
+ {t({
93
+ message: "Sign In",
94
+ comment: "@context: Sign in form submit button",
95
+ })}
96
+ </button>
97
+ </form>
98
+ </section>
99
+ </div>
100
+ </div>
101
+ );
102
+ };
103
+
104
+ export const signinRoutes = new Hono<Env>();
105
+
106
+ signinRoutes.get("/signin", async (c) => {
107
+ const isSetup = c.req.query("setup") !== undefined;
108
+ const isReset = c.req.query("reset") !== undefined;
109
+ let toast: { message: string } | undefined;
110
+ if (isSetup) {
111
+ toast = { message: "Account created successfully. Please sign in." };
112
+ } else if (isReset) {
113
+ toast = { message: "Password reset successfully. Please sign in." };
114
+ }
115
+
116
+ return c.html(
117
+ <BaseLayout title="Sign In - Jant" c={c} toast={toast}>
118
+ <SigninContent
119
+ demoEmail={c.env.DEMO_EMAIL}
120
+ demoPassword={c.env.DEMO_PASSWORD}
121
+ />
122
+ </BaseLayout>,
123
+ );
124
+ });
125
+
126
+ signinRoutes.post("/signin", async (c) => {
127
+ if (!c.var.auth) {
128
+ return dsToast("Auth not configured", "error");
129
+ }
130
+
131
+ const body = await c.req.json();
132
+ const parsed = SigninSchema.safeParse(body);
133
+
134
+ if (!parsed.success) {
135
+ const msg = parsed.error.errors[0]?.message ?? "Invalid input";
136
+ return dsToast(msg, "error");
137
+ }
138
+
139
+ const { email, password } = parsed.data;
140
+
141
+ try {
142
+ const { headers } = await c.var.auth.api.signInEmail({
143
+ returnHeaders: true,
144
+ body: { email, password },
145
+ headers: c.req.raw.headers,
146
+ });
147
+
148
+ return dsRedirect("/dash", { headers });
149
+ } catch {
150
+ return dsToast("Invalid email or password", "error");
151
+ }
152
+ });
153
+
154
+ signinRoutes.get("/signout", async (c) => {
155
+ if (c.var.auth) {
156
+ try {
157
+ await c.var.auth.api.signOut({ headers: c.req.raw.headers });
158
+ } catch {
159
+ // Ignore signout errors
160
+ }
161
+ }
162
+ return c.redirect("/");
163
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Compose Route
3
+ *
4
+ * Handles post creation from the public-site compose dialog.
5
+ * Returns dsRedirect to the new post's permalink (Datastar form pattern).
6
+ */
7
+
8
+ import { Hono } from "hono";
9
+ import type { Bindings } from "../types.js";
10
+ import type { AppVariables } from "../app.js";
11
+ import { requireAuth } from "../middleware/auth.js";
12
+ import { CreatePostSchema, validateMediaCount } from "../lib/schemas.js";
13
+ import * as sqid from "../lib/sqid.js";
14
+ import { dsRedirect, dsToast } from "../lib/sse.js";
15
+
16
+ type Env = { Bindings: Bindings; Variables: AppVariables };
17
+
18
+ export const composeRoutes = new Hono<Env>();
19
+
20
+ // All compose routes require authentication
21
+ composeRoutes.use("*", requireAuth());
22
+
23
+ composeRoutes.post("/", async (c) => {
24
+ const raw = await c.req.json();
25
+
26
+ const result = CreatePostSchema.safeParse(raw);
27
+ if (!result.success) {
28
+ const firstError = result.error.issues[0]?.message ?? "Invalid input";
29
+ return dsToast(firstError, "error");
30
+ }
31
+
32
+ const data = result.data;
33
+
34
+ // Validate media count
35
+ if (data.mediaIds) {
36
+ const mediaError = validateMediaCount(data.mediaIds);
37
+ if (mediaError) {
38
+ return dsToast(mediaError, "error");
39
+ }
40
+ }
41
+
42
+ const post = await c.var.services.posts.create({
43
+ format: data.format,
44
+ title: data.title || undefined,
45
+ body: data.body || undefined,
46
+ status: data.status ?? "published",
47
+ featured: data.featured,
48
+ pinned: data.pinned,
49
+ url: data.url || undefined,
50
+ quoteText: data.quoteText || undefined,
51
+ rating: data.rating || undefined,
52
+ collectionId: data.collectionId || undefined,
53
+ });
54
+
55
+ // Attach media if provided
56
+ if (data.mediaIds && data.mediaIds.length > 0) {
57
+ await c.var.services.media.attachToPost(post.id, data.mediaIds);
58
+ }
59
+
60
+ // Redirect to the new post's permalink
61
+ const permalink = post.path ? `/${post.path}` : `/p/${sqid.encode(post.id)}`;
62
+ return dsRedirect(permalink);
63
+ });