@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
@@ -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(
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * CRUD operations for posts with Thread support.
5
5
  * Posts have format (note/link/quote), status (draft/published),
6
- * featured flag, and pinned flag.
6
+ * visibility (listed/featured/unlisted), and pinned flag.
7
7
  */
8
8
 
9
9
  import { eq, and, isNull, desc, or, inArray, sql } from "drizzle-orm";
@@ -11,10 +11,18 @@ import type { BatchItem } from "drizzle-orm/batch";
11
11
  import type { Database } from "../db/index.js";
12
12
  import { posts, postCollections } from "../db/schema.js";
13
13
  import { now } from "../lib/time.js";
14
- import { render as renderMarkdown } from "../lib/markdown.js";
14
+ import { renderTiptapJson } from "../lib/tiptap-render.js";
15
+ import { extractSummary } from "../lib/summary.js";
15
16
  import type { StorageDriver } from "../lib/storage.js";
16
17
  import type { MediaService } from "./media.js";
17
- import type { Format, Status, Post, CreatePost, UpdatePost } from "../types.js";
18
+ import type {
19
+ Format,
20
+ Status,
21
+ Visibility,
22
+ Post,
23
+ CreatePost,
24
+ UpdatePost,
25
+ } from "../types.js";
18
26
  import type { PathRegistryService } from "./path-registry.js";
19
27
  import { ConflictError } from "../lib/errors.js";
20
28
 
@@ -27,11 +35,13 @@ export interface PostDeleteDeps {
27
35
  export interface PostFilters {
28
36
  format?: Format;
29
37
  status?: Status;
30
- featured?: boolean;
38
+ visibility?: Visibility;
31
39
  pinned?: boolean;
32
40
  collectionId?: number;
33
41
  /** Exclude posts that are replies (have threadId set) */
34
42
  excludeReplies?: boolean;
43
+ /** Exclude unlisted posts from results */
44
+ excludeUnlisted?: boolean;
35
45
  includeDeleted?: boolean;
36
46
  threadId?: number;
37
47
  limit?: number;
@@ -39,14 +49,24 @@ export interface PostFilters {
39
49
  offset?: number; // offset for page-based pagination
40
50
  }
41
51
 
52
+ /** Config for automatic summary extraction */
53
+ export interface SummaryConfig {
54
+ maxParagraphs: number;
55
+ maxChars: number;
56
+ }
57
+
42
58
  export interface PostService {
43
59
  getById(id: number): Promise<Post | null>;
44
60
  getByPath(path: string): Promise<Post | null>;
45
61
  list(filters?: PostFilters): Promise<Post[]>;
46
62
  /** Count posts matching filters (ignores cursor, offset, limit) */
47
63
  count(filters?: PostFilters): Promise<number>;
48
- create(data: CreatePost): Promise<Post>;
49
- update(id: number, data: UpdatePost): Promise<Post | null>;
64
+ create(data: CreatePost, summaryConfig?: SummaryConfig): Promise<Post>;
65
+ update(
66
+ id: number,
67
+ data: UpdatePost,
68
+ summaryConfig?: SummaryConfig,
69
+ ): Promise<Post | null>;
50
70
  /**
51
71
  * Soft-delete a post and clean up its media (storage files + DB records).
52
72
  * Thread roots cascade to all replies.
@@ -56,10 +76,10 @@ export interface PostService {
56
76
  */
57
77
  delete(id: number, deps?: PostDeleteDeps): Promise<boolean>;
58
78
  getThread(rootId: number): Promise<Post[]>;
59
- updateThreadStatusAndFeatured(
79
+ updateThreadStatusAndVisibility(
60
80
  rootId: number,
61
81
  status: Status,
62
- featured: boolean,
82
+ visibility: Visibility,
63
83
  ): Promise<void>;
64
84
  /** Get reply counts for multiple posts */
65
85
  getReplyCounts(postIds: number[]): Promise<Map<number, number>>;
@@ -70,10 +90,23 @@ export interface PostService {
70
90
  ): Promise<Map<number, Post[]>>;
71
91
  }
72
92
 
73
- /** Check if an error is a SQLite UNIQUE constraint violation (D1 or better-sqlite3) */
93
+ /** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
74
94
  function isUniqueConstraintError(err: unknown): boolean {
75
- const msg = String(err);
76
- return msg.includes("UNIQUE constraint") || msg.includes("SQLITE_CONSTRAINT");
95
+ let current: unknown = err;
96
+ while (current) {
97
+ const msg = String(current);
98
+ if (
99
+ msg.includes("UNIQUE constraint") ||
100
+ msg.includes("SQLITE_CONSTRAINT")
101
+ ) {
102
+ return true;
103
+ }
104
+ current =
105
+ current instanceof Error && current.cause !== current
106
+ ? current.cause
107
+ : undefined;
108
+ }
109
+ return false;
77
110
  }
78
111
 
79
112
  export function createPostService(
@@ -87,8 +120,11 @@ export function createPostService(
87
120
  if (filters.status) {
88
121
  conditions.push(eq(posts.status, filters.status));
89
122
  }
90
- if (filters.featured !== undefined) {
91
- conditions.push(eq(posts.featured, filters.featured ? 1 : 0));
123
+ if (filters.visibility !== undefined) {
124
+ conditions.push(eq(posts.visibility, filters.visibility));
125
+ }
126
+ if (filters.excludeUnlisted) {
127
+ conditions.push(sql`${posts.visibility} != 'unlisted'`);
92
128
  }
93
129
  if (filters.pinned !== undefined) {
94
130
  conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
@@ -120,7 +156,7 @@ export function createPostService(
120
156
  id: row.id,
121
157
  format: row.format as Format,
122
158
  status: row.status as Status,
123
- featured: row.featured,
159
+ visibility: row.visibility as Visibility,
124
160
  pinned: row.pinned,
125
161
  path: row.path,
126
162
  title: row.title,
@@ -128,6 +164,7 @@ export function createPostService(
128
164
  body: row.body,
129
165
  bodyHtml: row.bodyHtml,
130
166
  quoteText: row.quoteText,
167
+ summary: row.summary,
131
168
  rating: row.rating,
132
169
  replyToId: row.replyToId,
133
170
  threadId: row.threadId,
@@ -190,27 +227,37 @@ export function createPostService(
190
227
  return result[0]?.count ?? 0;
191
228
  },
192
229
 
193
- async create(data) {
230
+ async create(data, summaryConfig) {
194
231
  const timestamp = now();
195
232
 
196
- const bodyHtml = data.body ? renderMarkdown(data.body) : null;
233
+ const bodyHtml = data.body ? renderTiptapJson(data.body) : null;
234
+
235
+ // Generate summary for titled notes with body content
236
+ let summary: string | null = null;
237
+ if (data.format === "note" && data.title && data.body && summaryConfig) {
238
+ summary = extractSummary(
239
+ data.body,
240
+ summaryConfig.maxParagraphs,
241
+ summaryConfig.maxChars,
242
+ );
243
+ }
197
244
 
198
245
  // Handle thread relationship
199
246
  let threadId: number | null = null;
200
247
  let status: Status = data.status ?? "published";
201
- let featured = data.featured ?? false;
248
+ let visibility: Visibility = data.visibility ?? "listed";
202
249
 
203
250
  if (data.replyToId) {
204
251
  const parent = await this.getById(data.replyToId);
205
252
  if (parent) {
206
253
  threadId = parent.threadId ?? parent.id;
207
- // Inherit status and featured from root
254
+ // Inherit status and visibility from root
208
255
  const root = parent.threadId
209
256
  ? await this.getById(parent.threadId)
210
257
  : parent;
211
258
  if (root) {
212
259
  status = root.status as Status;
213
- featured = root.featured === 1;
260
+ visibility = root.visibility as Visibility;
214
261
  }
215
262
  }
216
263
  }
@@ -229,7 +276,7 @@ export function createPostService(
229
276
  .values({
230
277
  format: data.format,
231
278
  status,
232
- featured: featured ? 1 : 0,
279
+ visibility,
233
280
  pinned: data.pinned ? 1 : 0,
234
281
  path: data.path ?? null,
235
282
  title: data.title ?? null,
@@ -237,6 +284,7 @@ export function createPostService(
237
284
  body: data.body ?? null,
238
285
  bodyHtml,
239
286
  quoteText: data.quoteText ?? null,
287
+ summary,
240
288
  rating: data.rating ?? null,
241
289
  replyToId: data.replyToId ?? null,
242
290
  threadId,
@@ -275,7 +323,7 @@ export function createPostService(
275
323
  return post;
276
324
  },
277
325
 
278
- async update(id, data) {
326
+ async update(id, data, summaryConfig) {
279
327
  const existing = await this.getById(id);
280
328
  if (!existing) return null;
281
329
 
@@ -310,22 +358,38 @@ export function createPostService(
310
358
 
311
359
  if (data.body !== undefined) {
312
360
  updates.body = data.body;
313
- updates.bodyHtml = data.body ? renderMarkdown(data.body) : null;
361
+ updates.bodyHtml = data.body ? renderTiptapJson(data.body) : null;
362
+ }
363
+
364
+ // Recompute summary when body, title, or format change
365
+ if (summaryConfig) {
366
+ const format = data.format ?? (existing.format as Format);
367
+ const title = data.title !== undefined ? data.title : existing.title;
368
+ const body = data.body !== undefined ? data.body : existing.body;
369
+ if (format === "note" && title && body) {
370
+ updates.summary = extractSummary(
371
+ body,
372
+ summaryConfig.maxParagraphs,
373
+ summaryConfig.maxChars,
374
+ );
375
+ } else {
376
+ updates.summary = null;
377
+ }
314
378
  }
315
379
 
316
- // Handle status/featured change - cascade to thread if this is root
380
+ // Handle status/visibility change - cascade to thread if this is root
317
381
  const statusChanged =
318
382
  data.status !== undefined && data.status !== existing.status;
319
- const featuredChanged =
320
- data.featured !== undefined &&
321
- (data.featured ? 1 : 0) !== existing.featured;
383
+ const visibilityChanged =
384
+ data.visibility !== undefined &&
385
+ data.visibility !== existing.visibility;
322
386
 
323
387
  if (statusChanged) updates.status = data.status;
324
- if (featuredChanged) updates.featured = data.featured ? 1 : 0;
388
+ if (visibilityChanged) updates.visibility = data.visibility;
325
389
 
326
390
  // Build all write queries for atomic execution via D1 batch
327
391
  const needsCascade =
328
- (statusChanged || featuredChanged) && !existing.threadId;
392
+ (statusChanged || visibilityChanged) && !existing.threadId;
329
393
  const needsCollectionSync = data.collectionIds !== undefined;
330
394
  const hasExtraWrites = needsCascade || needsCollectionSync;
331
395
 
@@ -348,13 +412,8 @@ export function createPostService(
348
412
  .update(posts)
349
413
  .set({
350
414
  status: data.status ?? (existing.status as Status),
351
- featured: (
352
- data.featured !== undefined
353
- ? data.featured
354
- : existing.featured === 1
355
- )
356
- ? 1
357
- : 0,
415
+ visibility:
416
+ data.visibility ?? (existing.visibility as Visibility),
358
417
  updatedAt: timestamp,
359
418
  })
360
419
  .where(eq(posts.threadId, id)),
@@ -467,11 +526,11 @@ export function createPostService(
467
526
  return rows.map(toPost);
468
527
  },
469
528
 
470
- async updateThreadStatusAndFeatured(rootId, status, featured) {
529
+ async updateThreadStatusAndVisibility(rootId, status, visibility) {
471
530
  const timestamp = now();
472
531
  await db
473
532
  .update(posts)
474
- .set({ status, featured: featured ? 1 : 0, updatedAt: timestamp })
533
+ .set({ status, visibility, updatedAt: timestamp })
475
534
  .where(eq(posts.threadId, rootId));
476
535
  },
477
536
 
@@ -27,7 +27,7 @@ interface RawSearchRow {
27
27
  id: number;
28
28
  format: string;
29
29
  status: string;
30
- featured: number;
30
+ visibility: string;
31
31
  pinned: number;
32
32
  path: string | null;
33
33
  title: string | null;
@@ -35,6 +35,7 @@ interface RawSearchRow {
35
35
  body: string | null;
36
36
  body_html: string | null;
37
37
  quote_text: string | null;
38
+ summary: string | null;
38
39
  rating: number | null;
39
40
  collection_id: number | null;
40
41
  reply_to_id: number | null;
@@ -97,7 +98,7 @@ export function createSearchService(d1: D1Database): SearchService {
97
98
  id: row.id,
98
99
  format: row.format as Post["format"],
99
100
  status: row.status as Post["status"],
100
- featured: row.featured,
101
+ visibility: row.visibility as Post["visibility"],
101
102
  pinned: row.pinned,
102
103
  path: row.path,
103
104
  title: row.title,
@@ -105,6 +106,7 @@ export function createSearchService(d1: D1Database): SearchService {
105
106
  body: row.body,
106
107
  bodyHtml: row.body_html,
107
108
  quoteText: row.quote_text,
109
+ summary: row.summary,
108
110
  rating: row.rating,
109
111
  replyToId: row.reply_to_id,
110
112
  threadId: row.thread_id,
@@ -44,6 +44,7 @@ export interface AvatarUploadDeps {
44
44
  media: MediaService;
45
45
  storage: StorageDriver;
46
46
  storageProvider: string;
47
+ maxFileSizeMB: number;
47
48
  }
48
49
 
49
50
  export interface SettingsService {
@@ -194,7 +195,7 @@ export function createSettingsService(db: Database): SettingsService {
194
195
  // Header nav max visible: only update if provided (may be managed separately)
195
196
  if (data.headerNavMaxVisible !== undefined) {
196
197
  const navMax = parseInt(String(data.headerNavMaxVisible), 10);
197
- if (!isNaN(navMax) && navMax !== 3) {
198
+ if (!isNaN(navMax) && navMax !== 2) {
198
199
  await this.set("HEADER_NAV_MAX_VISIBLE", String(navMax));
199
200
  } else {
200
201
  await this.remove("HEADER_NAV_MAX_VISIBLE");
@@ -215,7 +216,10 @@ export function createSettingsService(db: Database): SettingsService {
215
216
  },
216
217
 
217
218
  async uploadAvatar(data, deps) {
218
- const uploadError = validateUploadFile(data.file as unknown as File);
219
+ const uploadError = validateUploadFile(data.file as unknown as File, {
220
+ imagesOnly: true,
221
+ maxFileSizeMB: deps.maxFileSizeMB,
222
+ });
219
223
  if (uploadError) {
220
224
  throw new ValidationError(uploadError);
221
225
  }