@jant/core 0.6.15 → 0.6.16

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 (112) hide show
  1. package/bin/commands/import-site.js +7 -0
  2. package/dist/app-C75Zc8dD.js +6 -0
  3. package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
  4. package/dist/client/.vite/manifest.json +8 -8
  5. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  6. package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
  7. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  8. package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
  9. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  10. package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
  11. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  12. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  13. package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
  14. package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/node.js +6 -6
  17. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  18. package/package.json +1 -1
  19. package/src/__tests__/mise-config.test.ts +22 -0
  20. package/src/client/__tests__/compose-bridge.test.ts +252 -2
  21. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  22. package/src/client/__tests__/toast.test.ts +65 -1
  23. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
  28. package/src/client/components/compose-format-convert.ts +5 -1
  29. package/src/client/components/compose-types.ts +47 -3
  30. package/src/client/components/jant-command-palette.ts +12 -6
  31. package/src/client/components/jant-compose-dialog.ts +1464 -680
  32. package/src/client/components/jant-compose-editor.ts +225 -93
  33. package/src/client/components/jant-compose-fullscreen.ts +3 -5
  34. package/src/client/components/jant-post-menu.ts +91 -21
  35. package/src/client/compose-bridge.ts +143 -122
  36. package/src/client/compose-launch.ts +13 -0
  37. package/src/client/compose-shortcuts.ts +5 -1
  38. package/src/client/post-refresh.ts +135 -0
  39. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  40. package/src/client/tiptap/link-input-rules.ts +4 -4
  41. package/src/client/toast.ts +130 -38
  42. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  43. package/src/db/__tests__/thread-activity.test.ts +113 -0
  44. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  45. package/src/db/migrations/0031_many_ego.sql +3 -0
  46. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  47. package/src/db/migrations/meta/_journal.json +7 -0
  48. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  49. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  50. package/src/db/migrations/pg/meta/_journal.json +7 -0
  51. package/src/db/pg/schema.ts +18 -0
  52. package/src/db/schema.ts +20 -0
  53. package/src/db/thread-activity.ts +101 -0
  54. package/src/i18n/locales/public/en.po +89 -21
  55. package/src/i18n/locales/public/en.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hans.po +88 -20
  57. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/public/zh-Hant.po +88 -20
  59. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/feed.test.ts +168 -0
  61. package/src/lib/__tests__/markdown.test.ts +16 -0
  62. package/src/lib/__tests__/slug.test.ts +18 -0
  63. package/src/lib/__tests__/translit.test.ts +87 -0
  64. package/src/lib/__tests__/url.test.ts +53 -0
  65. package/src/lib/feed.ts +113 -11
  66. package/src/lib/hugo-markdown.ts +5 -0
  67. package/src/lib/link-preview.ts +25 -0
  68. package/src/lib/markdown-manager.ts +6 -1
  69. package/src/lib/post-display.ts +42 -9
  70. package/src/lib/timeline.ts +39 -9
  71. package/src/lib/tiptap-render.ts +4 -2
  72. package/src/lib/translit.ts +288 -0
  73. package/src/lib/url.ts +123 -12
  74. package/src/lib/view.ts +28 -0
  75. package/src/routes/api/__tests__/palette.test.ts +44 -0
  76. package/src/routes/api/posts.ts +13 -9
  77. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  78. package/src/routes/api/public/posts.ts +7 -0
  79. package/src/routes/compose.tsx +6 -2
  80. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  81. package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
  82. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  83. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  84. package/src/routes/pages/archive.tsx +65 -17
  85. package/src/routes/pages/page.tsx +5 -2
  86. package/src/services/__tests__/collection.test.ts +32 -1
  87. package/src/services/__tests__/post-timeline.test.ts +142 -11
  88. package/src/services/__tests__/post.test.ts +277 -0
  89. package/src/services/collection.ts +8 -10
  90. package/src/services/export-theme/assets/client-site.css +1 -1
  91. package/src/services/export.ts +3 -5
  92. package/src/services/path.ts +4 -1
  93. package/src/services/post.ts +297 -100
  94. package/src/services/search.ts +9 -0
  95. package/src/styles/components.css +0 -14
  96. package/src/styles/site-media.css +4 -4
  97. package/src/styles/tokens.css +62 -1
  98. package/src/styles/ui.css +1363 -1283
  99. package/src/types/entities.ts +5 -0
  100. package/src/types/props.ts +12 -0
  101. package/src/types/views.ts +6 -0
  102. package/src/ui/compose/ComposeDialog.tsx +17 -17
  103. package/src/ui/feed/LinkPreview.tsx +2 -7
  104. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  105. package/src/ui/pages/ArchivePage.tsx +131 -24
  106. package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
  107. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  108. package/src/ui/shared/PostFooter.tsx +35 -12
  109. package/src/ui/shared/custom-icons.ts +5 -0
  110. package/src/ui/shared/post-article-attributes.ts +6 -0
  111. package/dist/app-BxCOR3Uc.js +0 -6
  112. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
@@ -243,13 +243,13 @@ const labels: ComposeLabels = {
243
243
  publishDateInvalid: "Enter a valid date.",
244
244
  publishDateFutureError:
245
245
  "Choose today or an earlier date, or leave it blank to publish now.",
246
- publishDateSummaryNow: "Publish now",
246
+ publishDateSummaryNow: "Now",
247
247
  publishDateSummaryAction: "Edit publish date",
248
248
  publishSlugLabel: "Custom link",
249
249
  publishSlugPlaceholder: "your-post-link",
250
250
  publishSlugHint: "Leave blank to generate one automatically.",
251
251
  publishSlugAuto: "Generate automatically",
252
- publishSlugSummaryAuto: "Auto link",
252
+ publishSlugSummaryAuto: "Auto",
253
253
  publishSlugSummaryAction: "Edit custom link",
254
254
  publishSlugReset: "Reset link",
255
255
  publishSlugSuggested: "Suggested link",
@@ -265,9 +265,9 @@ const labels: ComposeLabels = {
265
265
  threadLimitReached: "Threads can include up to 20 posts.",
266
266
  showMore: "Show more",
267
267
  showLess: "Show less",
268
- newThread: "New Thread",
269
- replyTitle: "Reply",
270
- editTitle: "Edit",
268
+ closeCompose: "Close compose",
269
+ editing: "Editing",
270
+ composeDialogLabel: "Compose",
271
271
  slashHint: "Type / for commands",
272
272
  tableControls: {
273
273
  toolbarLabel: "Table controls",
@@ -308,10 +308,14 @@ const labels: ComposeLabels = {
308
308
 
309
309
  async function createElement(
310
310
  format: string = "note",
311
+ options: { titleByDefault?: boolean } = {},
311
312
  ): Promise<JantComposeEditor> {
312
313
  const el = document.createElement("jant-compose-editor") as JantComposeEditor;
313
314
  el.format = format as "note" | "link" | "quote";
314
315
  el.labels = labels;
316
+ if (options.titleByDefault !== undefined) {
317
+ el.titleByDefault = options.titleByDefault;
318
+ }
315
319
  document.body.appendChild(el);
316
320
  await el.updateComplete;
317
321
  return el;
@@ -795,13 +799,46 @@ describe("JantComposeEditor", () => {
795
799
  expect(el._attachedTexts[0].bodyJson).toBeNull();
796
800
  });
797
801
 
798
- it("shows title toggle only in note mode", async () => {
802
+ it("shows a note's title field by default on a post that starts a thread", async () => {
799
803
  const el = await createElement("note");
804
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
800
805
  expect(el.querySelector('.compose-tool-btn[title="Title"]')).not.toBeNull();
806
+ });
807
+
808
+ it("hides a note's title field by default on a continuation post", async () => {
809
+ const el = await createElement("note", { titleByDefault: false });
810
+ expect(el.querySelector(".compose-note-title")).toBeNull();
811
+ });
812
+
813
+ it("toggles the title field from the toolbar", async () => {
814
+ const el = await createElement("note", { titleByDefault: false });
815
+ const toggle = requireElement(
816
+ el.querySelector<HTMLButtonElement>('.compose-tool-btn[title="Title"]'),
817
+ "expected title toggle",
818
+ );
819
+
820
+ toggle.click();
821
+ await el.updateComplete;
822
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
823
+
824
+ toggle.click();
825
+ await el.updateComplete;
826
+ expect(el.querySelector(".compose-note-title")).toBeNull();
827
+ });
828
+
829
+ it("drops a hidden title from the submitted data", async () => {
830
+ const el = await createElement("note");
831
+ el._title = "Named";
832
+ el._showTitle = false;
833
+ await el.updateComplete;
834
+ expect(el.getData().title).toBe("");
835
+ });
801
836
 
802
- el.format = "link";
837
+ it("keeps the title field visible when a post arrives with one", async () => {
838
+ const el = await createElement("note", { titleByDefault: false });
839
+ el.populate({ format: "note", title: "Loaded title" });
803
840
  await el.updateComplete;
804
- expect(el.querySelector('.compose-tool-btn[title="Title"]')).toBeNull();
841
+ expect(el.querySelector(".compose-note-title")).not.toBeNull();
805
842
  });
806
843
 
807
844
  it.each([
@@ -812,7 +849,6 @@ describe("JantComposeEditor", () => {
812
849
  async (format, selector) => {
813
850
  const el = await createElement(format);
814
851
  if (format === "note") {
815
- el._showTitle = true;
816
852
  await el.updateComplete;
817
853
  }
818
854
 
@@ -841,7 +877,6 @@ describe("JantComposeEditor", () => {
841
877
 
842
878
  it("keeps focus in the title while an IME composition is active", async () => {
843
879
  const el = await createElement("note");
844
- el._showTitle = true;
845
880
  await el.updateComplete;
846
881
 
847
882
  const titleInput = requireElement(
@@ -864,7 +899,6 @@ describe("JantComposeEditor", () => {
864
899
 
865
900
  it("leaves Mod-Enter available for the publish shortcut", async () => {
866
901
  const el = await createElement("note");
867
- el._showTitle = true;
868
902
  await el.updateComplete;
869
903
 
870
904
  const titleInput = requireElement(
@@ -885,7 +919,7 @@ describe("JantComposeEditor", () => {
885
919
  expect(document.activeElement).toBe(titleInput);
886
920
  });
887
921
 
888
- it("keeps title after rate and places fullscreen at the far right of the toolbar", async () => {
922
+ it("places fullscreen at the far right of the toolbar", async () => {
889
923
  const el = await createElement("note");
890
924
  const toolTitles = [
891
925
  ...el.querySelectorAll<HTMLButtonElement>(".compose-tool-btn"),
@@ -924,7 +958,6 @@ describe("JantComposeEditor", () => {
924
958
  it("getData returns current field values", async () => {
925
959
  const el = await createElement("note");
926
960
  el._title = "Test Title";
927
- el._showTitle = true;
928
961
  el._bodyJson = {
929
962
  type: "doc",
930
963
  content: [
@@ -994,31 +1027,11 @@ describe("JantComposeEditor", () => {
994
1027
  expect(data.attachmentOrder).toEqual(["t2"]);
995
1028
  });
996
1029
 
997
- it("getData omits title when showTitle is off in note mode", async () => {
998
- const el = await createElement("note");
999
- el._title = "Hidden Title";
1000
- el._showTitle = false;
1001
-
1002
- const data = el.getData();
1003
- expect(data.title).toBe("");
1004
- });
1005
-
1006
- it("preserves title in memory when toggling off and restores on toggle on", async () => {
1030
+ it("getData returns the note title as typed", async () => {
1007
1031
  const el = await createElement("note");
1008
- el._title = "My Title";
1009
- el._showTitle = true;
1010
- await el.updateComplete;
1011
-
1012
- // Toggle off — title stays in memory
1013
- el._showTitle = false;
1014
- await el.updateComplete;
1015
- expect(el._title).toBe("My Title");
1016
- expect(el.getData().title).toBe("");
1032
+ el._title = "Kept Title";
1017
1033
 
1018
- // Toggle back on — title restored
1019
- el._showTitle = true;
1020
- await el.updateComplete;
1021
- expect(el.getData().title).toBe("My Title");
1034
+ expect(el.getData().title).toBe("Kept Title");
1022
1035
  });
1023
1036
 
1024
1037
  it("promotes a leading H1 to the note title", async () => {
@@ -1035,7 +1048,6 @@ describe("JantComposeEditor", () => {
1035
1048
  const body = JSON.parse(data.body) as JSONContent;
1036
1049
 
1037
1050
  expect(data.title).toBe("My Markdown Title");
1038
- expect(el._showTitle).toBe(true);
1039
1051
  expect(body.content?.[0]).toEqual(tiptapParagraph("Body"));
1040
1052
  });
1041
1053
 
@@ -1050,7 +1062,6 @@ describe("JantComposeEditor", () => {
1050
1062
  const body = JSON.parse(data.body) as JSONContent;
1051
1063
 
1052
1064
  expect(data.title).toBe("");
1053
- expect(el._showTitle).toBe(false);
1054
1065
  expect(body.content?.[0]).toEqual(tiptapHeading(1, "Draft title"));
1055
1066
  });
1056
1067
 
@@ -1058,7 +1069,6 @@ describe("JantComposeEditor", () => {
1058
1069
  const el = await createElement("note");
1059
1070
  const editor = requireEditor(el);
1060
1071
  el._title = "Manual title";
1061
- el._showTitle = true;
1062
1072
 
1063
1073
  editor.commands.setContent(
1064
1074
  tiptapDoc(tiptapHeading(1, "Markdown Title"), tiptapParagraph("Body")),
@@ -1231,6 +1241,7 @@ describe("JantComposeEditor", () => {
1231
1241
  alt: "",
1232
1242
  error: null,
1233
1243
  posterUrl: null,
1244
+ remoteUrl: null,
1234
1245
  summary: null,
1235
1246
  chars: null,
1236
1247
  },
@@ -1264,6 +1275,7 @@ describe("JantComposeEditor", () => {
1264
1275
  alt: "",
1265
1276
  error: null,
1266
1277
  posterUrl: null,
1278
+ remoteUrl: null,
1267
1279
  summary: null,
1268
1280
  chars: null,
1269
1281
  },
@@ -1277,6 +1289,7 @@ describe("JantComposeEditor", () => {
1277
1289
  alt: "",
1278
1290
  error: null,
1279
1291
  posterUrl: null,
1292
+ remoteUrl: null,
1280
1293
  summary: null,
1281
1294
  chars: null,
1282
1295
  },
@@ -1343,7 +1356,7 @@ describe("JantComposeEditor", () => {
1343
1356
  expect(el._attachmentOrder[0]).toBe("t1");
1344
1357
  });
1345
1358
 
1346
- it("pastes clipboard files into attachments when no visible title is set", async () => {
1359
+ it("pastes clipboard files into attachments when no title is set", async () => {
1347
1360
  const uploadWithMetadataMock = vi.mocked(uploadWithMetadata);
1348
1361
  const el = await createElement("note");
1349
1362
  const events: CustomEvent[] = [];
@@ -1379,7 +1392,6 @@ describe("JantComposeEditor", () => {
1379
1392
  id: "med_test",
1380
1393
  });
1381
1394
  const el = await createElement("note");
1382
- el._showTitle = true;
1383
1395
  el._title = "Essay";
1384
1396
  await el.updateComplete;
1385
1397
 
@@ -1399,30 +1411,9 @@ describe("JantComposeEditor", () => {
1399
1411
  ]);
1400
1412
  });
1401
1413
 
1402
- it("pastes images as attachments when the title row was toggled off after typing", async () => {
1403
- const uploadWithMetadataMock = vi.mocked(uploadWithMetadata);
1404
- const el = await createElement("note");
1405
- el._showTitle = true;
1406
- el._title = "Essay";
1407
- await el.updateComplete;
1408
- el._showTitle = false;
1409
- await el.updateComplete;
1410
-
1411
- const image = new File(["image"], "clipboard.png", { type: "image/png" });
1412
- const { handled } = triggerEditorPaste(el, [image]);
1413
- await el.updateComplete;
1414
-
1415
- expect(handled).toBe(true);
1416
- expect(uploadWithMetadataMock).not.toHaveBeenCalled();
1417
- expect(el._attachments.map((attachment) => attachment.file.name)).toEqual([
1418
- "clipboard.png",
1419
- ]);
1420
- });
1421
-
1422
1414
  it("pastes images as attachments when only whitespace is in the title", async () => {
1423
1415
  const uploadWithMetadataMock = vi.mocked(uploadWithMetadata);
1424
1416
  const el = await createElement("note");
1425
- el._showTitle = true;
1426
1417
  el._title = " ";
1427
1418
  await el.updateComplete;
1428
1419
 
@@ -14,14 +14,13 @@ const labels = {
14
14
  fullscreen: "Fullscreen",
15
15
  exitFullscreen: "Exit fullscreen",
16
16
  done: "Done",
17
- title: "Title",
18
17
  titlePlaceholder: "Title",
19
18
  bodyPlaceholder: "What's on your mind...",
20
19
  showMore: "Show more",
21
20
  showLess: "Show less",
22
- newThread: "New Thread",
23
- replyTitle: "Reply",
24
- editTitle: "Edit",
21
+ closeCompose: "Close compose",
22
+ editing: "Editing",
23
+ composeDialogLabel: "Compose",
25
24
  } as ComposeLabels;
26
25
 
27
26
  async function flush(el?: JantComposeFullscreen) {
@@ -58,7 +57,7 @@ describe("JantComposeFullscreen", () => {
58
57
  }
59
58
  });
60
59
 
61
- it("opens in body-first mode when the title is hidden", async () => {
60
+ it("shows the title field when the editor arrived with one open", async () => {
62
61
  const el = document.createElement(
63
62
  "jant-compose-fullscreen",
64
63
  ) as JantComposeFullscreen;
@@ -68,26 +67,39 @@ describe("JantComposeFullscreen", () => {
68
67
 
69
68
  document.dispatchEvent(
70
69
  new CustomEvent("jant:fullscreen-open", {
71
- detail: {
72
- json: null,
73
- title: "",
74
- showTitle: false,
75
- labels,
76
- },
70
+ detail: { json: null, title: "", showTitle: true, labels },
77
71
  }),
78
72
  );
79
73
  await flush(el);
80
74
 
81
- expect(el.querySelector(".compose-fullscreen-title")).toBeNull();
82
- expect(
83
- el.querySelector(".compose-fullscreen-title-placeholder"),
84
- ).not.toBeNull();
75
+ expect(el.querySelector(".compose-fullscreen-title")).not.toBeNull();
85
76
  expect(el.textContent).not.toContain("Fullscreen");
86
- expect(
87
- el.querySelector(
88
- ".compose-fullscreen-toolbar .compose-fullscreen-title-placeholder",
89
- ),
90
- ).toBeNull();
77
+ });
78
+
79
+ it("offers a way back to a title the editor had hidden", async () => {
80
+ const el = document.createElement(
81
+ "jant-compose-fullscreen",
82
+ ) as JantComposeFullscreen;
83
+ el.labels = labels;
84
+ document.body.appendChild(el);
85
+ await flush(el);
86
+
87
+ document.dispatchEvent(
88
+ new CustomEvent("jant:fullscreen-open", {
89
+ detail: { json: null, title: "", showTitle: false, labels },
90
+ }),
91
+ );
92
+ await flush(el);
93
+
94
+ expect(el.querySelector(".compose-fullscreen-title")).toBeNull();
95
+ const reveal = el.querySelector<HTMLButtonElement>(
96
+ ".compose-fullscreen-title-placeholder",
97
+ );
98
+ expect(reveal).not.toBeNull();
99
+
100
+ reveal?.click();
101
+ await flush(el);
102
+ expect(el.querySelector(".compose-fullscreen-title")).not.toBeNull();
91
103
  });
92
104
 
93
105
  it.each(["metaKey", "ctrlKey"] as const)(
@@ -105,7 +117,6 @@ describe("JantComposeFullscreen", () => {
105
117
  detail: {
106
118
  json: null,
107
119
  title: "Keyboard post",
108
- showTitle: true,
109
120
  labels,
110
121
  editorIndex: 2,
111
122
  },
@@ -173,7 +184,6 @@ describe("JantComposeFullscreen", () => {
173
184
  detail: {
174
185
  json: null,
175
186
  title: "",
176
- showTitle: false,
177
187
  labels,
178
188
  },
179
189
  }),
@@ -194,66 +204,6 @@ describe("JantComposeFullscreen", () => {
194
204
  expect(el.querySelector(".compose-fullscreen-dialog")).not.toBeNull();
195
205
  });
196
206
 
197
- it("reveals the title on demand and returns its visibility on close", async () => {
198
- const el = document.createElement(
199
- "jant-compose-fullscreen",
200
- ) as JantComposeFullscreen;
201
- el.labels = labels;
202
- document.body.appendChild(el);
203
- await flush(el);
204
-
205
- document.dispatchEvent(
206
- new CustomEvent("jant:fullscreen-open", {
207
- detail: {
208
- json: null,
209
- title: "",
210
- showTitle: false,
211
- labels,
212
- },
213
- }),
214
- );
215
- await flush(el);
216
-
217
- const toggle = el.querySelector<HTMLButtonElement>(
218
- ".compose-fullscreen-title-placeholder",
219
- );
220
- expect(toggle).not.toBeNull();
221
- toggle?.click();
222
- await flush(el);
223
-
224
- const input = el.querySelector<HTMLInputElement>(
225
- ".compose-fullscreen-title",
226
- );
227
- expect(input).not.toBeNull();
228
- if (!input) {
229
- throw new Error("expected fullscreen title input");
230
- }
231
- input.value = "Desk Notes";
232
- input.dispatchEvent(new Event("input"));
233
-
234
- let detail: ComposeFullscreenCloseDetail | null = null;
235
- document.addEventListener(
236
- "jant:fullscreen-close",
237
- (event) => {
238
- detail = (event as CustomEvent<ComposeFullscreenCloseDetail>).detail;
239
- },
240
- { once: true },
241
- );
242
-
243
- el.querySelector<HTMLButtonElement>(".compose-fullscreen-done")?.click();
244
-
245
- expect(detail).toMatchObject({
246
- json: {
247
- type: "doc",
248
- content: [{ type: "paragraph" }],
249
- },
250
- title: "Desk Notes",
251
- showTitle: true,
252
- replyExpanded: false,
253
- selection: { from: 1, to: 1 },
254
- });
255
- });
256
-
257
207
  it("restores and returns the current editor selection", async () => {
258
208
  const el = document.createElement(
259
209
  "jant-compose-fullscreen",
@@ -275,7 +225,6 @@ describe("JantComposeFullscreen", () => {
275
225
  ],
276
226
  },
277
227
  title: "",
278
- showTitle: false,
279
228
  selection: { from: 4, to: 4 },
280
229
  labels,
281
230
  },
@@ -318,7 +267,7 @@ describe("JantComposeFullscreen", () => {
318
267
  detail: {
319
268
  json: null,
320
269
  title: "",
321
- showTitle: false,
270
+ showTitle: true,
322
271
  labels,
323
272
  replyContext: {
324
273
  contentHtml: "<p>A quiet sentence worth answering.</p>",
@@ -333,9 +282,10 @@ describe("JantComposeFullscreen", () => {
333
282
  expect(el.textContent).toContain("A quiet sentence worth answering.");
334
283
  expect(el.textContent).toContain("Mar 10, 2026");
335
284
  expect(el.textContent).toContain("Show more");
285
+ // The reply's title field lives inside its own row, not the toolbar.
336
286
  expect(
337
287
  el.querySelector(
338
- ".compose-fullscreen-editor-row .compose-fullscreen-title-placeholder-reply",
288
+ ".compose-fullscreen-editor-row .compose-fullscreen-title-reply",
339
289
  ),
340
290
  ).not.toBeNull();
341
291
  });
@@ -353,7 +303,6 @@ describe("JantComposeFullscreen", () => {
353
303
  detail: {
354
304
  json: null,
355
305
  title: "Follow-up",
356
- showTitle: true,
357
306
  labels,
358
307
  replyContext: {
359
308
  contentHtml: "<p>Thread seed.</p>",
@@ -390,7 +339,6 @@ describe("JantComposeFullscreen", () => {
390
339
  detail: {
391
340
  json: null,
392
341
  title: "",
393
- showTitle: false,
394
342
  labels,
395
343
  replyContext: {
396
344
  contentHtml: "<p>One more thread preview.</p>",
@@ -436,7 +384,6 @@ describe("JantComposeFullscreen", () => {
436
384
  detail: {
437
385
  json: null,
438
386
  title: "",
439
- showTitle: false,
440
387
  labels,
441
388
  },
442
389
  }),
@@ -491,7 +438,6 @@ describe("JantComposeFullscreen", () => {
491
438
  ],
492
439
  },
493
440
  title: "",
494
- showTitle: false,
495
441
  labels,
496
442
  },
497
443
  }),
@@ -249,7 +249,11 @@ export function convertComposeFormat(
249
249
  if (prepend.length) content.unshift(...prepend);
250
250
 
251
251
  out.bodyJson = content.length ? { type: "doc", content } : null;
252
- out.showTitle = to === "note" && out.title.trim().length > 0;
252
+ // A title carried in from another format has nowhere to show unless the note's
253
+ // title field is open, so opening it is the only way not to drop it. The
254
+ // toggle is otherwise left alone, so note → link → note keeps the user's
255
+ // choice instead of resetting it.
256
+ if (to === "note" && out.title.trim().length > 0) out.showTitle = true;
253
257
 
254
258
  return out;
255
259
  }
@@ -11,6 +11,18 @@ import type { TableControlLabels } from "../tiptap/table-control-labels.js";
11
11
 
12
12
  export type ComposeFormat = "note" | "link" | "quote";
13
13
 
14
+ /**
15
+ * What one composer row says about itself. An editor owns its fields, so it
16
+ * owns these two answers too — the dialog reduces them over the rows it has
17
+ * instead of reading editors back out of the DOM while it renders.
18
+ */
19
+ export interface ComposeRowStatus {
20
+ /** Anything worth keeping: the check behind "discard this?" and autosave. */
21
+ hasContent: boolean;
22
+ /** Enough to publish this row: its format's requirements met, fields valid. */
23
+ publishable: boolean;
24
+ }
25
+
14
26
  export interface ComposeAttachment {
15
27
  clientId: string;
16
28
  file: File;
@@ -25,6 +37,12 @@ export interface ComposeAttachment {
25
37
  status: "pending" | "processing" | "uploading" | "done" | "error";
26
38
  progress: number | null;
27
39
  mediaId: string | null;
40
+ /**
41
+ * Server URL of the uploaded file, set when the upload completes. Unlike
42
+ * `previewUrl` (a blob URL revoked on editor reset) it stays valid, so
43
+ * draft restore can rehydrate the attachment after the dialog closed.
44
+ */
45
+ remoteUrl: string | null;
28
46
  alt: string;
29
47
  error: string | null;
30
48
  /** Text content preview for text files (first ~100 chars) */
@@ -64,6 +82,23 @@ export interface DraftItem {
64
82
  }[];
65
83
  }
66
84
 
85
+ /**
86
+ * Snapshot of a media attachment whose upload completed. Stored in the local
87
+ * draft (and passed by the bridge on failure restore) so uploaded images
88
+ * survive a failed publish or a page reload — the bytes are already on the
89
+ * server, only the reference needs restoring.
90
+ */
91
+ export interface LocalDraftMedia {
92
+ clientId: string;
93
+ mediaId: string;
94
+ url: string;
95
+ mimeType: string;
96
+ name?: string;
97
+ alt?: string;
98
+ summary?: string | null;
99
+ chars?: number | null;
100
+ }
101
+
67
102
  export interface LocalDraft {
68
103
  format: ComposeFormat;
69
104
  title: string;
@@ -87,9 +122,15 @@ export interface LocalDraft {
87
122
  summary: string;
88
123
  }>;
89
124
  attachmentOrder?: string[];
125
+ mediaAttachments?: LocalDraftMedia[];
90
126
  /** Present when the draft is a multi-post thread */
91
127
  threadItems?: Array<{
92
128
  format: ComposeFormat;
129
+ /** Per-post publish date; empty means "follows the thread". */
130
+ publishedAtInput?: string;
131
+ publishedAtTimeMinutes?: number | null;
132
+ /** Per-post permalink; empty means the server assigns a random id. */
133
+ slug?: string;
93
134
  title: string;
94
135
  bodyJson: JSONContent | null;
95
136
  url: string;
@@ -102,6 +143,7 @@ export interface LocalDraft {
102
143
  summary: string;
103
144
  }>;
104
145
  attachmentOrder?: string[];
146
+ mediaAttachments?: LocalDraftMedia[];
105
147
  }>;
106
148
  savedAt: number;
107
149
  }
@@ -229,9 +271,9 @@ export interface ComposeLabels {
229
271
  threadLimitReached: string;
230
272
  showMore: string;
231
273
  showLess: string;
232
- newThread: string;
233
- replyTitle: string;
234
- editTitle: string;
274
+ closeCompose: string;
275
+ editing: string;
276
+ composeDialogLabel: string;
235
277
  slashHint: string;
236
278
  tableControls: TableControlLabels;
237
279
  collectionFormLabels: CollectionFormLabels;
@@ -301,6 +343,8 @@ export interface ComposeSubmitDetail {
301
343
  collectionIds: string[];
302
344
  attachments: ComposeSubmitAttachment[];
303
345
  editPostId?: string;
346
+ /** Identifies an existing server draft loaded through the drafts workflow. */
347
+ draftSourceId?: string;
304
348
  replyToId?: string;
305
349
  quietReply?: boolean;
306
350
  replyThreadRootId?: string;
@@ -13,6 +13,7 @@ import { classMap } from "lit/directives/class-map.js";
13
13
  import { unsafeSVG } from "lit/directives/unsafe-svg.js";
14
14
  import { NAVIGATION_SETTINGS_PATH } from "../../lib/settings-paths.js";
15
15
  import { openNewCompose } from "../compose-launch.js";
16
+ import { publicPath } from "../runtime-paths.js";
16
17
  import { getBestFieldSearchRank, normalizeSearch } from "../search-rank.js";
17
18
 
18
19
  // ---------------------------------------------------------------------------
@@ -23,6 +24,7 @@ interface PaletteItem {
23
24
  title: string;
24
25
  path: string;
25
26
  type: "post" | "collection" | "system";
27
+ status?: "draft" | "published";
26
28
  }
27
29
 
28
30
  interface CommandItem {
@@ -351,7 +353,7 @@ export class JantCommandPalette extends LitElement {
351
353
  const q = normalizeSearch(this._query);
352
354
  const navItems: DisplayItem[] = this.#navigateItems.map((item) => ({
353
355
  label: item.title,
354
- secondary: item.path,
356
+ secondary: item.status === "draft" ? `Draft · ${item.path}` : item.path,
355
357
  icon: ICONS[item.type],
356
358
  }));
357
359
 
@@ -378,7 +380,7 @@ export class JantCommandPalette extends LitElement {
378
380
  #executeSearch(query: string) {
379
381
  saveHistory(SEARCH_HISTORY_KEY, query);
380
382
  this.close();
381
- window.location.href = `/search?q=${encodeURIComponent(query)}`;
383
+ window.location.href = publicPath(`/search?q=${encodeURIComponent(query)}`);
382
384
  }
383
385
 
384
386
  #executeItem(index: number) {
@@ -407,7 +409,7 @@ export class JantCommandPalette extends LitElement {
407
409
  if (displayItem?.pathTarget) {
408
410
  saveHistory(NAV_HISTORY_KEY, displayItem.pathTarget);
409
411
  this.close();
410
- window.location.href = displayItem.pathTarget;
412
+ window.location.href = publicPath(displayItem.pathTarget);
411
413
  return;
412
414
  }
413
415
 
@@ -429,11 +431,15 @@ export class JantCommandPalette extends LitElement {
429
431
  saveHistory(NAV_HISTORY_KEY, item.path);
430
432
  this.close();
431
433
  if (item.type === "system") {
432
- window.location.href = item.path;
434
+ window.location.href = publicPath(item.path);
433
435
  } else if (item.type === "collection") {
434
- window.location.href = `/collections/${item.path}`;
436
+ window.location.href = publicPath(`/collections/${item.path}`);
435
437
  } else {
436
- window.location.href = `/${item.path}`;
438
+ window.location.href = publicPath(
439
+ item.status === "draft"
440
+ ? `/preview/${item.path}?edit=1`
441
+ : `/${item.path}`,
442
+ );
437
443
  }
438
444
  }
439
445
  }