@jant/core 0.3.23 → 0.3.25

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 (248) hide show
  1. package/dist/app.js +50 -26
  2. package/dist/db/schema.js +72 -47
  3. package/dist/i18n/locales/en.js +1 -1
  4. package/dist/i18n/locales/zh-Hans.js +1 -1
  5. package/dist/i18n/locales/zh-Hant.js +1 -1
  6. package/dist/index.js +5 -11
  7. package/dist/lib/constants.js +2 -4
  8. package/dist/lib/excerpt.js +76 -0
  9. package/dist/lib/feed.js +18 -7
  10. package/dist/lib/nav-reorder.js +1 -1
  11. package/dist/lib/navigation.js +30 -6
  12. package/dist/lib/pagination.js +44 -0
  13. package/dist/lib/render.js +7 -11
  14. package/dist/lib/schemas.js +80 -38
  15. package/dist/lib/theme.js +4 -4
  16. package/dist/lib/time.js +56 -1
  17. package/dist/lib/timeline.js +95 -0
  18. package/dist/lib/view.js +61 -72
  19. package/dist/routes/api/collections.js +124 -0
  20. package/dist/routes/api/nav-items.js +104 -0
  21. package/dist/routes/api/pages.js +91 -0
  22. package/dist/routes/api/posts.js +27 -33
  23. package/dist/routes/api/search.js +4 -5
  24. package/dist/routes/api/settings.js +68 -0
  25. package/dist/routes/api/upload.js +13 -13
  26. package/dist/routes/compose.js +48 -0
  27. package/dist/routes/dash/collections.js +24 -42
  28. package/dist/routes/dash/index.js +3 -3
  29. package/dist/routes/dash/media.js +2 -2
  30. package/dist/routes/dash/pages.js +440 -106
  31. package/dist/routes/dash/posts.js +27 -37
  32. package/dist/routes/dash/redirects.js +2 -2
  33. package/dist/routes/dash/settings.js +79 -5
  34. package/dist/routes/feed/rss.js +4 -6
  35. package/dist/routes/feed/sitemap.js +11 -8
  36. package/dist/routes/pages/archive.js +13 -15
  37. package/dist/routes/pages/collection.js +12 -9
  38. package/dist/routes/pages/collections.js +28 -0
  39. package/dist/routes/pages/featured.js +32 -0
  40. package/dist/routes/pages/home.js +19 -68
  41. package/dist/routes/pages/page.js +57 -29
  42. package/dist/routes/pages/post.js +7 -17
  43. package/dist/routes/pages/search.js +5 -9
  44. package/dist/services/collection.js +52 -64
  45. package/dist/services/index.js +5 -3
  46. package/dist/services/navigation.js +29 -53
  47. package/dist/services/page.js +84 -0
  48. package/dist/services/post.js +102 -69
  49. package/dist/services/search.js +24 -18
  50. package/dist/types.js +24 -40
  51. package/dist/ui/compose/ComposeDialog.js +452 -0
  52. package/dist/ui/compose/ComposePrompt.js +55 -0
  53. package/dist/{theme/components/TypeBadge.js → ui/dash/FormatBadge.js} +3 -15
  54. package/dist/{theme/components → ui/dash}/PageForm.js +15 -15
  55. package/dist/{theme/components → ui/dash}/PostForm.js +117 -137
  56. package/dist/{theme/components → ui/dash}/PostList.js +18 -13
  57. package/dist/ui/dash/StatusBadge.js +46 -0
  58. package/dist/{theme/components → ui/dash}/index.js +3 -6
  59. package/dist/ui/feed/LinkCard.js +72 -0
  60. package/dist/ui/feed/NoteCard.js +58 -0
  61. package/dist/{themes/minimal/timeline → ui/feed}/QuoteCard.js +29 -14
  62. package/dist/{themes/minimal/timeline → ui/feed}/ThreadPreview.js +20 -18
  63. package/dist/ui/feed/TimelineFeed.js +41 -0
  64. package/dist/ui/feed/TimelineItem.js +27 -0
  65. package/dist/{theme → ui}/layouts/BaseLayout.js +10 -0
  66. package/dist/{theme → ui}/layouts/DashLayout.js +0 -8
  67. package/dist/ui/layouts/SiteLayout.js +141 -0
  68. package/dist/{themes/minimal → ui}/pages/ArchivePage.js +37 -50
  69. package/dist/ui/pages/CollectionPage.js +70 -0
  70. package/dist/ui/pages/CollectionsPage.js +76 -0
  71. package/dist/ui/pages/FeaturedPage.js +24 -0
  72. package/dist/ui/pages/HomePage.js +24 -0
  73. package/dist/{themes/minimal → ui}/pages/PostPage.js +20 -12
  74. package/dist/{themes/minimal → ui}/pages/SearchPage.js +19 -18
  75. package/dist/{themes/minimal → ui}/pages/SinglePage.js +5 -4
  76. package/dist/ui/shared/MediaGallery.js +35 -0
  77. package/dist/{theme/components → ui/shared}/Pagination.js +41 -2
  78. package/dist/{theme/components → ui/shared}/ThreadView.js +3 -3
  79. package/dist/ui/shared/index.js +5 -0
  80. package/package.json +2 -9
  81. package/src/__tests__/helpers/app.ts +4 -0
  82. package/src/__tests__/helpers/db.ts +53 -73
  83. package/src/app.tsx +56 -28
  84. package/src/db/migrations/0005_v2_schema_migration.sql +268 -0
  85. package/src/db/migrations/0006_rename_slug_to_path.sql +5 -0
  86. package/src/db/migrations/meta/_journal.json +14 -0
  87. package/src/db/schema.ts +63 -46
  88. package/src/i18n/locales/en.po +443 -240
  89. package/src/i18n/locales/en.ts +1 -1
  90. package/src/i18n/locales/zh-Hans.po +443 -240
  91. package/src/i18n/locales/zh-Hans.ts +1 -1
  92. package/src/i18n/locales/zh-Hant.po +443 -240
  93. package/src/i18n/locales/zh-Hant.ts +1 -1
  94. package/src/index.ts +29 -42
  95. package/src/lib/__tests__/excerpt.test.ts +125 -0
  96. package/src/lib/__tests__/schemas.test.ts +201 -99
  97. package/src/lib/__tests__/time.test.ts +62 -0
  98. package/src/{routes/api → lib}/__tests__/timeline.test.ts +81 -75
  99. package/src/lib/__tests__/view.test.ts +204 -50
  100. package/src/lib/constants.ts +2 -4
  101. package/src/lib/excerpt.ts +87 -0
  102. package/src/lib/feed.ts +22 -7
  103. package/src/lib/nav-reorder.ts +1 -1
  104. package/src/lib/navigation.ts +45 -8
  105. package/src/lib/pagination.ts +50 -0
  106. package/src/lib/render.tsx +7 -14
  107. package/src/lib/schemas.ts +119 -51
  108. package/src/lib/theme.ts +5 -5
  109. package/src/lib/time.ts +64 -0
  110. package/src/lib/timeline.ts +141 -0
  111. package/src/lib/view.ts +80 -82
  112. package/src/preset.css +46 -0
  113. package/src/routes/__tests__/compose.test.ts +199 -0
  114. package/src/routes/api/__tests__/collections.test.ts +249 -0
  115. package/src/routes/api/__tests__/nav-items.test.ts +222 -0
  116. package/src/routes/api/__tests__/pages.test.ts +218 -0
  117. package/src/routes/api/__tests__/posts.test.ts +50 -108
  118. package/src/routes/api/__tests__/search.test.ts +2 -3
  119. package/src/routes/api/__tests__/settings.test.ts +132 -0
  120. package/src/routes/api/collections.ts +143 -0
  121. package/src/routes/api/nav-items.ts +115 -0
  122. package/src/routes/api/pages.ts +101 -0
  123. package/src/routes/api/posts.ts +28 -28
  124. package/src/routes/api/search.ts +3 -3
  125. package/src/routes/api/settings.ts +91 -0
  126. package/src/routes/api/upload.ts +16 -6
  127. package/src/routes/compose.ts +63 -0
  128. package/src/routes/dash/__tests__/pages.test.ts +225 -0
  129. package/src/routes/dash/collections.tsx +20 -42
  130. package/src/routes/dash/index.tsx +3 -3
  131. package/src/routes/dash/media.tsx +2 -2
  132. package/src/routes/dash/pages.tsx +480 -122
  133. package/src/routes/dash/posts.tsx +42 -54
  134. package/src/routes/dash/redirects.tsx +2 -2
  135. package/src/routes/dash/settings.tsx +83 -5
  136. package/src/routes/feed/rss.ts +4 -3
  137. package/src/routes/feed/sitemap.ts +15 -5
  138. package/src/routes/pages/__tests__/collections.test.ts +94 -0
  139. package/src/routes/pages/__tests__/featured.test.ts +94 -0
  140. package/src/routes/pages/archive.tsx +15 -15
  141. package/src/routes/pages/collection.tsx +16 -9
  142. package/src/routes/pages/collections.tsx +36 -0
  143. package/src/routes/pages/featured.tsx +38 -0
  144. package/src/routes/pages/home.tsx +21 -92
  145. package/src/routes/pages/page.tsx +62 -27
  146. package/src/routes/pages/post.tsx +6 -18
  147. package/src/routes/pages/search.tsx +3 -7
  148. package/src/services/__tests__/collection.test.ts +257 -158
  149. package/src/services/__tests__/media.test.ts +18 -18
  150. package/src/services/__tests__/navigation.test.ts +161 -87
  151. package/src/services/__tests__/page.test.ts +106 -0
  152. package/src/services/__tests__/post-timeline.test.ts +92 -88
  153. package/src/services/__tests__/post.test.ts +432 -197
  154. package/src/services/__tests__/search.test.ts +19 -25
  155. package/src/services/collection.ts +71 -113
  156. package/src/services/index.ts +9 -8
  157. package/src/services/navigation.ts +38 -71
  158. package/src/services/page.ts +136 -0
  159. package/src/services/post.ts +141 -101
  160. package/src/services/search.ts +38 -27
  161. package/src/styles/tokens.css +47 -0
  162. package/src/styles/ui.css +491 -0
  163. package/src/types.ts +212 -198
  164. package/src/ui/compose/ComposeDialog.tsx +395 -0
  165. package/src/ui/compose/ComposePrompt.tsx +55 -0
  166. package/src/ui/dash/FormatBadge.tsx +28 -0
  167. package/src/{theme/components → ui/dash}/PageForm.tsx +21 -21
  168. package/src/{theme/components → ui/dash}/PostForm.tsx +110 -131
  169. package/src/ui/dash/PostList.tsx +101 -0
  170. package/src/ui/dash/StatusBadge.tsx +61 -0
  171. package/src/ui/dash/index.ts +10 -0
  172. package/src/ui/feed/LinkCard.tsx +72 -0
  173. package/src/ui/feed/NoteCard.tsx +63 -0
  174. package/src/ui/feed/QuoteCard.tsx +68 -0
  175. package/src/ui/feed/ThreadPreview.tsx +48 -0
  176. package/src/ui/feed/TimelineFeed.tsx +49 -0
  177. package/src/ui/feed/TimelineItem.tsx +45 -0
  178. package/src/{theme → ui}/layouts/BaseLayout.tsx +11 -1
  179. package/src/{theme → ui}/layouts/DashLayout.tsx +0 -10
  180. package/src/ui/layouts/SiteLayout.tsx +150 -0
  181. package/src/ui/pages/ArchivePage.tsx +162 -0
  182. package/src/ui/pages/CollectionPage.tsx +70 -0
  183. package/src/ui/pages/CollectionsPage.tsx +73 -0
  184. package/src/ui/pages/FeaturedPage.tsx +31 -0
  185. package/src/ui/pages/HomePage.tsx +37 -0
  186. package/src/ui/pages/PostPage.tsx +56 -0
  187. package/src/{themes/minimal → ui}/pages/SearchPage.tsx +24 -20
  188. package/src/{themes/minimal → ui}/pages/SinglePage.tsx +5 -5
  189. package/src/ui/shared/MediaGallery.tsx +59 -0
  190. package/src/{theme/components → ui/shared}/Pagination.tsx +67 -4
  191. package/src/{theme/components → ui/shared}/ThreadView.tsx +6 -3
  192. package/src/ui/shared/__tests__/pagination.test.ts +46 -0
  193. package/src/ui/shared/index.ts +12 -0
  194. package/bin/jant.js +0 -185
  195. package/dist/lib/theme-components.js +0 -49
  196. package/dist/routes/api/timeline.js +0 -120
  197. package/dist/routes/dash/navigation.js +0 -288
  198. package/dist/theme/components/MediaGallery.js +0 -107
  199. package/dist/theme/components/VisibilityBadge.js +0 -37
  200. package/dist/theme/index.js +0 -18
  201. package/dist/theme/layouts/index.js +0 -2
  202. package/dist/themes/minimal/MinimalSiteLayout.js +0 -83
  203. package/dist/themes/minimal/index.js +0 -65
  204. package/dist/themes/minimal/pages/CollectionPage.js +0 -65
  205. package/dist/themes/minimal/pages/HomePage.js +0 -25
  206. package/dist/themes/minimal/timeline/ArticleCard.js +0 -36
  207. package/dist/themes/minimal/timeline/ImageCard.js +0 -67
  208. package/dist/themes/minimal/timeline/LinkCard.js +0 -47
  209. package/dist/themes/minimal/timeline/NoteCard.js +0 -34
  210. package/dist/themes/minimal/timeline/TimelineFeed.js +0 -48
  211. package/dist/themes/minimal/timeline/TimelineItem.js +0 -44
  212. package/src/lib/__tests__/theme-components.test.ts +0 -126
  213. package/src/lib/theme-components.ts +0 -68
  214. package/src/routes/api/timeline.tsx +0 -159
  215. package/src/routes/dash/navigation.tsx +0 -316
  216. package/src/theme/components/MediaGallery.tsx +0 -128
  217. package/src/theme/components/PostList.tsx +0 -92
  218. package/src/theme/components/TypeBadge.tsx +0 -37
  219. package/src/theme/components/VisibilityBadge.tsx +0 -45
  220. package/src/theme/components/index.ts +0 -23
  221. package/src/theme/index.ts +0 -22
  222. package/src/theme/layouts/index.ts +0 -7
  223. package/src/themes/minimal/MinimalSiteLayout.tsx +0 -100
  224. package/src/themes/minimal/index.ts +0 -83
  225. package/src/themes/minimal/pages/ArchivePage.tsx +0 -157
  226. package/src/themes/minimal/pages/CollectionPage.tsx +0 -60
  227. package/src/themes/minimal/pages/HomePage.tsx +0 -41
  228. package/src/themes/minimal/pages/PostPage.tsx +0 -43
  229. package/src/themes/minimal/timeline/ArticleCard.tsx +0 -37
  230. package/src/themes/minimal/timeline/ImageCard.tsx +0 -63
  231. package/src/themes/minimal/timeline/LinkCard.tsx +0 -48
  232. package/src/themes/minimal/timeline/NoteCard.tsx +0 -35
  233. package/src/themes/minimal/timeline/QuoteCard.tsx +0 -49
  234. package/src/themes/minimal/timeline/ThreadPreview.tsx +0 -47
  235. package/src/themes/minimal/timeline/TimelineFeed.tsx +0 -57
  236. package/src/themes/minimal/timeline/TimelineItem.tsx +0 -75
  237. /package/dist/{theme → ui}/color-themes.js +0 -0
  238. /package/dist/{theme/components → ui/dash}/ActionButtons.js +0 -0
  239. /package/dist/{theme/components → ui/dash}/CrudPageHeader.js +0 -0
  240. /package/dist/{theme/components → ui/dash}/DangerZone.js +0 -0
  241. /package/dist/{theme/components → ui/dash}/ListItemRow.js +0 -0
  242. /package/dist/{theme/components → ui/shared}/EmptyState.js +0 -0
  243. /package/src/{theme → ui}/color-themes.ts +0 -0
  244. /package/src/{theme/components → ui/dash}/ActionButtons.tsx +0 -0
  245. /package/src/{theme/components → ui/dash}/CrudPageHeader.tsx +0 -0
  246. /package/src/{theme/components → ui/dash}/DangerZone.tsx +0 -0
  247. /package/src/{theme/components → ui/dash}/ListItemRow.tsx +0 -0
  248. /package/src/{theme/components → ui/shared}/EmptyState.tsx +0 -0
@@ -7,13 +7,13 @@ import { Hono } from "hono";
7
7
  import { useLingui } from "@lingui/react/macro";
8
8
  import type { Bindings, Post, Media, Collection } from "../../types.js";
9
9
  import type { AppVariables } from "../../app.js";
10
- import { DashLayout } from "../../theme/layouts/index.js";
10
+ import { DashLayout } from "../../ui/layouts/DashLayout.js";
11
11
  import {
12
12
  PostForm,
13
13
  PostList,
14
14
  CrudPageHeader,
15
15
  ActionButtons,
16
- } from "../../theme/components/index.js";
16
+ } from "../../ui/dash/index.js";
17
17
  import * as sqid from "../../lib/sqid.js";
18
18
  import { dsRedirect } from "../../lib/sse.js";
19
19
 
@@ -53,7 +53,7 @@ function NewPostContent({ collections }: { collections: Collection[] }) {
53
53
  // List posts
54
54
  postsRoutes.get("/", async (c) => {
55
55
  const posts = await c.var.services.posts.list({
56
- visibility: ["featured", "quiet", "unlisted", "draft"],
56
+ excludeReplies: true,
57
57
  });
58
58
  const siteName = await getSiteName(c);
59
59
 
@@ -89,25 +89,30 @@ postsRoutes.get("/new", async (c) => {
89
89
  // Create post
90
90
  postsRoutes.post("/", async (c) => {
91
91
  const body = await c.req.json<{
92
- type: string;
92
+ format: string;
93
93
  title?: string;
94
- content: string;
95
- visibility: string;
96
- sourceUrl?: string;
97
- sourceName?: string;
98
- path?: string;
94
+ body: string;
95
+ status: string;
96
+ featured?: boolean;
97
+ pinned?: boolean;
98
+ url?: string;
99
+ quoteText?: string;
100
+ rating?: number;
101
+ collectionId?: number;
99
102
  mediaIds?: string[];
100
- collectionIds?: number[];
101
103
  }>();
102
104
 
103
105
  const post = await c.var.services.posts.create({
104
- type: body.type as Post["type"],
106
+ format: body.format as Post["format"],
105
107
  title: body.title || undefined,
106
- content: body.content,
107
- visibility: body.visibility as Post["visibility"],
108
- sourceUrl: body.sourceUrl || undefined,
109
- sourceName: body.sourceName || undefined,
110
- path: body.path || undefined,
108
+ body: body.body,
109
+ status: body.status as Post["status"],
110
+ featured: body.featured,
111
+ pinned: body.pinned,
112
+ url: body.url || undefined,
113
+ quoteText: body.quoteText || undefined,
114
+ rating: body.rating || undefined,
115
+ collectionId: body.collectionId || undefined,
111
116
  });
112
117
 
113
118
  // Attach media if provided
@@ -115,14 +120,6 @@ postsRoutes.post("/", async (c) => {
115
120
  await c.var.services.media.attachToPost(post.id, body.mediaIds);
116
121
  }
117
122
 
118
- // Sync collection associations
119
- if (body.collectionIds) {
120
- await c.var.services.collections.syncPostCollections(
121
- post.id,
122
- body.collectionIds,
123
- );
124
- }
125
-
126
123
  return dsRedirect(`/dash/posts/${sqid.encode(post.id)}`);
127
124
  });
128
125
 
@@ -132,6 +129,7 @@ function ViewPostContent({ post }: { post: Post }) {
132
129
  message: "Post",
133
130
  comment: "@context: Default post title",
134
131
  });
132
+ const permalink = post.path ? `/${post.path}` : `/p/${sqid.encode(post.id)}`;
135
133
 
136
134
  return (
137
135
  <>
@@ -143,7 +141,7 @@ function ViewPostContent({ post }: { post: Post }) {
143
141
  message: "Edit",
144
142
  comment: "@context: Button to edit post",
145
143
  })}
146
- viewHref={`/p/${sqid.encode(post.id)}`}
144
+ viewHref={permalink}
147
145
  viewLabel={t({
148
146
  message: "View",
149
147
  comment: "@context: Button to view post",
@@ -155,7 +153,7 @@ function ViewPostContent({ post }: { post: Post }) {
155
153
  <section>
156
154
  <div
157
155
  class="prose"
158
- dangerouslySetInnerHTML={{ __html: post.contentHtml || "" }}
156
+ dangerouslySetInnerHTML={{ __html: post.bodyHtml || "" }}
159
157
  />
160
158
  </section>
161
159
  </div>
@@ -170,7 +168,6 @@ function EditPostContent({
170
168
  imageTransformUrl,
171
169
  s3PublicUrl,
172
170
  collections,
173
- postCollectionIds,
174
171
  }: {
175
172
  post: Post;
176
173
  mediaAttachments: Media[];
@@ -178,7 +175,6 @@ function EditPostContent({
178
175
  imageTransformUrl?: string;
179
176
  s3PublicUrl?: string;
180
177
  collections: Collection[];
181
- postCollectionIds: number[];
182
178
  }) {
183
179
  const { t } = useLingui();
184
180
  return (
@@ -194,7 +190,6 @@ function EditPostContent({
194
190
  imageTransformUrl={imageTransformUrl}
195
191
  s3PublicUrl={s3PublicUrl}
196
192
  collections={collections}
197
- postCollectionIds={postCollectionIds}
198
193
  />
199
194
  </>
200
195
  );
@@ -237,9 +232,6 @@ postsRoutes.get("/:id/edit", async (c) => {
237
232
  const imageTransformUrl = c.env.IMAGE_TRANSFORM_URL;
238
233
  const s3PublicUrl = c.env.S3_PUBLIC_URL;
239
234
  const collections = await c.var.services.collections.list();
240
- const postCollections =
241
- await c.var.services.collections.getCollectionsForPost(post.id);
242
- const postCollectionIds = postCollections.map((col) => col.id);
243
235
 
244
236
  return c.html(
245
237
  <DashLayout
@@ -255,7 +247,6 @@ postsRoutes.get("/:id/edit", async (c) => {
255
247
  imageTransformUrl={imageTransformUrl}
256
248
  s3PublicUrl={s3PublicUrl}
257
249
  collections={collections}
258
- postCollectionIds={postCollectionIds}
259
250
  />
260
251
  </DashLayout>,
261
252
  );
@@ -267,25 +258,30 @@ postsRoutes.post("/:id", async (c) => {
267
258
  if (!id) return c.notFound();
268
259
 
269
260
  const body = await c.req.json<{
270
- type: string;
261
+ format: string;
271
262
  title?: string;
272
- content?: string;
273
- visibility: string;
274
- sourceUrl?: string;
275
- sourceName?: string;
276
- path?: string;
263
+ body?: string;
264
+ status: string;
265
+ featured?: boolean;
266
+ pinned?: boolean;
267
+ url?: string;
268
+ quoteText?: string;
269
+ rating?: number;
270
+ collectionId?: number;
277
271
  mediaIds?: string[];
278
- collectionIds?: number[];
279
272
  }>();
280
273
 
281
274
  await c.var.services.posts.update(id, {
282
- type: body.type as Post["type"],
275
+ format: body.format as Post["format"],
283
276
  title: body.title || null,
284
- content: body.content || null,
285
- visibility: body.visibility as Post["visibility"],
286
- sourceUrl: body.sourceUrl || null,
287
- sourceName: body.sourceName || null,
288
- path: body.path || null,
277
+ body: body.body || null,
278
+ status: body.status as Post["status"],
279
+ featured: body.featured,
280
+ pinned: body.pinned,
281
+ url: body.url || null,
282
+ quoteText: body.quoteText || null,
283
+ rating: body.rating || null,
284
+ collectionId: body.collectionId || null,
289
285
  });
290
286
 
291
287
  // Update media attachments if provided
@@ -293,14 +289,6 @@ postsRoutes.post("/:id", async (c) => {
293
289
  await c.var.services.media.attachToPost(id, body.mediaIds);
294
290
  }
295
291
 
296
- // Sync collection associations
297
- if (body.collectionIds !== undefined) {
298
- await c.var.services.collections.syncPostCollections(
299
- id,
300
- body.collectionIds,
301
- );
302
- }
303
-
304
292
  return dsRedirect(`/dash/posts/${sqid.encode(id)}`);
305
293
  });
306
294
 
@@ -7,13 +7,13 @@ import { Hono } from "hono";
7
7
  import { useLingui } from "@lingui/react/macro";
8
8
  import type { Bindings, Redirect } from "../../types.js";
9
9
  import type { AppVariables } from "../../app.js";
10
- import { DashLayout } from "../../theme/layouts/index.js";
10
+ import { DashLayout } from "../../ui/layouts/DashLayout.js";
11
11
  import {
12
12
  EmptyState,
13
13
  ListItemRow,
14
14
  ActionButtons,
15
15
  CrudPageHeader,
16
- } from "../../theme/components/index.js";
16
+ } from "../../ui/dash/index.js";
17
17
  import { dsRedirect } from "../../lib/sse.js";
18
18
 
19
19
  type Env = { Bindings: Bindings; Variables: AppVariables };
@@ -8,7 +8,7 @@ import { Hono } from "hono";
8
8
  import { useLingui } from "@lingui/react/macro";
9
9
  import type { Bindings } from "../../types.js";
10
10
  import type { AppVariables } from "../../app.js";
11
- import { DashLayout } from "../../theme/layouts/index.js";
11
+ import { DashLayout } from "../../ui/layouts/DashLayout.js";
12
12
  import { sse, dsRedirect, dsToast } from "../../lib/sse.js";
13
13
  import {
14
14
  getSiteLanguage,
@@ -17,7 +17,7 @@ import {
17
17
  } from "../../lib/config.js";
18
18
  import { SETTINGS_KEYS } from "../../lib/constants.js";
19
19
  import { getAvailableThemes } from "../../lib/theme.js";
20
- import type { ColorTheme } from "../../theme/color-themes.js";
20
+ import type { ColorTheme } from "../../ui/color-themes.js";
21
21
 
22
22
  /** Escape HTML special characters for safe insertion into HTML strings */
23
23
  function escapeHtml(str: string): string {
@@ -283,17 +283,21 @@ function ThemeCard({
283
283
  function AppearanceContent({
284
284
  themes,
285
285
  currentThemeId,
286
+ customCSS,
286
287
  }: {
287
288
  themes: ColorTheme[];
288
289
  currentThemeId: string;
290
+ customCSS: string;
289
291
  }) {
290
292
  const { t } = useLingui();
291
293
 
292
- const signals = JSON.stringify({ theme: currentThemeId }).replace(
294
+ const themeSignals = JSON.stringify({ theme: currentThemeId }).replace(
293
295
  /</g,
294
296
  "\\u003c",
295
297
  );
296
298
 
299
+ const cssSignals = JSON.stringify({ customCSS }).replace(/</g, "\\u003c");
300
+
297
301
  return (
298
302
  <>
299
303
  <h1 class="text-2xl font-semibold mb-2">
@@ -302,7 +306,7 @@ function AppearanceContent({
302
306
  <SettingsNav currentTab="appearance" />
303
307
 
304
308
  <div
305
- data-signals={signals}
309
+ data-signals={themeSignals}
306
310
  data-on:change="@post('/dash/settings/appearance')"
307
311
  class="max-w-3xl"
308
312
  >
@@ -332,6 +336,59 @@ function AppearanceContent({
332
336
  </div>
333
337
  </fieldset>
334
338
  </div>
339
+
340
+ <form
341
+ data-signals={cssSignals}
342
+ data-on:submit__prevent="@post('/dash/settings/custom-css')"
343
+ data-indicator="_cssLoading"
344
+ class="max-w-3xl mt-8"
345
+ >
346
+ <fieldset>
347
+ <legend class="text-lg font-semibold">
348
+ {t({
349
+ message: "Custom CSS",
350
+ comment: "@context: Appearance settings heading for custom CSS",
351
+ })}
352
+ </legend>
353
+ <p class="text-sm text-muted-foreground mb-4">
354
+ {t({
355
+ message:
356
+ "Add custom CSS to override any styles. Use data attributes like [data-page], [data-post], [data-format] to target specific elements.",
357
+ comment: "@context: Custom CSS settings description",
358
+ })}
359
+ </p>
360
+ <textarea
361
+ data-bind="customCSS"
362
+ class="textarea font-mono text-sm min-h-32"
363
+ rows={8}
364
+ placeholder={t({
365
+ message: "/* Your custom CSS here */",
366
+ comment: "@context: Custom CSS textarea placeholder",
367
+ })}
368
+ >
369
+ {customCSS}
370
+ </textarea>
371
+ </fieldset>
372
+ <button
373
+ type="submit"
374
+ class="btn mt-4"
375
+ data-attr-disabled="$_cssLoading"
376
+ >
377
+ <span data-show="!$_cssLoading">
378
+ {t({
379
+ message: "Save CSS",
380
+ comment: "@context: Button to save custom CSS",
381
+ })}
382
+ </span>
383
+ <span data-show="$_cssLoading">
384
+ {t({
385
+ message: "Processing...",
386
+ comment:
387
+ "@context: Loading text shown on submit button while request is in progress",
388
+ })}
389
+ </span>
390
+ </button>
391
+ </form>
335
392
  </>
336
393
  );
337
394
  }
@@ -585,6 +642,7 @@ settingsRoutes.get("/appearance", async (c) => {
585
642
  const { settings } = c.var.services;
586
643
  const siteName = await getSiteName(c);
587
644
  const currentThemeId = (await settings.get(SETTINGS_KEYS.THEME)) ?? "default";
645
+ const customCSS = (await settings.get(SETTINGS_KEYS.CUSTOM_CSS)) ?? "";
588
646
  const themes = getAvailableThemes(c.var.config);
589
647
  const saved = c.req.query("saved") !== undefined;
590
648
 
@@ -596,7 +654,11 @@ settingsRoutes.get("/appearance", async (c) => {
596
654
  currentPath="/dash/settings"
597
655
  toast={saved ? { message: "Theme saved successfully." } : undefined}
598
656
  >
599
- <AppearanceContent themes={themes} currentThemeId={currentThemeId} />
657
+ <AppearanceContent
658
+ themes={themes}
659
+ currentThemeId={currentThemeId}
660
+ customCSS={customCSS}
661
+ />
600
662
  </DashLayout>,
601
663
  );
602
664
  });
@@ -621,6 +683,22 @@ settingsRoutes.post("/appearance", async (c) => {
621
683
  return dsRedirect("/dash/settings/appearance?saved");
622
684
  });
623
685
 
686
+ // Save custom CSS
687
+ settingsRoutes.post("/custom-css", async (c) => {
688
+ const body = await c.req.json<{ customCSS: string }>();
689
+ const { settings } = c.var.services;
690
+
691
+ const css = body.customCSS?.trim() ?? "";
692
+
693
+ if (css) {
694
+ await settings.set(SETTINGS_KEYS.CUSTOM_CSS, css);
695
+ } else {
696
+ await settings.remove(SETTINGS_KEYS.CUSTOM_CSS);
697
+ }
698
+
699
+ return dsToast("Custom CSS saved successfully.");
700
+ });
701
+
624
702
  // Account page
625
703
  settingsRoutes.get("/account", async (c) => {
626
704
  const siteName = await getSiteName(c);
@@ -26,7 +26,8 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
26
26
  const siteLanguage = await getSiteLanguage(c);
27
27
 
28
28
  const posts = await c.var.services.posts.list({
29
- visibility: ["featured", "quiet"],
29
+ status: "published",
30
+ excludeReplies: true,
30
31
  limit: 50,
31
32
  });
32
33
 
@@ -63,7 +64,7 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
63
64
  rssRoutes.get("/", async (c) => {
64
65
  const feedData = await buildFeedData(c);
65
66
 
66
- const renderer = c.var.config.theme?.feed?.rss ?? defaultRssRenderer;
67
+ const renderer = c.var.config.feed?.rss ?? defaultRssRenderer;
67
68
  const xml = renderer(feedData);
68
69
 
69
70
  return new Response(xml, {
@@ -77,7 +78,7 @@ rssRoutes.get("/", async (c) => {
77
78
  rssRoutes.get("/atom.xml", async (c) => {
78
79
  const feedData = await buildFeedData(c);
79
80
 
80
- const renderer = c.var.config.theme?.feed?.atom ?? defaultAtomRenderer;
81
+ const renderer = c.var.config.feed?.atom ?? defaultAtomRenderer;
81
82
  const xml = renderer(feedData);
82
83
 
83
84
  return new Response(xml, {
@@ -6,7 +6,11 @@ import { Hono } from "hono";
6
6
  import type { Bindings } from "../../types.js";
7
7
  import type { AppVariables } from "../../app.js";
8
8
  import { defaultSitemapRenderer } from "../../lib/feed.js";
9
- import { createMediaContext, toPostViewsFromPosts } from "../../lib/view.js";
9
+ import {
10
+ createMediaContext,
11
+ toPostViewsFromPosts,
12
+ toPageView,
13
+ } from "../../lib/view.js";
10
14
 
11
15
  type Env = { Bindings: Bindings; Variables: AppVariables };
12
16
 
@@ -17,16 +21,22 @@ sitemapRoutes.get("/sitemap.xml", async (c) => {
17
21
  const siteUrl = c.env.SITE_URL;
18
22
 
19
23
  const posts = await c.var.services.posts.list({
20
- visibility: ["featured", "quiet"],
24
+ status: "published",
25
+ excludeReplies: true,
21
26
  limit: 1000,
22
27
  });
23
28
 
24
- // Transform to PostView[]
29
+ // Fetch published pages
30
+ const allPages = await c.var.services.pages.list();
31
+ const publishedPages = allPages.filter((p) => p.status === "published");
32
+
33
+ // Transform to View Models
25
34
  const mediaCtx = createMediaContext(c);
26
35
  const postViews = toPostViewsFromPosts(posts, mediaCtx);
36
+ const pageViews = publishedPages.map(toPageView);
27
37
 
28
- const renderer = c.var.config.theme?.feed?.sitemap ?? defaultSitemapRenderer;
29
- const xml = renderer({ siteUrl, posts: postViews });
38
+ const renderer = c.var.config.feed?.sitemap ?? defaultSitemapRenderer;
39
+ const xml = renderer({ siteUrl, posts: postViews, pages: pageViews });
30
40
 
31
41
  return new Response(xml, {
32
42
  headers: {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for the collections listing page data logic.
3
+ *
4
+ * Note: Route handler tests that import JSX components with @lingui/react/macro
5
+ * cannot run in vitest (requires SWC plugin). These tests verify the service
6
+ * layer operations that the collections route orchestrates.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from "vitest";
10
+ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
11
+ import { createCollectionService } from "../../../services/collection.js";
12
+ import { createPostService } from "../../../services/post.js";
13
+ import type { Database } from "../../../db/index.js";
14
+
15
+ describe("Collections Listing Page - Data Logic", () => {
16
+ let db: Database;
17
+ let collectionService: ReturnType<typeof createCollectionService>;
18
+ let postService: ReturnType<typeof createPostService>;
19
+
20
+ beforeEach(() => {
21
+ const testDb = createTestDatabase();
22
+ db = testDb.db as unknown as Database;
23
+ collectionService = createCollectionService(db);
24
+ postService = createPostService(db);
25
+ });
26
+
27
+ it("returns collections with post counts", async () => {
28
+ const recipes = await collectionService.create({
29
+ slug: "recipes",
30
+ title: "Recipes",
31
+ });
32
+ await collectionService.create({
33
+ slug: "travel",
34
+ title: "Travel",
35
+ });
36
+
37
+ // Add posts to recipes collection
38
+ await postService.create({
39
+ format: "note",
40
+ body: "Recipe 1",
41
+ collectionId: recipes.id,
42
+ });
43
+ await postService.create({
44
+ format: "note",
45
+ body: "Recipe 2",
46
+ collectionId: recipes.id,
47
+ });
48
+
49
+ // Simulate route handler logic
50
+ const [allCollections, postCounts] = await Promise.all([
51
+ collectionService.list(),
52
+ collectionService.getPostCounts(),
53
+ ]);
54
+
55
+ const collections = allCollections.map((col) => ({
56
+ ...col,
57
+ postCount: postCounts.get(col.id) ?? 0,
58
+ }));
59
+
60
+ expect(collections).toHaveLength(2);
61
+ const recipesResult = collections.find((c) => c.slug === "recipes");
62
+ const travelResult = collections.find((c) => c.slug === "travel");
63
+ expect(recipesResult?.postCount).toBe(2);
64
+ expect(travelResult?.postCount).toBe(0);
65
+ });
66
+
67
+ it("returns empty list when no collections exist", async () => {
68
+ const allCollections = await collectionService.list();
69
+ expect(allCollections).toHaveLength(0);
70
+ });
71
+
72
+ it("does not count soft-deleted posts", async () => {
73
+ const col = await collectionService.create({
74
+ slug: "test",
75
+ title: "Test",
76
+ });
77
+
78
+ const post = await postService.create({
79
+ format: "note",
80
+ body: "Will be deleted",
81
+ collectionId: col.id,
82
+ });
83
+ await postService.create({
84
+ format: "note",
85
+ body: "Will remain",
86
+ collectionId: col.id,
87
+ });
88
+
89
+ await postService.delete(post.id);
90
+
91
+ const postCounts = await collectionService.getPostCounts();
92
+ expect(postCounts.get(col.id)).toBe(1);
93
+ });
94
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Tests for the featured page data logic.
3
+ *
4
+ * Note: Route handler tests that import JSX components with @lingui/react/macro
5
+ * cannot run in vitest (requires SWC plugin). These tests verify the service
6
+ * layer operations that the featured route orchestrates.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach } from "vitest";
10
+ import { createTestDatabase } from "../../../__tests__/helpers/db.js";
11
+ import { createPostService } from "../../../services/post.js";
12
+ import type { Database } from "../../../db/index.js";
13
+
14
+ describe("Featured Page - Data Logic", () => {
15
+ let db: Database;
16
+ let postService: ReturnType<typeof createPostService>;
17
+
18
+ beforeEach(() => {
19
+ const testDb = createTestDatabase();
20
+ db = testDb.db as unknown as Database;
21
+ postService = createPostService(db);
22
+ });
23
+
24
+ it("returns only featured published posts", async () => {
25
+ await postService.create({
26
+ format: "note",
27
+ body: "Featured post",
28
+ featured: true,
29
+ status: "published",
30
+ });
31
+ await postService.create({
32
+ format: "note",
33
+ body: "Normal post",
34
+ featured: false,
35
+ status: "published",
36
+ });
37
+ await postService.create({
38
+ format: "note",
39
+ body: "Draft featured",
40
+ featured: true,
41
+ status: "draft",
42
+ });
43
+
44
+ const posts = await postService.list({
45
+ featured: true,
46
+ status: "published",
47
+ excludeReplies: true,
48
+ });
49
+
50
+ expect(posts).toHaveLength(1);
51
+ expect(posts[0]?.body).toBe("Featured post");
52
+ });
53
+
54
+ it("returns empty list when no featured posts exist", async () => {
55
+ await postService.create({
56
+ format: "note",
57
+ body: "Normal post",
58
+ status: "published",
59
+ });
60
+
61
+ const posts = await postService.list({
62
+ featured: true,
63
+ status: "published",
64
+ excludeReplies: true,
65
+ });
66
+
67
+ expect(posts).toHaveLength(0);
68
+ });
69
+
70
+ it("excludes replies from featured posts", async () => {
71
+ const root = await postService.create({
72
+ format: "note",
73
+ body: "Featured root",
74
+ featured: true,
75
+ status: "published",
76
+ });
77
+
78
+ // Reply inherits featured from root
79
+ await postService.create({
80
+ format: "note",
81
+ body: "Reply to featured",
82
+ replyToId: root.id,
83
+ });
84
+
85
+ const posts = await postService.list({
86
+ featured: true,
87
+ status: "published",
88
+ excludeReplies: true,
89
+ });
90
+
91
+ expect(posts).toHaveLength(1);
92
+ expect(posts[0]?.body).toBe("Featured root");
93
+ });
94
+ });