@jant/core 0.3.36 → 0.3.38

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 (271) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -60,7 +60,7 @@ function renderMediaCard(
60
60
  <button
61
61
  type="button"
62
62
  class="block w-full aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary cursor-pointer"
63
- onclick="document.getElementById('lightbox-img').src = '${fullUrl}'; document.getElementById('lightbox').showModal()"
63
+ data-on:click="document.getElementById('lightbox-img').src = '${fullUrl}'; document.getElementById('lightbox').showModal()"
64
64
  >
65
65
  <img
66
66
  src="${thumbnailUrl}"
@@ -69,13 +69,9 @@ function renderMediaCard(
69
69
  loading="lazy"
70
70
  />
71
71
  </button>
72
- <a
73
- href="/dash/media/${media.id}"
74
- class="block mt-2 text-xs truncate hover:underline"
75
- title="${media.originalName}"
76
- >
72
+ <span class="block mt-2 text-xs truncate" title="${media.originalName}">
77
73
  ${media.originalName}
78
- </a>
74
+ </span>
79
75
  <div class="text-xs text-muted-foreground">${sizeStr}</div>
80
76
  </div>
81
77
  `.toString();
@@ -83,23 +79,18 @@ function renderMediaCard(
83
79
 
84
80
  return html`
85
81
  <div class="group relative" data-media-id="${media.id}">
86
- <a
87
- href="/dash/media/${media.id}"
88
- class="block aspect-square bg-muted rounded-lg overflow-hidden border hover:border-primary"
82
+ <div
83
+ class="block aspect-square bg-muted rounded-lg overflow-hidden border"
89
84
  >
90
85
  <div
91
86
  class="w-full h-full flex items-center justify-center text-muted-foreground"
92
87
  >
93
88
  <span class="text-xs">${media.mimeType}</span>
94
89
  </div>
95
- </a>
96
- <a
97
- href="/dash/media/${media.id}"
98
- class="block mt-2 text-xs truncate hover:underline"
99
- title="${media.originalName}"
100
- >
90
+ </div>
91
+ <span class="block mt-2 text-xs truncate" title="${media.originalName}">
101
92
  ${media.originalName}
102
- </a>
93
+ </span>
103
94
  <div class="text-xs text-muted-foreground">${sizeStr}</div>
104
95
  </div>
105
96
  `.toString();
@@ -179,11 +170,77 @@ uploadApiRoutes.post("/", async (c) => {
179
170
  const { id, filename, storageKey } = generateStorageKey(file.name);
180
171
 
181
172
  try {
182
- // Upload to storage
183
- await storage.put(storageKey, file.stream(), {
173
+ // Read optional summary (provided for text attachments)
174
+ let summary = (formData.get("summary") as string) || undefined;
175
+ let chars: number | undefined;
176
+ // Buffer for text files — file.stream() may not work after file.text()
177
+ let textBuffer: Uint8Array | undefined;
178
+
179
+ // Extract summary and char count BEFORE consuming the stream for storage,
180
+ // because file.text() may not work after file.stream() is consumed.
181
+ if (
182
+ file.type === "text/plain" ||
183
+ file.type === "text/markdown" ||
184
+ file.type === "text/csv"
185
+ ) {
186
+ try {
187
+ const textContent = await file.text();
188
+ textBuffer = new TextEncoder().encode(textContent);
189
+ chars = textContent.length;
190
+ if (!summary) {
191
+ summary = textContent.slice(0, 100).trim() || undefined;
192
+ }
193
+ } catch {
194
+ // Ignore — summary and chars are optional
195
+ }
196
+ } else if (file.type === "text/x-tiptap+json") {
197
+ try {
198
+ const raw = await file.text();
199
+ textBuffer = new TextEncoder().encode(raw);
200
+ const envelope = JSON.parse(raw) as {
201
+ json?: { content?: unknown[] };
202
+ html?: string;
203
+ };
204
+ // Walk the TipTap JSON tree to extract plain text
205
+ if (envelope.json) {
206
+ let text = "";
207
+ const walk = (node: Record<string, unknown>) => {
208
+ if (typeof node.text === "string") text += node.text;
209
+ if (Array.isArray(node.content))
210
+ (node.content as Record<string, unknown>[]).forEach(walk);
211
+ };
212
+ walk(envelope.json as Record<string, unknown>);
213
+ chars = text.length;
214
+ }
215
+ } catch {
216
+ // Ignore — chars is optional
217
+ }
218
+ }
219
+
220
+ // Upload to storage — use buffered bytes for text files (stream may be consumed)
221
+ await storage.put(storageKey, textBuffer ?? file.stream(), {
184
222
  contentType: file.type,
185
223
  });
186
224
 
225
+ // Read optional client-side metadata
226
+ const widthRaw = parseInt(formData.get("width") as string) || undefined;
227
+ const heightRaw = parseInt(formData.get("height") as string) || undefined;
228
+ const blurhashRaw = (formData.get("blurhash") as string) || undefined;
229
+ const waveformRaw = (formData.get("waveform") as string) || undefined;
230
+
231
+ // Upload poster frame for videos (if provided by client)
232
+ let posterKey: string | undefined;
233
+ const posterFile = formData.get("poster") as File | null;
234
+ if (posterFile && file.type.startsWith("video/")) {
235
+ const date = new Date();
236
+ const year = date.getUTCFullYear();
237
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
238
+ posterKey = `media/${year}/${month}/${id}-poster.webp`;
239
+ await storage.put(posterKey, posterFile.stream(), {
240
+ contentType: "image/webp",
241
+ });
242
+ }
243
+
187
244
  // Save to database
188
245
  const media = await c.var.services.media.create({
189
246
  id,
@@ -193,6 +250,15 @@ uploadApiRoutes.post("/", async (c) => {
193
250
  size: file.size,
194
251
  storageKey,
195
252
  provider: c.var.appConfig.storageDriver,
253
+ width: widthRaw && widthRaw > 0 ? widthRaw : undefined,
254
+ height: heightRaw && heightRaw > 0 ? heightRaw : undefined,
255
+ blurhash:
256
+ blurhashRaw && blurhashRaw.length < 200 ? blurhashRaw : undefined,
257
+ waveform:
258
+ waveformRaw && waveformRaw.length < 2000 ? waveformRaw : undefined,
259
+ posterKey,
260
+ summary,
261
+ chars,
196
262
  });
197
263
 
198
264
  // SSE response for Datastar
@@ -1,20 +1,16 @@
1
1
  import { describe, it, expect, beforeEach } from "vitest";
2
2
  import { createTestDatabase } from "../../../__tests__/helpers/db.js";
3
- import { createPageService } from "../../../services/page.js";
4
3
  import { createSettingsService } from "../../../services/settings.js";
5
4
  import { createNavItemService } from "../../../services/navigation.js";
6
- import { createPathRegistryService } from "../../../services/path-registry.js";
7
5
  import type { Database } from "../../../db/index.js";
8
- import type { PageService } from "../../../services/page.js";
9
6
  import type { SettingsService } from "../../../services/settings.js";
10
7
  import type { NavItemService } from "../../../services/navigation.js";
11
8
 
12
9
  /**
13
- * Reproduces the seed logic from POST /setup to verify the default About page
14
- * and navigation items are created correctly.
10
+ * Reproduces the seed logic from POST /setup to verify the default
11
+ * navigation items are created correctly.
15
12
  */
16
13
  async function runSetupSeed(services: {
17
- pages: PageService;
18
14
  settings: SettingsService;
19
15
  navItems: NavItemService;
20
16
  }) {
@@ -30,31 +26,20 @@ async function runSetupSeed(services: {
30
26
  label: "Archive",
31
27
  url: "/archive",
32
28
  });
33
-
34
- const aboutPage = await services.pages.create({
35
- slug: "about",
36
- title: "About",
37
- body: [
38
- "Welcome to my corner of the internet.",
39
- "",
40
- "This is a place where I share my thoughts, ideas, and things I find interesting. Feel free to look around and get to know what this site is all about.",
41
- "",
42
- "If you'd like to get in touch, don't hesitate to reach out.",
43
- ].join("\n"),
44
- status: "published",
29
+ await services.navItems.create({
30
+ type: "system",
31
+ label: "RSS",
32
+ url: "/feed",
45
33
  });
46
-
47
34
  await services.navItems.create({
48
- type: "page",
49
- label: "About",
50
- url: "/about",
51
- pageId: aboutPage.id,
35
+ type: "system",
36
+ label: "Settings",
37
+ url: "/settings",
52
38
  });
53
39
  }
54
40
 
55
41
  describe("Setup seed logic", () => {
56
42
  let services: {
57
- pages: PageService;
58
43
  settings: SettingsService;
59
44
  navItems: NavItemService;
60
45
  };
@@ -63,57 +48,32 @@ describe("Setup seed logic", () => {
63
48
  const testDb = createTestDatabase();
64
49
  const db = testDb.db as unknown as Database;
65
50
  services = {
66
- pages: createPageService(db, createPathRegistryService(db)),
67
51
  settings: createSettingsService(db),
68
52
  navItems: createNavItemService(db),
69
53
  };
70
54
  });
71
55
 
72
- it("creates a default About page with correct content", async () => {
73
- await runSetupSeed(services);
74
-
75
- const aboutPage = await services.pages.getBySlug("about");
76
- expect(aboutPage).not.toBeNull();
77
- expect(aboutPage?.title).toBe("About");
78
- expect(aboutPage?.status).toBe("published");
79
- expect(aboutPage?.body).toContain("Welcome to my corner of the internet");
80
- expect(aboutPage?.bodyHtml).toBeTruthy();
81
- });
82
-
83
- it("adds About page to navigation as a page-type nav item", async () => {
56
+ it("creates four nav items: Collections, Archive, RSS, Settings", async () => {
84
57
  await runSetupSeed(services);
85
58
 
86
- const aboutPage = await services.pages.getBySlug("about");
87
59
  const navItemsList = await services.navItems.list();
88
-
89
- const aboutNavItem = navItemsList.find(
90
- (item) => item.pageId === aboutPage?.id,
91
- );
92
- expect(aboutNavItem).toBeDefined();
93
- expect(aboutNavItem?.type).toBe("page");
94
- expect(aboutNavItem?.label).toBe("About");
95
- expect(aboutNavItem?.url).toBe("/about");
96
- });
97
-
98
- it("creates three nav items total: Collections, Archive, About", async () => {
99
- await runSetupSeed(services);
100
-
101
- const navItemsList = await services.navItems.list();
102
- expect(navItemsList).toHaveLength(3);
60
+ expect(navItemsList).toHaveLength(4);
103
61
 
104
62
  const labels = navItemsList.map((item) => item.label);
105
63
  expect(labels).toContain("Collections");
106
64
  expect(labels).toContain("Archive");
107
- expect(labels).toContain("About");
65
+ expect(labels).toContain("RSS");
66
+ expect(labels).toContain("Settings");
108
67
  });
109
68
 
110
- it("renders About page body as HTML", async () => {
69
+ it("creates link and system type nav items", async () => {
111
70
  await runSetupSeed(services);
112
71
 
113
- const aboutPage = await services.pages.getBySlug("about");
114
- expect(aboutPage?.bodyHtml).toContain("<p>");
115
- expect(aboutPage?.bodyHtml).toContain(
116
- "Welcome to my corner of the internet",
117
- );
72
+ const navItemsList = await services.navItems.list();
73
+ const linkItems = navItemsList.filter((item) => item.type === "link");
74
+ const systemItems = navItemsList.filter((item) => item.type === "system");
75
+
76
+ expect(linkItems).toHaveLength(2);
77
+ expect(systemItems).toHaveLength(2);
118
78
  });
119
79
  });
@@ -14,7 +14,8 @@ import { BaseLayout } from "../../ui/layouts/BaseLayout.js";
14
14
  import { dsRedirect, dsToast } from "../../lib/sse.js";
15
15
  import { SetupSchema } from "../../lib/schemas.js";
16
16
  import { mapIanaToTimezone } from "../../lib/timezones.js";
17
- import { getI18n } from "../../i18n/index.js";
17
+ import { getI18n, baseLocale } from "../../i18n/index.js";
18
+ import { detectLocaleFromHeader } from "../../i18n/detect.js";
18
19
 
19
20
  type Env = { Bindings: Bindings; Variables: AppVariables };
20
21
 
@@ -40,8 +41,8 @@ const SetupContent: FC = () => {
40
41
  </header>
41
42
  <section>
42
43
  <form
43
- data-signals="{name: '', email: '', password: '', _timezone: ''}"
44
- data-init="$_timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''"
44
+ data-signals="{siteName: '', email: '', password: '', timezone: '', language: ''}"
45
+ data-init="$timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; $language = navigator.language || ''"
45
46
  data-on:submit__prevent="@post('/setup')"
46
47
  data-indicator="_loading"
47
48
  class="flex flex-col gap-4"
@@ -49,16 +50,16 @@ const SetupContent: FC = () => {
49
50
  <div class="field">
50
51
  <label class="label">
51
52
  {t({
52
- message: "Your Name",
53
- comment: "@context: Setup form field - user name",
53
+ message: "Site Name",
54
+ comment: "@context: Setup form field - site name",
54
55
  })}
55
56
  </label>
56
57
  <input
57
58
  type="text"
58
- data-bind="name"
59
+ data-bind="siteName"
59
60
  class="input"
60
61
  required
61
- placeholder="John Doe"
62
+ placeholder="My Blog"
62
63
  />
63
64
  </div>
64
65
  <div class="field">
@@ -139,7 +140,8 @@ setupRoutes.post("/setup", async (c) => {
139
140
 
140
141
  const body = await c.req.json<Record<string, string>>();
141
142
  const parsed = SetupSchema.safeParse(body);
142
- const browserTimezone = body._timezone;
143
+ const browserTimezone = body.timezone;
144
+ const browserLanguage = body.language;
143
145
 
144
146
  if (!parsed.success) {
145
147
  const errorMsg =
@@ -154,7 +156,7 @@ setupRoutes.post("/setup", async (c) => {
154
156
  return dsToast(errorMsg, "error");
155
157
  }
156
158
 
157
- const { name, email, password } = parsed.data;
159
+ const { siteName, email, password } = parsed.data;
158
160
 
159
161
  if (!c.var.auth) {
160
162
  return dsToast(
@@ -171,7 +173,7 @@ setupRoutes.post("/setup", async (c) => {
171
173
 
172
174
  try {
173
175
  const signUpResponse = await c.var.auth.api.signUpEmail({
174
- body: { name, email, password },
176
+ body: { name: siteName.trim(), email, password },
175
177
  });
176
178
 
177
179
  if (!signUpResponse || "error" in signUpResponse) {
@@ -189,6 +191,9 @@ setupRoutes.post("/setup", async (c) => {
189
191
 
190
192
  await c.var.services.settings.completeOnboarding();
191
193
 
194
+ // Save site name
195
+ await c.var.services.settings.set("SITE_NAME", siteName.trim());
196
+
192
197
  // Save auto-detected timezone
193
198
  if (browserTimezone) {
194
199
  const tz = mapIanaToTimezone(browserTimezone);
@@ -197,33 +202,21 @@ setupRoutes.post("/setup", async (c) => {
197
202
  }
198
203
  }
199
204
 
200
- // Seed default navigation items (order: Collections, About, Archive, RSS, Dashboard)
205
+ // Save auto-detected language from browser's navigator.language
206
+ if (browserLanguage) {
207
+ const detectedLang = detectLocaleFromHeader(browserLanguage);
208
+ if (detectedLang !== baseLocale) {
209
+ await c.var.services.settings.set("SITE_LANGUAGE", detectedLang);
210
+ }
211
+ }
212
+
213
+ // Seed default navigation items (order: Collections, Archive, RSS, Settings)
201
214
  await c.var.services.navItems.create({
202
215
  type: "link",
203
216
  label: "Collections",
204
217
  url: "/c",
205
218
  });
206
219
 
207
- const aboutPage = await c.var.services.pages.create({
208
- slug: "about",
209
- title: "About",
210
- body: [
211
- "Welcome to my corner of the internet.",
212
- "",
213
- "This is a place where I share my thoughts, ideas, and things I find interesting. Feel free to look around and get to know what this site is all about.",
214
- "",
215
- "If you'd like to get in touch, don't hesitate to reach out.",
216
- ].join("\n"),
217
- status: "published",
218
- });
219
-
220
- await c.var.services.navItems.create({
221
- type: "page",
222
- label: "About",
223
- url: "/about",
224
- pageId: aboutPage.id,
225
- });
226
-
227
220
  await c.var.services.navItems.create({
228
221
  type: "link",
229
222
  label: "Archive",
@@ -238,8 +231,8 @@ setupRoutes.post("/setup", async (c) => {
238
231
 
239
232
  await c.var.services.navItems.create({
240
233
  type: "system",
241
- label: "Dashboard",
242
- url: "/dash",
234
+ label: "Settings",
235
+ url: "/settings",
243
236
  });
244
237
 
245
238
  return dsRedirect("/signin?setup");
@@ -182,21 +182,17 @@ signinRoutes.post("/signin", async (c) => {
182
182
  }
183
183
  });
184
184
 
185
- signinRoutes.get("/signout", async (c) => {
185
+ signinRoutes.post("/signout", async (c) => {
186
186
  if (c.var.auth) {
187
187
  try {
188
188
  const res = await c.var.auth.api.signOut({
189
189
  headers: c.req.raw.headers,
190
190
  asResponse: true,
191
191
  });
192
- const redirect = c.redirect("/");
193
- for (const cookie of res.headers.getSetCookie()) {
194
- redirect.headers.append("Set-Cookie", cookie);
195
- }
196
- return redirect;
192
+ return dsRedirect("/", { headers: res.headers });
197
193
  } catch {
198
194
  // Ignore signout errors
199
195
  }
200
196
  }
201
- return c.redirect("/");
197
+ return dsRedirect("/");
202
198
  });
@@ -2,26 +2,19 @@
2
2
  * Compose Route
3
3
  *
4
4
  * Handles post creation from the public-site compose dialog.
5
- * Published posts are prepended to the homepage timeline via SSE.
5
+ * On publish the client reloads the page to pick up the new post.
6
6
  * Drafts close the dialog and show a confirmation toast.
7
7
  */
8
8
 
9
- import { Hono, type Context } from "hono";
9
+ import { Hono } from "hono";
10
10
  import { msg } from "@lingui/core/macro";
11
- import type { Bindings, Post } from "../types.js";
11
+ import type { Bindings } from "../types.js";
12
12
  import type { AppVariables } from "../types/app-context.js";
13
13
  import { requireAuth } from "../middleware/auth.js";
14
14
  import { CreatePostSchema } from "../lib/schemas.js";
15
15
  import { ValidationError } from "../lib/errors.js";
16
16
  import { sse, dsToast } from "../lib/sse.js";
17
17
  import { getI18n } from "../i18n/index.js";
18
- import {
19
- toPostView,
20
- toPostViewFromPost,
21
- createMediaContext,
22
- } from "../lib/view.js";
23
- import { buildMediaMap } from "../lib/media-helpers.js";
24
- import { TimelineItemFromPost } from "../ui/feed/TimelineItem.js";
25
18
 
26
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
27
20
 
@@ -48,40 +41,7 @@ const INITIAL_SIGNALS = {
48
41
 
49
42
  /** Script fragment that closes the compose dialog and self-removes */
50
43
  const CLOSE_DIALOG_SCRIPT =
51
- "<script data-effect=\"el.remove()\">document.getElementById('compose-dialog').close()</script>";
52
-
53
- /** Build a timeline card HTML string for a newly created post */
54
- async function buildTimelineCard(
55
- c: Context<Env>,
56
- post: Post,
57
- mediaIds: string[] | undefined,
58
- ): Promise<string> {
59
- const mediaCtx = createMediaContext(c.var.appConfig);
60
- let postView;
61
-
62
- if (mediaIds && mediaIds.length > 0) {
63
- const rawMediaMap = await c.var.services.media.getByPostIds([post.id]);
64
- const mediaMap = buildMediaMap(
65
- rawMediaMap,
66
- mediaCtx.r2PublicUrl,
67
- mediaCtx.imageTransformUrl,
68
- mediaCtx.s3PublicUrl,
69
- );
70
- postView = toPostView(
71
- { ...post, mediaAttachments: mediaMap.get(post.id) ?? [] },
72
- mediaCtx,
73
- );
74
- } else {
75
- postView = toPostViewFromPost(post, mediaCtx);
76
- }
77
-
78
- return (
79
- <div>
80
- <TimelineItemFromPost post={postView} />
81
- <hr class="feed-divider" />
82
- </div>
83
- ).toString();
84
- }
44
+ "<div data-init=\"document.getElementById('compose-dialog').close(); el.remove()\"></div>";
85
45
 
86
46
  composeRoutes.post("/", async (c) => {
87
47
  const i18n = getI18n(c);
@@ -127,13 +87,15 @@ composeRoutes.post("/", async (c) => {
127
87
  format: data.format,
128
88
  title: data.title || undefined,
129
89
  body: data.body || undefined,
90
+ bodyMarkdown: data.bodyMarkdown || undefined,
130
91
  status: data.status ?? "published",
92
+ visibility: data.visibility || undefined,
93
+ featured: data.featured,
131
94
  url: data.url || undefined,
132
95
  quoteText: data.quoteText || undefined,
133
96
  rating: data.rating || undefined,
134
- collectionIds: data.collectionIds?.length
135
- ? data.collectionIds
136
- : undefined,
97
+ collectionIds: data.collectionIds,
98
+ replyToId: data.replyToId,
137
99
  },
138
100
  {
139
101
  maxParagraphs: c.var.appConfig.summaryMaxParagraphs,
@@ -172,8 +134,7 @@ composeRoutes.post("/", async (c) => {
172
134
  });
173
135
  }
174
136
 
175
- const cardHtml = await buildTimelineCard(c, post, data.mediaIds);
176
- return c.json({ status: "published" as const, cardHtml });
137
+ return c.json({ status: "published" as const, permalink: `/${post.slug}` });
177
138
  }
178
139
 
179
140
  // ── SSE response mode (used by Datastar) ─────────────────────────
@@ -195,13 +156,7 @@ composeRoutes.post("/", async (c) => {
195
156
  });
196
157
  }
197
158
 
198
- const cardHtml = await buildTimelineCard(c, post, data.mediaIds);
199
-
200
159
  return sse(c, async (stream) => {
201
- await stream.patchElements(cardHtml, {
202
- mode: "prepend",
203
- selector: "#timeline-items",
204
- });
205
160
  await stream.patchElements(CLOSE_DIALOG_SCRIPT, {
206
161
  mode: "append",
207
162
  selector: "body",
@@ -38,7 +38,7 @@ function createMockFile(
38
38
  };
39
39
  }
40
40
 
41
- describe("Dashboard Settings - Avatar Upload Logic", () => {
41
+ describe("Settings - Avatar Upload Logic", () => {
42
42
  let db: Database;
43
43
  let settingsService: ReturnType<typeof createSettingsService>;
44
44
  let mediaService: ReturnType<typeof createMediaService>;