@jant/core 0.3.34 → 0.3.36

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 (156) hide show
  1. package/dist/client/assets/module-RjUF93sV.js +716 -0
  2. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  3. package/dist/client/assets/url-8Dj-5CLW.js +1 -0
  4. package/dist/client/client.css +1 -1
  5. package/dist/client/client.js +3109 -2294
  6. package/dist/index.js +3327 -3031
  7. package/package.json +13 -4
  8. package/src/__tests__/helpers/app.ts +1 -1
  9. package/src/__tests__/helpers/db.ts +6 -0
  10. package/src/app.tsx +1 -5
  11. package/src/{lib → client}/avatar-upload.ts +1 -1
  12. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  13. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  14. package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
  15. package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
  16. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
  17. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  18. package/src/client/components/collection-sidebar-types.ts +45 -0
  19. package/src/{ui → client}/components/collection-types.ts +3 -4
  20. package/src/{ui → client}/components/compose-types.ts +3 -1
  21. package/src/{ui → client}/components/jant-collection-form.ts +301 -182
  22. package/src/client/components/jant-collection-sidebar.ts +801 -0
  23. package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
  24. package/src/client/components/jant-compose-editor.ts +1249 -0
  25. package/src/client/components/jant-compose-fullscreen.ts +338 -0
  26. package/src/client/components/jant-media-lightbox.ts +257 -0
  27. package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
  28. package/src/{ui → client}/components/jant-post-form.ts +57 -8
  29. package/src/{ui → client}/components/jant-settings-general.ts +2 -2
  30. package/src/{ui → client}/components/nav-manager-types.ts +3 -0
  31. package/src/{ui → client}/components/post-form-template.ts +35 -31
  32. package/src/{ui → client}/components/post-form-types.ts +7 -3
  33. package/src/{lib → client}/compose-bridge.ts +9 -7
  34. package/src/client/lazy-slugify.ts +51 -0
  35. package/src/{lib → client}/media-upload.ts +16 -3
  36. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  37. package/src/client/page-slug-bridge.ts +42 -0
  38. package/src/{lib → client}/post-form-bridge.ts +2 -2
  39. package/src/{lib → client}/settings-bridge.ts +3 -3
  40. package/src/client/tiptap/bubble-menu.ts +205 -0
  41. package/src/client/tiptap/create-editor.ts +40 -0
  42. package/src/client/tiptap/exitable-marks.ts +73 -0
  43. package/src/client/tiptap/extensions.ts +60 -0
  44. package/src/client/tiptap/image-node.ts +488 -0
  45. package/src/client/tiptap/link-toolbar.ts +371 -0
  46. package/src/client/tiptap/more-break.ts +50 -0
  47. package/src/client/tiptap/paste-image.ts +140 -0
  48. package/src/client/tiptap/slash-commands.ts +328 -0
  49. package/src/{types → client/types}/sortablejs.d.ts +1 -1
  50. package/src/client.ts +24 -17
  51. package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
  52. package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
  53. package/src/db/schema.ts +6 -1
  54. package/src/i18n/locales/en.po +641 -215
  55. package/src/i18n/locales/en.ts +1 -1
  56. package/src/i18n/locales/zh-Hans.po +642 -204
  57. package/src/i18n/locales/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/zh-Hant.po +642 -204
  59. package/src/i18n/locales/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  61. package/src/lib/__tests__/schemas.test.ts +9 -6
  62. package/src/lib/__tests__/url.test.ts +2 -2
  63. package/src/lib/__tests__/view.test.ts +9 -9
  64. package/src/lib/emoji-catalog.ts +146 -0
  65. package/src/lib/feed.ts +1 -1
  66. package/src/lib/media-helpers.ts +10 -9
  67. package/src/lib/render.tsx +4 -3
  68. package/src/lib/resolve-config.ts +8 -1
  69. package/src/lib/schemas.ts +2 -3
  70. package/src/lib/summary.ts +92 -0
  71. package/src/lib/timeline.ts +2 -0
  72. package/src/lib/tiptap-render.ts +196 -0
  73. package/src/lib/upload.ts +97 -9
  74. package/src/lib/url.ts +7 -23
  75. package/src/lib/view.ts +33 -19
  76. package/src/middleware/error-handler.ts +3 -3
  77. package/src/preset.css +38 -0
  78. package/src/routes/api/collections.ts +20 -3
  79. package/src/routes/api/posts.ts +48 -33
  80. package/src/routes/api/upload.ts +7 -5
  81. package/src/routes/auth/reset.tsx +5 -4
  82. package/src/routes/auth/setup.tsx +26 -11
  83. package/src/routes/auth/signin.tsx +10 -7
  84. package/src/routes/compose.tsx +20 -11
  85. package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
  86. package/src/routes/dash/index.tsx +7 -1
  87. package/src/routes/dash/media.tsx +3 -0
  88. package/src/routes/dash/pages.tsx +8 -2
  89. package/src/routes/dash/posts.tsx +6 -2
  90. package/src/routes/dash/redirects.tsx +15 -9
  91. package/src/routes/dash/settings.tsx +336 -32
  92. package/src/routes/feed/__tests__/rss.test.ts +245 -6
  93. package/src/routes/feed/rss.ts +70 -6
  94. package/src/routes/pages/__tests__/featured.test.ts +6 -7
  95. package/src/routes/pages/archive.tsx +11 -7
  96. package/src/routes/pages/collection.tsx +32 -15
  97. package/src/routes/pages/collections.tsx +11 -2
  98. package/src/routes/pages/featured.tsx +1 -1
  99. package/src/routes/pages/home.tsx +1 -1
  100. package/src/services/__tests__/post.test.ts +124 -33
  101. package/src/services/__tests__/settings.test.ts +3 -3
  102. package/src/services/page.ts +16 -3
  103. package/src/services/post.ts +96 -37
  104. package/src/services/search.ts +4 -2
  105. package/src/services/settings.ts +6 -2
  106. package/src/styles/components.css +240 -60
  107. package/src/styles/tokens.css +10 -0
  108. package/src/styles/ui.css +1157 -81
  109. package/src/types/bindings.ts +5 -0
  110. package/src/types/config.ts +23 -1
  111. package/src/types/constants.ts +3 -0
  112. package/src/types/entities.ts +9 -2
  113. package/src/types/operations.ts +9 -3
  114. package/src/types/props.ts +3 -3
  115. package/src/types/views.ts +3 -2
  116. package/src/ui/compose/ComposeDialog.tsx +24 -7
  117. package/src/ui/dash/PageForm.tsx +2 -0
  118. package/src/ui/dash/PostList.tsx +5 -5
  119. package/src/ui/dash/StatusBadge.tsx +13 -5
  120. package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
  121. package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
  122. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  123. package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
  124. package/src/ui/dash/media/MediaListContent.tsx +9 -4
  125. package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
  126. package/src/ui/dash/pages/PagesContent.tsx +2 -1
  127. package/src/ui/dash/posts/PostForm.tsx +19 -7
  128. package/src/ui/dash/settings/AccountContent.tsx +133 -138
  129. package/src/ui/dash/settings/AvatarContent.tsx +70 -0
  130. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  131. package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
  132. package/src/ui/layouts/DashLayout.tsx +157 -75
  133. package/src/ui/layouts/SiteLayout.tsx +13 -13
  134. package/src/ui/pages/ArchivePage.tsx +10 -7
  135. package/src/ui/pages/CollectionPage.tsx +6 -35
  136. package/src/ui/pages/CollectionsPage.tsx +2 -1
  137. package/src/ui/pages/FeaturedPage.tsx +2 -1
  138. package/src/ui/pages/HomePage.tsx +1 -1
  139. package/src/ui/pages/SearchPage.tsx +1 -1
  140. package/src/ui/shared/CollectionsSidebar.tsx +228 -3
  141. package/src/ui/shared/MediaGallery.tsx +179 -41
  142. package/src/lib/collections-reorder.ts +0 -28
  143. package/src/routes/dash/appearance.tsx +0 -240
  144. package/src/routes/dash/collections.tsx +0 -211
  145. package/src/ui/components/jant-compose-editor.ts +0 -814
  146. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  147. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  148. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  149. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  150. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  151. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  152. /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
  153. /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
  154. /package/src/{ui → client}/components/settings-types.ts +0 -0
  155. /package/src/{lib → client}/image-processor.ts +0 -0
  156. /package/src/{lib → client}/toast.ts +0 -0
@@ -28,7 +28,7 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
28
28
 
29
29
  app.use("*", async (c, next) => {
30
30
  const env = {
31
- SITE_URL: "http://localhost:9019",
31
+ SITE_URL: "http://localhost:9020",
32
32
  ...envOverrides,
33
33
  } as Bindings;
34
34
  c.env = env;
@@ -46,17 +46,255 @@ function createFeedTestApp(envOverrides: Partial<Bindings> = {}) {
46
46
  }
47
47
 
48
48
  describe("RSS Feed Routes", () => {
49
+ describe("/feed — featured only", () => {
50
+ it("returns only featured posts", async () => {
51
+ const { app, services } = createFeedTestApp();
52
+
53
+ // Create a mix of featured and non-featured posts
54
+ await services.posts.create({
55
+ format: "note",
56
+ title: "Regular Post",
57
+ body: "Not featured",
58
+ status: "published",
59
+ });
60
+ await services.posts.create({
61
+ format: "note",
62
+ title: "Featured Post",
63
+ body: "This is featured",
64
+ status: "published",
65
+ visibility: "featured",
66
+ });
67
+
68
+ const res = await app.request("/feed");
69
+ expect(res.status).toBe(200);
70
+
71
+ const xml = await res.text();
72
+ expect(xml).toContain("Featured Post");
73
+ expect(xml).not.toContain("Regular Post");
74
+ });
75
+
76
+ it("returns empty feed when no featured posts exist", async () => {
77
+ const { app, services } = createFeedTestApp();
78
+
79
+ await services.posts.create({
80
+ format: "note",
81
+ title: "Regular Post",
82
+ body: "Not featured",
83
+ status: "published",
84
+ });
85
+
86
+ const res = await app.request("/feed");
87
+ expect(res.status).toBe(200);
88
+
89
+ const xml = await res.text();
90
+ expect(xml).not.toContain("Regular Post");
91
+ });
92
+
93
+ it("returns RSS content type", async () => {
94
+ const { app } = createFeedTestApp();
95
+
96
+ const res = await app.request("/feed");
97
+ expect(res.headers.get("Content-Type")).toBe(
98
+ "application/rss+xml; charset=utf-8",
99
+ );
100
+ });
101
+ });
102
+
103
+ describe("/feed/atom.xml — featured only (Atom)", () => {
104
+ it("returns only featured posts in Atom format", async () => {
105
+ const { app, services } = createFeedTestApp();
106
+
107
+ await services.posts.create({
108
+ format: "note",
109
+ title: "Regular Post",
110
+ body: "Not featured",
111
+ status: "published",
112
+ });
113
+ await services.posts.create({
114
+ format: "note",
115
+ title: "Featured Post",
116
+ body: "This is featured",
117
+ status: "published",
118
+ visibility: "featured",
119
+ });
120
+
121
+ const res = await app.request("/feed/atom.xml");
122
+ expect(res.status).toBe(200);
123
+ expect(res.headers.get("Content-Type")).toBe(
124
+ "application/atom+xml; charset=utf-8",
125
+ );
126
+
127
+ const xml = await res.text();
128
+ expect(xml).toContain("Featured Post");
129
+ expect(xml).not.toContain("Regular Post");
130
+ });
131
+ });
132
+
133
+ describe("/feed/all — all published posts", () => {
134
+ it("returns all published posts", async () => {
135
+ const { app, services } = createFeedTestApp();
136
+
137
+ await services.posts.create({
138
+ format: "note",
139
+ title: "Regular Post",
140
+ body: "Not featured",
141
+ status: "published",
142
+ });
143
+ await services.posts.create({
144
+ format: "note",
145
+ title: "Featured Post",
146
+ body: "This is featured",
147
+ status: "published",
148
+ visibility: "featured",
149
+ });
150
+ await services.posts.create({
151
+ format: "note",
152
+ title: "Draft Post",
153
+ body: "Draft",
154
+ status: "draft",
155
+ });
156
+
157
+ const res = await app.request("/feed/all");
158
+ expect(res.status).toBe(200);
159
+
160
+ const xml = await res.text();
161
+ expect(xml).toContain("Regular Post");
162
+ expect(xml).toContain("Featured Post");
163
+ expect(xml).not.toContain("Draft Post");
164
+ });
165
+
166
+ it("filters by format query parameter", async () => {
167
+ const { app, services } = createFeedTestApp();
168
+
169
+ await services.posts.create({
170
+ format: "note",
171
+ title: "My Note",
172
+ body: "A note",
173
+ status: "published",
174
+ });
175
+ await services.posts.create({
176
+ format: "link",
177
+ title: "My Link",
178
+ url: "https://example.com",
179
+ status: "published",
180
+ });
181
+ await services.posts.create({
182
+ format: "quote",
183
+ title: "My Quote",
184
+ quoteText: "Something wise",
185
+ status: "published",
186
+ });
187
+
188
+ const res = await app.request("/feed/all?format=note");
189
+ expect(res.status).toBe(200);
190
+
191
+ const xml = await res.text();
192
+ expect(xml).toContain("My Note");
193
+ expect(xml).not.toContain("My Link");
194
+ expect(xml).not.toContain("My Quote");
195
+ });
196
+
197
+ it("ignores invalid format query parameter", async () => {
198
+ const { app, services } = createFeedTestApp();
199
+
200
+ await services.posts.create({
201
+ format: "note",
202
+ title: "My Note",
203
+ body: "A note",
204
+ status: "published",
205
+ });
206
+ await services.posts.create({
207
+ format: "link",
208
+ title: "My Link",
209
+ url: "https://example.com",
210
+ status: "published",
211
+ });
212
+
213
+ const res = await app.request("/feed/all?format=invalid");
214
+ expect(res.status).toBe(200);
215
+
216
+ const xml = await res.text();
217
+ // Invalid format is ignored — all posts returned
218
+ expect(xml).toContain("My Note");
219
+ expect(xml).toContain("My Link");
220
+ });
221
+
222
+ it("returns RSS content type", async () => {
223
+ const { app } = createFeedTestApp();
224
+
225
+ const res = await app.request("/feed/all");
226
+ expect(res.headers.get("Content-Type")).toBe(
227
+ "application/rss+xml; charset=utf-8",
228
+ );
229
+ });
230
+ });
231
+
232
+ describe("/feed/all/atom.xml — all published posts (Atom)", () => {
233
+ it("returns all published posts in Atom format", async () => {
234
+ const { app, services } = createFeedTestApp();
235
+
236
+ await services.posts.create({
237
+ format: "note",
238
+ title: "Regular Post",
239
+ body: "Not featured",
240
+ status: "published",
241
+ });
242
+ await services.posts.create({
243
+ format: "note",
244
+ title: "Featured Post",
245
+ body: "This is featured",
246
+ status: "published",
247
+ visibility: "featured",
248
+ });
249
+
250
+ const res = await app.request("/feed/all/atom.xml");
251
+ expect(res.status).toBe(200);
252
+ expect(res.headers.get("Content-Type")).toBe(
253
+ "application/atom+xml; charset=utf-8",
254
+ );
255
+
256
+ const xml = await res.text();
257
+ expect(xml).toContain("Regular Post");
258
+ expect(xml).toContain("Featured Post");
259
+ });
260
+
261
+ it("supports format filtering", async () => {
262
+ const { app, services } = createFeedTestApp();
263
+
264
+ await services.posts.create({
265
+ format: "note",
266
+ title: "My Note",
267
+ body: "A note",
268
+ status: "published",
269
+ });
270
+ await services.posts.create({
271
+ format: "link",
272
+ title: "My Link",
273
+ url: "https://example.com",
274
+ status: "published",
275
+ });
276
+
277
+ const res = await app.request("/feed/all/atom.xml?format=link");
278
+ expect(res.status).toBe(200);
279
+
280
+ const xml = await res.text();
281
+ expect(xml).not.toContain("My Note");
282
+ expect(xml).toContain("My Link");
283
+ });
284
+ });
285
+
49
286
  describe("RSS_FEED_LIMIT env var", () => {
50
287
  it("defaults to 50 when RSS_FEED_LIMIT is not set", async () => {
51
288
  const { app, services } = createFeedTestApp();
52
289
 
53
- // Create 3 posts
290
+ // Create 3 featured posts
54
291
  for (let i = 0; i < 3; i++) {
55
292
  await services.posts.create({
56
293
  format: "note",
57
294
  title: `Post ${i}`,
58
295
  body: `Body ${i}`,
59
296
  status: "published",
297
+ visibility: "featured",
60
298
  });
61
299
  }
62
300
 
@@ -75,7 +313,7 @@ describe("RSS Feed Routes", () => {
75
313
  RSS_FEED_LIMIT: "2",
76
314
  });
77
315
 
78
- // Create 5 posts
316
+ // Create 5 posts on /feed/all
79
317
  for (let i = 0; i < 5; i++) {
80
318
  await services.posts.create({
81
319
  format: "note",
@@ -85,7 +323,7 @@ describe("RSS Feed Routes", () => {
85
323
  });
86
324
  }
87
325
 
88
- const res = await app.request("/feed");
326
+ const res = await app.request("/feed/all");
89
327
  expect(res.status).toBe(200);
90
328
 
91
329
  const xml = await res.text();
@@ -103,7 +341,7 @@ describe("RSS Feed Routes", () => {
103
341
  RSS_FEED_LIMIT: "not-a-number",
104
342
  });
105
343
 
106
- // Create 2 posts
344
+ // Create 2 posts on /feed/all
107
345
  for (let i = 0; i < 2; i++) {
108
346
  await services.posts.create({
109
347
  format: "note",
@@ -113,7 +351,7 @@ describe("RSS Feed Routes", () => {
113
351
  });
114
352
  }
115
353
 
116
- const res = await app.request("/feed");
354
+ const res = await app.request("/feed/all");
117
355
  expect(res.status).toBe(200);
118
356
 
119
357
  const xml = await res.text();
@@ -133,6 +371,7 @@ describe("RSS Feed Routes", () => {
133
371
  title: `Post ${i}`,
134
372
  body: `Body ${i}`,
135
373
  status: "published",
374
+ visibility: "featured",
136
375
  });
137
376
  }
138
377
 
@@ -1,13 +1,19 @@
1
1
  /**
2
2
  * RSS Feed Routes
3
+ *
4
+ * Three-level hierarchy:
5
+ * - /feed — featured posts only (curated feed for subscribers)
6
+ * - /feed/all — all published posts (with optional ?format= filter)
7
+ * - /c/{slug}/feed — per-collection feed (handled in collection routes)
3
8
  */
4
9
 
5
10
  import { Hono } from "hono";
6
11
  import type { Context } from "hono";
7
- import type { Bindings, FeedData } from "../../types.js";
12
+ import type { Bindings, FeedData, Format } from "../../types.js";
8
13
  import type { AppVariables } from "../../types/app-context.js";
9
14
  import { defaultRssRenderer, defaultAtomRenderer } from "../../lib/feed.js";
10
15
  import { buildMediaMap } from "../../lib/media-helpers.js";
16
+ import { FORMATS } from "../../types/constants.js";
11
17
 
12
18
  import { createMediaContext, toPostViews } from "../../lib/view.js";
13
19
 
@@ -15,10 +21,23 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
15
21
 
16
22
  export const rssRoutes = new Hono<Env>();
17
23
 
24
+ interface FeedOptions {
25
+ visibility?: "featured";
26
+ excludeUnlisted?: boolean;
27
+ format?: Format;
28
+ }
29
+
18
30
  /**
19
31
  * Build FeedData from the Hono context.
32
+ *
33
+ * @param c - Hono context
34
+ * @param opts - Filter options for the feed
35
+ * @returns Feed data ready for rendering
20
36
  */
21
- async function buildFeedData(c: Context<Env>): Promise<FeedData> {
37
+ async function buildFeedData(
38
+ c: Context<Env>,
39
+ opts?: FeedOptions,
40
+ ): Promise<FeedData> {
22
41
  const { appConfig } = c.var;
23
42
  const siteName = appConfig.siteName;
24
43
  const siteDescription = appConfig.siteDescription;
@@ -29,6 +48,9 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
29
48
  const posts = await c.var.services.posts.list({
30
49
  status: "published",
31
50
  excludeReplies: true,
51
+ visibility: opts?.visibility,
52
+ excludeUnlisted: opts?.excludeUnlisted,
53
+ format: opts?.format,
32
54
  limit: feedLimit,
33
55
  });
34
56
 
@@ -61,9 +83,23 @@ async function buildFeedData(c: Context<Env>): Promise<FeedData> {
61
83
  };
62
84
  }
63
85
 
64
- // RSS 2.0 Feed - main feed at /feed
86
+ /**
87
+ * Parse and validate the `format` query parameter.
88
+ * Returns a valid Format or undefined if missing/invalid.
89
+ */
90
+ function parseFormatQuery(c: Context<Env>): Format | undefined {
91
+ const raw = c.req.query("format");
92
+ if (raw && (FORMATS as readonly string[]).includes(raw)) {
93
+ return raw as Format;
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ // --- Featured feed (curated) ---
99
+
100
+ // RSS 2.0 — /feed
65
101
  rssRoutes.get("/", async (c) => {
66
- const feedData = await buildFeedData(c);
102
+ const feedData = await buildFeedData(c, { visibility: "featured" });
67
103
  const xml = defaultRssRenderer(feedData);
68
104
 
69
105
  return new Response(xml, {
@@ -73,9 +109,37 @@ rssRoutes.get("/", async (c) => {
73
109
  });
74
110
  });
75
111
 
76
- // Atom Feed
112
+ // Atom — /feed/atom.xml
77
113
  rssRoutes.get("/atom.xml", async (c) => {
78
- const feedData = await buildFeedData(c);
114
+ const feedData = await buildFeedData(c, { visibility: "featured" });
115
+ const xml = defaultAtomRenderer(feedData);
116
+
117
+ return new Response(xml, {
118
+ headers: {
119
+ "Content-Type": "application/atom+xml; charset=utf-8",
120
+ },
121
+ });
122
+ });
123
+
124
+ // --- All posts feed ---
125
+
126
+ // RSS 2.0 — /feed/all
127
+ rssRoutes.get("/all", async (c) => {
128
+ const format = parseFormatQuery(c);
129
+ const feedData = await buildFeedData(c, { excludeUnlisted: true, format });
130
+ const xml = defaultRssRenderer(feedData);
131
+
132
+ return new Response(xml, {
133
+ headers: {
134
+ "Content-Type": "application/rss+xml; charset=utf-8",
135
+ },
136
+ });
137
+ });
138
+
139
+ // Atom — /feed/all/atom.xml
140
+ rssRoutes.get("/all/atom.xml", async (c) => {
141
+ const format = parseFormatQuery(c);
142
+ const feedData = await buildFeedData(c, { excludeUnlisted: true, format });
79
143
  const xml = defaultAtomRenderer(feedData);
80
144
 
81
145
  return new Response(xml, {
@@ -26,24 +26,23 @@ describe("Featured Page - Data Logic", () => {
26
26
  await postService.create({
27
27
  format: "note",
28
28
  body: "Featured post",
29
- featured: true,
29
+ visibility: "featured",
30
30
  status: "published",
31
31
  });
32
32
  await postService.create({
33
33
  format: "note",
34
34
  body: "Normal post",
35
- featured: false,
36
35
  status: "published",
37
36
  });
38
37
  await postService.create({
39
38
  format: "note",
40
39
  body: "Draft featured",
41
- featured: true,
40
+ visibility: "featured",
42
41
  status: "draft",
43
42
  });
44
43
 
45
44
  const posts = await postService.list({
46
- featured: true,
45
+ visibility: "featured",
47
46
  status: "published",
48
47
  excludeReplies: true,
49
48
  });
@@ -60,7 +59,7 @@ describe("Featured Page - Data Logic", () => {
60
59
  });
61
60
 
62
61
  const posts = await postService.list({
63
- featured: true,
62
+ visibility: "featured",
64
63
  status: "published",
65
64
  excludeReplies: true,
66
65
  });
@@ -72,7 +71,7 @@ describe("Featured Page - Data Logic", () => {
72
71
  const root = await postService.create({
73
72
  format: "note",
74
73
  body: "Featured root",
75
- featured: true,
74
+ visibility: "featured",
76
75
  status: "published",
77
76
  });
78
77
 
@@ -84,7 +83,7 @@ describe("Featured Page - Data Logic", () => {
84
83
  });
85
84
 
86
85
  const posts = await postService.list({
87
- featured: true,
86
+ visibility: "featured",
88
87
  status: "published",
89
88
  excludeReplies: true,
90
89
  });
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Archive Page Route
3
3
  *
4
- * Shows all posts, optionally filtered by format or featured status
4
+ * Shows all posts, optionally filtered by format or visibility
5
5
  */
6
6
 
7
7
  import { Hono } from "hono";
8
- import type { Bindings, Format } from "../../types.js";
8
+ import type { Bindings, Format, Visibility } from "../../types.js";
9
9
  import type { AppVariables } from "../../types/app-context.js";
10
- import { FORMATS } from "../../types.js";
10
+ import { FORMATS, VISIBILITIES } from "../../types.js";
11
11
  import { ArchivePage } from "../../ui/pages/ArchivePage.js";
12
12
  import { getNavigationData } from "../../lib/navigation.js";
13
13
  import { renderPublicPage } from "../../lib/render.js";
@@ -24,8 +24,12 @@ archiveRoutes.get("/", async (c) => {
24
24
  const formatParam = c.req.query("format") as Format | undefined;
25
25
  const format =
26
26
  formatParam && FORMATS.includes(formatParam) ? formatParam : undefined;
27
- const featuredParam = c.req.query("featured");
28
- const featured = featuredParam === "true" ? true : undefined;
27
+ const visibilityParam = c.req.query("visibility") as Visibility | undefined;
28
+ const visibility =
29
+ visibilityParam &&
30
+ (VISIBILITIES as readonly string[]).includes(visibilityParam)
31
+ ? visibilityParam
32
+ : undefined;
29
33
 
30
34
  // Parse cursor
31
35
  const cursorParam = c.req.query("cursor");
@@ -37,7 +41,7 @@ archiveRoutes.get("/", async (c) => {
37
41
  const posts = await c.var.services.posts.list({
38
42
  format,
39
43
  status: "published",
40
- featured,
44
+ visibility,
41
45
  excludeReplies: true,
42
46
  cursor,
43
47
  limit: PAGE_SIZE + 1,
@@ -78,7 +82,7 @@ archiveRoutes.get("/", async (c) => {
78
82
  hasMore={hasMore}
79
83
  nextCursor={nextCursor}
80
84
  format={format}
81
- featured={featured}
85
+ visibility={visibility}
82
86
  />
83
87
  ),
84
88
  });
@@ -8,11 +8,7 @@ import type { AppVariables } from "../../types/app-context.js";
8
8
  import { CollectionPage } from "../../ui/pages/CollectionPage.js";
9
9
  import { getNavigationData } from "../../lib/navigation.js";
10
10
  import { renderPublicPage } from "../../lib/render.js";
11
- import {
12
- createMediaContext,
13
- toPostViewsFromPosts,
14
- toPostViews,
15
- } from "../../lib/view.js";
11
+ import { createMediaContext, toPostViews } from "../../lib/view.js";
16
12
  import { defaultRssRenderer } from "../../lib/feed.js";
17
13
  import { buildMediaMap } from "../../lib/media-helpers.js";
18
14
  import { CollectionsSidebar } from "../../ui/shared/CollectionsSidebar.js";
@@ -27,35 +23,56 @@ collectionRoutes.get("/:slug", async (c) => {
27
23
  const collection = await c.var.services.collections.getBySlug(slug);
28
24
  if (!collection) return c.notFound();
29
25
 
30
- // Fetch posts and all collections in parallel
31
- const [posts, allCollections] = await Promise.all([
26
+ // Fetch posts, all collections, dividers, and post counts in parallel
27
+ const [posts, allCollections, dividers, postCounts] = await Promise.all([
32
28
  c.var.services.posts.list({
33
29
  collectionId: collection.id,
34
30
  status: "published",
35
31
  excludeReplies: true,
36
32
  }),
37
33
  c.var.services.collections.list(),
34
+ c.var.services.collections.listDividers(),
35
+ c.var.services.collections.getPostCounts(),
38
36
  ]);
39
37
 
40
38
  const navData = await getNavigationData(c);
41
39
 
42
- // Transform to View Models
40
+ // Batch-load media for posts
41
+ const postIds = posts.map((p) => p.id);
42
+ const rawMediaMap = await c.var.services.media.getByPostIds(postIds);
43
43
  const mediaCtx = createMediaContext(c.var.appConfig);
44
- const postViews = toPostViewsFromPosts(posts, mediaCtx);
44
+ const mediaMap = buildMediaMap(
45
+ rawMediaMap,
46
+ mediaCtx.r2PublicUrl,
47
+ mediaCtx.imageTransformUrl,
48
+ mediaCtx.s3PublicUrl,
49
+ );
50
+
51
+ const postViews = toPostViews(
52
+ posts.map((p) => ({
53
+ ...p,
54
+ mediaAttachments: mediaMap.get(p.id) ?? [],
55
+ })),
56
+ mediaCtx,
57
+ );
58
+
59
+ const items = postViews.map((post) => ({ post }));
45
60
 
46
61
  return renderPublicPage(c, {
47
62
  title: `${collection.title} - ${navData.siteName}`,
48
63
  description: collection.description ?? undefined,
49
64
  navData,
50
65
  sidebar: (
51
- <CollectionsSidebar collections={allCollections} activeSlug={slug} />
66
+ <CollectionsSidebar
67
+ collections={allCollections}
68
+ dividers={dividers}
69
+ activeSlug={slug}
70
+ isAuthenticated={navData.isAuthenticated}
71
+ postCounts={postCounts}
72
+ />
52
73
  ),
53
74
  content: (
54
- <CollectionPage
55
- collection={collection}
56
- posts={postViews}
57
- hasMore={false}
58
- />
75
+ <CollectionPage collection={collection} items={items} hasMore={false} />
59
76
  ),
60
77
  });
61
78
  });
@@ -17,8 +17,9 @@ type Env = { Bindings: Bindings; Variables: AppVariables };
17
17
  export const collectionsPageRoutes = new Hono<Env>();
18
18
 
19
19
  collectionsPageRoutes.get("/", async (c) => {
20
- const [allCollections, postCounts] = await Promise.all([
20
+ const [allCollections, dividers, postCounts] = await Promise.all([
21
21
  c.var.services.collections.list(),
22
+ c.var.services.collections.listDividers(),
22
23
  c.var.services.collections.getPostCounts(),
23
24
  ]);
24
25
 
@@ -32,7 +33,15 @@ collectionsPageRoutes.get("/", async (c) => {
32
33
  return renderPublicPage(c, {
33
34
  title: `Collections - ${navData.siteName}`,
34
35
  navData,
35
- sidebar: <CollectionsSidebar collections={allCollections} />,
36
+ sidebar: (
37
+ <CollectionsSidebar
38
+ collections={allCollections}
39
+ dividers={dividers}
40
+ activeSlug={undefined}
41
+ isAuthenticated={navData.isAuthenticated}
42
+ postCounts={postCounts}
43
+ />
44
+ ),
36
45
  content: <CollectionsPage collections={collections} />,
37
46
  });
38
47
  });
@@ -25,7 +25,7 @@ featuredRoutes.get("/", async (c) => {
25
25
  }
26
26
 
27
27
  const posts = await c.var.services.posts.list({
28
- featured: true,
28
+ visibility: "featured",
29
29
  status: "published",
30
30
  excludeReplies: true,
31
31
  });
@@ -28,7 +28,7 @@ homeRoutes.get("/", async (c) => {
28
28
  if (navData.homeDefaultView === "featured") {
29
29
  // Show featured posts on homepage
30
30
  const posts = await c.var.services.posts.list({
31
- featured: true,
31
+ visibility: "featured",
32
32
  status: "published",
33
33
  excludeReplies: true,
34
34
  });