@jant/core 0.3.35 → 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 +3026 -2778
  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 +7 -7
  93. package/src/routes/feed/rss.ts +8 -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
@@ -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
  });
@@ -22,28 +22,51 @@ describe("PostService", () => {
22
22
 
23
23
  describe("create", () => {
24
24
  it("creates a note post with required fields", async () => {
25
+ const body = JSON.stringify({
26
+ type: "doc",
27
+ content: [
28
+ {
29
+ type: "paragraph",
30
+ content: [{ type: "text", text: "Hello world" }],
31
+ },
32
+ ],
33
+ });
25
34
  const post = await postService.create({
26
35
  format: "note",
27
- body: "Hello world",
36
+ body,
28
37
  });
29
38
 
30
39
  expect(post.id).toBe(1);
31
40
  expect(post.format).toBe("note");
32
- expect(post.body).toBe("Hello world");
41
+ expect(post.body).toBe(body);
33
42
  expect(post.status).toBe("published"); // default
34
- expect(post.featured).toBe(0);
43
+ expect(post.visibility).toBe("listed");
35
44
  expect(post.pinned).toBe(0);
36
45
  expect(post.bodyHtml).toContain("<p>Hello world</p>");
37
46
  expect(post.deletedAt).toBeNull();
38
47
  });
39
48
 
40
49
  it("creates a post with all fields", async () => {
50
+ const body = JSON.stringify({
51
+ type: "doc",
52
+ content: [
53
+ {
54
+ type: "heading",
55
+ attrs: { level: 1 },
56
+ content: [{ type: "text", text: "Introduction" }],
57
+ },
58
+ {
59
+ type: "paragraph",
60
+ content: [{ type: "text", text: "Some content." }],
61
+ },
62
+ ],
63
+ });
41
64
  const post = await postService.create({
42
65
  format: "link",
43
66
  title: "My Link",
44
- body: "# Introduction\n\nSome content.",
67
+ body,
45
68
  status: "published",
46
- featured: true,
69
+ visibility: "featured",
47
70
  pinned: true,
48
71
  path: "my-link",
49
72
  url: "https://example.com/source",
@@ -54,7 +77,7 @@ describe("PostService", () => {
54
77
  expect(post.format).toBe("link");
55
78
  expect(post.title).toBe("My Link");
56
79
  expect(post.status).toBe("published");
57
- expect(post.featured).toBe(1);
80
+ expect(post.visibility).toBe("featured");
58
81
  expect(post.pinned).toBe(1);
59
82
  expect(post.path).toBe("my-link");
60
83
  expect(post.url).toBe("https://example.com/source");
@@ -63,10 +86,27 @@ describe("PostService", () => {
63
86
  expect(post.bodyHtml).toContain("<h1>");
64
87
  });
65
88
 
66
- it("renders markdown body to HTML", async () => {
89
+ it("renders Tiptap JSON body to HTML", async () => {
90
+ const body = JSON.stringify({
91
+ type: "doc",
92
+ content: [
93
+ {
94
+ type: "paragraph",
95
+ content: [
96
+ { type: "text", text: "This is " },
97
+ {
98
+ type: "text",
99
+ marks: [{ type: "bold" }],
100
+ text: "bold",
101
+ },
102
+ { type: "text", text: " text" },
103
+ ],
104
+ },
105
+ ],
106
+ });
67
107
  const post = await postService.create({
68
108
  format: "note",
69
- body: "This is **bold** text",
109
+ body,
70
110
  });
71
111
 
72
112
  expect(post.bodyHtml).toContain("<strong>bold</strong>");
@@ -267,26 +307,60 @@ describe("PostService", () => {
267
307
  expect(published[0]?.status).toBe("published");
268
308
  });
269
309
 
270
- it("filters by featured", async () => {
310
+ it("filters by visibility", async () => {
271
311
  await postService.create({
272
312
  format: "note",
273
313
  body: "featured post",
274
- featured: true,
314
+ visibility: "featured",
275
315
  });
276
316
  await postService.create({
277
317
  format: "note",
278
318
  body: "normal post",
279
319
  });
320
+ await postService.create({
321
+ format: "note",
322
+ body: "unlisted post",
323
+ visibility: "unlisted",
324
+ });
280
325
 
281
- const featured = await postService.list({ featured: true });
326
+ const featured = await postService.list({ visibility: "featured" });
282
327
  expect(featured).toHaveLength(1);
283
- expect(featured[0]?.featured).toBe(1);
328
+ expect(featured[0]?.visibility).toBe("featured");
284
329
  expect(featured[0]?.body).toBe("featured post");
285
330
 
286
- const notFeatured = await postService.list({ featured: false });
287
- expect(notFeatured).toHaveLength(1);
288
- expect(notFeatured[0]?.featured).toBe(0);
289
- expect(notFeatured[0]?.body).toBe("normal post");
331
+ const listed = await postService.list({ visibility: "listed" });
332
+ expect(listed).toHaveLength(1);
333
+ expect(listed[0]?.visibility).toBe("listed");
334
+ expect(listed[0]?.body).toBe("normal post");
335
+
336
+ const unlisted = await postService.list({ visibility: "unlisted" });
337
+ expect(unlisted).toHaveLength(1);
338
+ expect(unlisted[0]?.visibility).toBe("unlisted");
339
+ expect(unlisted[0]?.body).toBe("unlisted post");
340
+ });
341
+
342
+ it("excludes unlisted posts when requested", async () => {
343
+ await postService.create({
344
+ format: "note",
345
+ body: "listed post",
346
+ });
347
+ await postService.create({
348
+ format: "note",
349
+ body: "unlisted post",
350
+ visibility: "unlisted",
351
+ });
352
+ await postService.create({
353
+ format: "note",
354
+ body: "featured post",
355
+ visibility: "featured",
356
+ });
357
+
358
+ const posts = await postService.list({ excludeUnlisted: true });
359
+ expect(posts).toHaveLength(2);
360
+ expect(posts.map((p) => p.body).sort()).toEqual([
361
+ "featured post",
362
+ "listed post",
363
+ ]);
290
364
  });
291
365
 
292
366
  it("filters by pinned", async () => {
@@ -426,15 +500,15 @@ describe("PostService", () => {
426
500
  expect(count).toBe(1);
427
501
  });
428
502
 
429
- it("filters by featured", async () => {
503
+ it("filters by visibility", async () => {
430
504
  await postService.create({
431
505
  format: "note",
432
506
  body: "featured",
433
- featured: true,
507
+ visibility: "featured",
434
508
  });
435
509
  await postService.create({ format: "note", body: "normal" });
436
510
 
437
- const count = await postService.count({ featured: true });
511
+ const count = await postService.count({ visibility: "featured" });
438
512
  expect(count).toBe(1);
439
513
  });
440
514
 
@@ -470,15 +544,32 @@ describe("PostService", () => {
470
544
  it("updates post body", async () => {
471
545
  const post = await postService.create({
472
546
  format: "note",
473
- body: "original",
547
+ body: JSON.stringify({
548
+ type: "doc",
549
+ content: [
550
+ {
551
+ type: "paragraph",
552
+ content: [{ type: "text", text: "original" }],
553
+ },
554
+ ],
555
+ }),
556
+ });
557
+
558
+ const updatedBody = JSON.stringify({
559
+ type: "doc",
560
+ content: [
561
+ {
562
+ type: "paragraph",
563
+ content: [{ type: "text", text: "updated content" }],
564
+ },
565
+ ],
474
566
  });
475
-
476
567
  const updated = await postService.update(post.id, {
477
- body: "updated content",
568
+ body: updatedBody,
478
569
  });
479
570
 
480
571
  expect(updated).not.toBeNull();
481
- expect(updated?.body).toBe("updated content");
572
+ expect(updated?.body).toBe(updatedBody);
482
573
  expect(updated?.bodyHtml).toContain("updated content");
483
574
  });
484
575
 
@@ -547,19 +638,19 @@ describe("PostService", () => {
547
638
  expect(updated?.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt);
548
639
  });
549
640
 
550
- it("updates featured flag", async () => {
641
+ it("updates visibility", async () => {
551
642
  const post = await postService.create({
552
643
  format: "note",
553
644
  body: "test",
554
645
  });
555
646
 
556
- expect(post.featured).toBe(0);
647
+ expect(post.visibility).toBe("listed");
557
648
 
558
649
  const updated = await postService.update(post.id, {
559
- featured: true,
650
+ visibility: "featured",
560
651
  });
561
652
 
562
- expect(updated?.featured).toBe(1);
653
+ expect(updated?.visibility).toBe("featured");
563
654
  });
564
655
 
565
656
  it("updates pinned flag", async () => {
@@ -723,11 +814,11 @@ describe("PostService", () => {
723
814
  expect(reply.status).toBe("draft");
724
815
  });
725
816
 
726
- it("inherits featured from root post", async () => {
817
+ it("inherits visibility from root post", async () => {
727
818
  const root = await postService.create({
728
819
  format: "note",
729
820
  body: "root",
730
- featured: true,
821
+ visibility: "featured",
731
822
  });
732
823
  const reply = await postService.create({
733
824
  format: "note",
@@ -735,7 +826,7 @@ describe("PostService", () => {
735
826
  replyToId: root.id,
736
827
  });
737
828
 
738
- expect(reply.featured).toBe(1);
829
+ expect(reply.visibility).toBe("featured");
739
830
  });
740
831
 
741
832
  it("getThread returns all posts in a thread", async () => {
@@ -797,7 +888,7 @@ describe("PostService", () => {
797
888
  }
798
889
  });
799
890
 
800
- it("cascades featured changes from root to thread", async () => {
891
+ it("cascades visibility changes from root to thread", async () => {
801
892
  const root = await postService.create({
802
893
  format: "note",
803
894
  body: "root",
@@ -808,11 +899,11 @@ describe("PostService", () => {
808
899
  replyToId: root.id,
809
900
  });
810
901
 
811
- await postService.update(root.id, { featured: true });
902
+ await postService.update(root.id, { visibility: "featured" });
812
903
 
813
904
  const thread = await postService.getThread(root.id);
814
905
  for (const post of thread) {
815
- expect(post.featured).toBe(1);
906
+ expect(post.visibility).toBe("featured");
816
907
  }
817
908
  });
818
909
  });
@@ -102,7 +102,7 @@ describe("SettingsService", () => {
102
102
  siteFooter: "",
103
103
  siteLanguage: "en",
104
104
  homeDefaultView: "latest",
105
- headerNavMaxVisible: "3",
105
+ headerNavMaxVisible: "2",
106
106
  timeZone: "UTC",
107
107
  };
108
108
 
@@ -154,10 +154,10 @@ describe("SettingsService", () => {
154
154
  expect(await settingsService.get("HOME_DEFAULT_VIEW")).toBe("featured");
155
155
  });
156
156
 
157
- it("removes HEADER_NAV_MAX_VISIBLE when set to default (3)", async () => {
157
+ it("removes HEADER_NAV_MAX_VISIBLE when set to default (2)", async () => {
158
158
  await settingsService.set("HEADER_NAV_MAX_VISIBLE", "5");
159
159
  await settingsService.updateGeneral(
160
- { ...defaults, headerNavMaxVisible: "3" },
160
+ { ...defaults, headerNavMaxVisible: "2" },
161
161
  { oldLanguage: "en", fallbackSiteName: "Jant" },
162
162
  );
163
163
 
@@ -27,10 +27,23 @@ export interface PageService {
27
27
  delete(id: number): Promise<boolean>;
28
28
  }
29
29
 
30
- /** Check if an error is a SQLite UNIQUE constraint violation (D1 or better-sqlite3) */
30
+ /** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
31
31
  function isUniqueConstraintError(err: unknown): boolean {
32
- const msg = String(err);
33
- return msg.includes("UNIQUE constraint") || msg.includes("SQLITE_CONSTRAINT");
32
+ let current: unknown = err;
33
+ while (current) {
34
+ const msg = String(current);
35
+ if (
36
+ msg.includes("UNIQUE constraint") ||
37
+ msg.includes("SQLITE_CONSTRAINT")
38
+ ) {
39
+ return true;
40
+ }
41
+ current =
42
+ current instanceof Error && current.cause !== current
43
+ ? current.cause
44
+ : undefined;
45
+ }
46
+ return false;
34
47
  }
35
48
 
36
49
  export function createPageService(