@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
@@ -16,6 +16,7 @@ import {
16
16
  FEATURED_SPARKLE_PATH,
17
17
  } from "../../lib/featured-icons.js";
18
18
  import { showConfirmDialog } from "../confirm.js";
19
+ import { refreshArticleView } from "../post-refresh.js";
19
20
  import { showToast } from "../toast.js";
20
21
  import { publicPath } from "../runtime-paths.js";
21
22
  import {
@@ -33,6 +34,7 @@ interface PostMenuData {
33
34
  featured: boolean;
34
35
  visibility: string;
35
36
  isReply: boolean;
37
+ isDraft: boolean;
36
38
  }
37
39
 
38
40
  interface CollectionItem {
@@ -104,6 +106,7 @@ function readPostMenuData(article: HTMLElement): PostMenuData | null {
104
106
  featured: article.hasAttribute("data-post-featured"),
105
107
  visibility: article.dataset.postVisibility ?? "public",
106
108
  isReply: threadId !== id || article.hasAttribute("data-post-reply"),
109
+ isDraft: article.hasAttribute("data-post-draft"),
107
110
  };
108
111
  }
109
112
 
@@ -529,6 +532,36 @@ export class JantPostMenu extends LitElement {
529
532
  }
530
533
  }
531
534
 
535
+ async #publish() {
536
+ if (!this._data) return;
537
+ const postId = this._data.id;
538
+ const trigger = this._triggerEl;
539
+ this.#close({ restoreFocus: false });
540
+
541
+ const article = document.querySelector<HTMLElement>(
542
+ `article[data-post-id="${postId}"]`,
543
+ );
544
+
545
+ try {
546
+ const res = await fetch(`/api/posts/${postId}`, {
547
+ method: "PUT",
548
+ headers: { "Content-Type": "application/json" },
549
+ body: JSON.stringify({ status: "published" }),
550
+ });
551
+ if (!res.ok) throw new Error();
552
+
553
+ showToast("Draft published.");
554
+ // The badge, the timestamp, and any drafts published alongside this one
555
+ // all change at once — re-render the surface rather than patching it.
556
+ if (!article || !(await refreshArticleView(article))) {
557
+ globalThis.location.reload();
558
+ }
559
+ } catch {
560
+ showToast("Could not publish the draft. Try again.", "error");
561
+ trigger?.focus();
562
+ }
563
+ }
564
+
532
565
  #setVisibility(newVisibility: string) {
533
566
  if (!this._data) return;
534
567
  const postId = this._data.id;
@@ -711,8 +744,10 @@ export class JantPostMenu extends LitElement {
711
744
  this.#close({ restoreFocus: false });
712
745
 
713
746
  const confirmed = await showConfirmDialog({
714
- message: "Delete this post permanently? This can't be undone.",
715
- confirmLabel: "Delete",
747
+ message: this._data.isDraft
748
+ ? "Discard this draft permanently? This can't be undone."
749
+ : "Delete this post permanently? This can't be undone.",
750
+ confirmLabel: this._data.isDraft ? "Discard" : "Delete",
716
751
  cancelLabel: "Cancel",
717
752
  tone: "danger",
718
753
  });
@@ -992,6 +1027,21 @@ export class JantPostMenu extends LitElement {
992
1027
 
993
1028
  // --- Icons (inline SVG) ---
994
1029
 
1030
+ #iconPublish() {
1031
+ return html`<svg
1032
+ xmlns="http://www.w3.org/2000/svg"
1033
+ viewBox="0 0 24 24"
1034
+ fill="none"
1035
+ stroke="currentColor"
1036
+ stroke-width="1.75"
1037
+ stroke-linecap="round"
1038
+ stroke-linejoin="round"
1039
+ >
1040
+ <path d="M12 19V5" />
1041
+ <path d="m5 12 7-7 7 7" />
1042
+ </svg>`;
1043
+ }
1044
+
995
1045
  #iconEdit() {
996
1046
  return html`<svg
997
1047
  xmlns="http://www.w3.org/2000/svg"
@@ -1501,6 +1551,21 @@ export class JantPostMenu extends LitElement {
1501
1551
  <span class="post-menu-item-trailing">${this.#iconEdit()}</span>
1502
1552
  </button>
1503
1553
 
1554
+ ${this._data.isDraft
1555
+ ? html`
1556
+ <button
1557
+ type="button"
1558
+ role="menuitem"
1559
+ class="post-menu-item"
1560
+ @click=${() => this.#publish()}
1561
+ >
1562
+ <span class="post-menu-item-label">Publish</span>
1563
+ <span class="post-menu-item-trailing"
1564
+ >${this.#iconPublish()}</span
1565
+ >
1566
+ </button>
1567
+ `
1568
+ : nothing}
1504
1569
  ${this._data.isReply
1505
1570
  ? nothing
1506
1571
  : html`
@@ -1539,24 +1604,27 @@ export class JantPostMenu extends LitElement {
1539
1604
  </div>
1540
1605
 
1541
1606
  <div class="post-menu-section">
1542
- <button
1543
- type="button"
1544
- role="menuitem"
1545
- class="post-menu-item"
1546
- @click=${() => this.#setFeatured(!isFeatured)}
1547
- >
1548
- <span class="post-menu-item-label"
1549
- >${isFeatured
1550
- ? "Remove from Featured"
1551
- : "Add to Featured"}</span
1552
- >
1553
- <span class="post-menu-item-trailing"
1554
- >${isFeatured
1555
- ? this.#iconFeaturedOff()
1556
- : this.#iconFeatured()}</span
1557
- >
1558
- </button>
1559
-
1607
+ ${this._data.isDraft
1608
+ ? nothing
1609
+ : html`
1610
+ <button
1611
+ type="button"
1612
+ role="menuitem"
1613
+ class="post-menu-item"
1614
+ @click=${() => this.#setFeatured(!isFeatured)}
1615
+ >
1616
+ <span class="post-menu-item-label"
1617
+ >${isFeatured
1618
+ ? "Remove from Featured"
1619
+ : "Add to Featured"}</span
1620
+ >
1621
+ <span class="post-menu-item-trailing"
1622
+ >${isFeatured
1623
+ ? this.#iconFeaturedOff()
1624
+ : this.#iconFeatured()}</span
1625
+ >
1626
+ </button>
1627
+ `}
1560
1628
  ${this._data.isReply
1561
1629
  ? nothing
1562
1630
  : html`
@@ -1604,7 +1672,9 @@ export class JantPostMenu extends LitElement {
1604
1672
  class="post-menu-item post-menu-item-danger"
1605
1673
  @click=${() => this.#delete()}
1606
1674
  >
1607
- <span class="post-menu-item-label">Delete</span>
1675
+ <span class="post-menu-item-label"
1676
+ >${this._data.isDraft ? "Discard" : "Delete"}</span
1677
+ >
1608
1678
  <span class="post-menu-item-trailing">${this.#iconTrash()}</span>
1609
1679
  </button>
1610
1680
  </div>
@@ -8,6 +8,7 @@
8
8
  import type { ComposeSubmitDetail } from "./components/compose-types.js";
9
9
  import type { ComposeAttachment } from "./components/compose-types.js";
10
10
  import type { ComposeSubmitAttachment } from "./components/compose-types.js";
11
+ import type { LocalDraftMedia } from "./components/compose-types.js";
11
12
  import type { JantComposeDialog } from "./components/jant-compose-dialog.js";
12
13
  import type { JantComposeEditor } from "./components/jant-compose-editor.js";
13
14
  import type { PostAttachmentInput } from "../types.js";
@@ -25,10 +26,15 @@ import {
25
26
  replaceWithAutoClose,
26
27
  queueToastForNextPage,
27
28
  } from "./toast.js";
28
- import { openReplyForArticle } from "./compose-launch.js";
29
- import { hydratePartial } from "./hydrate-partial.js";
29
+ import { getComposeDialog, openReplyForArticle } from "./compose-launch.js";
30
+ import {
31
+ refreshPostCardView,
32
+ refreshPostPageView,
33
+ refreshTimelineThreadView,
34
+ } from "./post-refresh.js";
30
35
  import { getJsonString, readJsonObject } from "./json.js";
31
36
  import { uploadViaSession } from "./upload-session.js";
37
+ import type { UploadSessionResult } from "./upload-session.js";
32
38
  import { publicPath } from "./runtime-paths.js";
33
39
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
34
40
  import { getMediaCategory } from "../lib/upload.js";
@@ -66,104 +72,6 @@ function getComposeDialogFromEventTarget(
66
72
  : null;
67
73
  }
68
74
 
69
- async function fetchPartialHtml(path: string): Promise<string | null> {
70
- const res = await fetch(path, {
71
- headers: { Accept: "text/html" },
72
- });
73
- if (!res.ok) return null;
74
- return res.text();
75
- }
76
-
77
- async function refreshTimelineThreadView(
78
- threadRootId: string,
79
- ): Promise<boolean> {
80
- try {
81
- const timelineItem = document.querySelector<HTMLElement>(
82
- `[data-timeline-item][data-thread-root-id="${threadRootId}"]`,
83
- );
84
- const content = timelineItem?.querySelector<HTMLElement>(
85
- "[data-timeline-item-content]",
86
- );
87
- if (!content) return false;
88
-
89
- const html = await fetchPartialHtml(
90
- `/_/timeline-item/${encodeURIComponent(threadRootId)}`,
91
- );
92
- if (!html) return false;
93
-
94
- content.innerHTML = html;
95
- // Swapped-in markup carries interactions whose per-element setup only runs
96
- // on DOMContentLoaded (thread "Show more" toggle, feed video autoplay, audio
97
- // waveform); re-initialize them or they stay inert until a full reload.
98
- hydratePartial(content);
99
- return true;
100
- } catch {
101
- return false;
102
- }
103
- }
104
-
105
- async function refreshPostCardView(postId: string): Promise<boolean> {
106
- try {
107
- const timelineItem = document
108
- .querySelector<HTMLElement>(`article[data-post-id="${postId}"]`)
109
- ?.closest<HTMLElement>("[data-timeline-item]");
110
- const html = await fetchPartialHtml(
111
- `/_/post-card/${encodeURIComponent(postId)}`,
112
- );
113
- if (!html) return false;
114
-
115
- if (timelineItem) {
116
- const content = timelineItem.querySelector<HTMLElement>(
117
- "[data-timeline-item-content]",
118
- );
119
- if (!content) return false;
120
- content.innerHTML = html;
121
- hydratePartial(content);
122
- return true;
123
- }
124
-
125
- const article = document.querySelector<HTMLElement>(
126
- `article[data-post-id="${postId}"]`,
127
- );
128
- if (!article) return false;
129
-
130
- article.outerHTML = html;
131
- // outerHTML detaches `article`; re-query the replacement to hydrate it.
132
- const nextArticle = document.querySelector<HTMLElement>(
133
- `article[data-post-id="${postId}"]`,
134
- );
135
- if (nextArticle) hydratePartial(nextArticle);
136
- return true;
137
- } catch {
138
- return false;
139
- }
140
- }
141
-
142
- async function refreshPostPageView(postId: string): Promise<boolean> {
143
- try {
144
- const container = document.querySelector<HTMLElement>(
145
- `[data-post-view][data-post-view-id="${postId}"]`,
146
- );
147
- if (!container) return false;
148
-
149
- const html = await fetchPartialHtml(
150
- `/_/post-view/${encodeURIComponent(postId)}`,
151
- );
152
- if (!html) return false;
153
-
154
- container.outerHTML = html;
155
- // outerHTML detaches `container`; re-query the replacement to hydrate it
156
- // (see refreshTimelineThreadView).
157
- const next = document.querySelector<HTMLElement>(
158
- `[data-post-view][data-post-view-id="${postId}"]`,
159
- );
160
- if (next) hydratePartial(next);
161
- return true;
162
- } catch {
163
- return false;
164
- }
165
- }
166
-
167
75
  async function refreshReplyTarget(
168
76
  detail: ComposeSubmitDetail,
169
77
  ): Promise<boolean> {
@@ -233,7 +141,7 @@ function refreshUploadToast() {
233
141
  }
234
142
 
235
143
  /**
236
- * Track completed upload mediaIds by clientId.
144
+ * Track completed upload results by clientId.
237
145
  *
238
146
  * When an attachment is migrated between editor instances (e.g. entering thread
239
147
  * mode while an upload is in-flight), `updateAttachmentStatus` fires on the old
@@ -242,8 +150,11 @@ function refreshUploadToast() {
242
150
  * handler can't look it up. This map provides a fallback: we record the result
243
151
  * here as soon as any upload succeeds, and `buildRequestAttachments` reads it
244
152
  * when `attachment.mediaId` is null and the clientId isn't in `uploadPromises`.
153
+ *
154
+ * The full result (not just the id) is kept so a failed publish can restore
155
+ * uploaded attachments into the reopened compose dialog.
245
156
  */
246
- const completedMediaIds = new Map<string, string>();
157
+ const completedUploads = new Map<string, UploadSessionResult>();
247
158
 
248
159
  /**
249
160
  * Quickly grab the very first decoded frame of a video as a small poster.
@@ -495,8 +406,14 @@ async function uploadFile(
495
406
 
496
407
  uploadProgress.set(clientId, 1);
497
408
  refreshUploadToast();
498
- editor?.updateAttachmentStatus(clientId, "done", result.id, null);
499
- completedMediaIds.set(clientId, result.id);
409
+ editor?.updateAttachmentStatus(
410
+ clientId,
411
+ "done",
412
+ result.id,
413
+ null,
414
+ result.url,
415
+ );
416
+ completedUploads.set(clientId, result);
500
417
  return result.id;
501
418
  } catch (error) {
502
419
  uploadProgress.delete(clientId);
@@ -516,7 +433,7 @@ document.addEventListener("jant:attachment-removed", (e: Event) => {
516
433
  e as CustomEvent<{ clientId: string; mediaId: string | null }>
517
434
  ).detail;
518
435
 
519
- completedMediaIds.delete(clientId);
436
+ completedUploads.delete(clientId);
520
437
 
521
438
  if (mediaId) {
522
439
  // Upload already finished — fire-and-forget delete
@@ -565,6 +482,25 @@ document.addEventListener("click", (e: MouseEvent) => {
565
482
  void openReplyForArticle(article);
566
483
  });
567
484
 
485
+ // ── Draft badge → editor ────────────────────────────────────────────
486
+ //
487
+ // The badge on an inline draft doubles as its most likely next action. Publish
488
+ // and Delete live in the post menu with every other post action.
489
+
490
+ document.addEventListener("click", (e: MouseEvent) => {
491
+ const trigger = (e.target as HTMLElement).closest<HTMLElement>(
492
+ "[data-draft-continue]",
493
+ );
494
+ if (!trigger) return;
495
+
496
+ const postId =
497
+ trigger.dataset.postId ??
498
+ trigger.closest<HTMLElement>("article[data-post]")?.dataset.postId;
499
+ if (!postId) return;
500
+
501
+ void getComposeDialog()?.openEdit(postId);
502
+ });
503
+
568
504
  // ── Submit handler ──────────────────────────────────────────────────
569
505
 
570
506
  /** Build the JSON body for both create and update requests */
@@ -651,7 +587,7 @@ function buildRequestAttachments(
651
587
  const mediaId =
652
588
  attachment.mediaId ??
653
589
  pendingMediaIds.get(attachment.clientId) ??
654
- completedMediaIds.get(attachment.clientId);
590
+ completedUploads.get(attachment.clientId)?.id;
655
591
  if (!mediaId) continue;
656
592
 
657
593
  requestAttachments.push({
@@ -694,6 +630,7 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
694
630
  getComposeDialogFromEventTarget(event.target) ??
695
631
  (document.querySelector("jant-compose-dialog") as JantComposeDialog | null);
696
632
  const isPageMode = !!composeEl?.pageMode;
633
+ const isDraftPreviewPage = !!document.querySelector("[data-preview-status]");
697
634
 
698
635
  // Get labels for toast messages
699
636
  const labels = composeEl?.labels;
@@ -751,12 +688,48 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
751
688
  }
752
689
  composeEl?.clearLocalDraftFromStorage?.();
753
690
  };
691
+ /**
692
+ * Uploads that completed before the publish failed. The local draft's own
693
+ * media snapshot misses uploads that finished after the dialog closed, so
694
+ * the reopened compose merges these over it.
695
+ */
696
+ const collectRestorableMedia = (): LocalDraftMedia[] => {
697
+ const details = detail.threadPosts?.length ? detail.threadPosts : [detail];
698
+ const media: LocalDraftMedia[] = [];
699
+ const seen = new Set<string>();
700
+ for (const d of details) {
701
+ for (const attachment of d.attachments) {
702
+ if (attachment.type !== "media" || seen.has(attachment.clientId)) {
703
+ continue;
704
+ }
705
+ seen.add(attachment.clientId);
706
+ const upload = completedUploads.get(attachment.clientId);
707
+ if (!upload) continue;
708
+ media.push({
709
+ clientId: attachment.clientId,
710
+ mediaId: upload.id,
711
+ url: upload.url,
712
+ mimeType: upload.mimeType,
713
+ name: upload.filename || undefined,
714
+ alt: attachment.alt || undefined,
715
+ });
716
+ }
717
+ }
718
+ return media;
719
+ };
720
+
754
721
  const reopenComposeAfterFailure = async () => {
755
722
  if (!composeEl || isPageMode) return;
756
723
 
724
+ if (detail.draftSourceId) {
725
+ if (typeof composeEl.openDraft !== "function") return;
726
+ await composeEl.openDraft(detail.draftSourceId);
727
+ return;
728
+ }
729
+
757
730
  if (isEdit && detail.editPostId) {
758
731
  if (typeof composeEl.openEdit !== "function") return;
759
- await composeEl.openEdit(detail.editPostId);
732
+ await composeEl.openEdit(detail.editPostId, { restoreToast: false });
760
733
  return;
761
734
  }
762
735
 
@@ -772,13 +745,22 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
772
745
  id: detail.replyRefreshId,
773
746
  }
774
747
  : undefined,
775
- { restoreDraft: true, initialFormat: detail.format },
748
+ {
749
+ restoreDraft: true,
750
+ initialFormat: detail.format,
751
+ restoreToast: false,
752
+ restoreMedia: collectRestorableMedia(),
753
+ },
776
754
  );
777
755
  return;
778
756
  }
779
757
 
780
758
  if (typeof composeEl.openNew !== "function") return;
781
- await composeEl.openNew({ restoreDraft: true });
759
+ await composeEl.openNew({
760
+ restoreDraft: true,
761
+ restoreToast: false,
762
+ restoreMedia: collectRestorableMedia(),
763
+ });
782
764
  };
783
765
  const handleSubmitError = async (message: string) => {
784
766
  clearPageLoading();
@@ -818,10 +800,10 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
818
800
  const results = await Promise.all(pendingPromises);
819
801
 
820
802
  // If any pending upload failed (null result where we expected a mediaId
821
- // AND the upload wasn't already tracked in completedMediaIds):
803
+ // AND the upload wasn't already tracked in completedUploads):
822
804
  const failedCount = results.filter(
823
805
  (id, i) =>
824
- id === null && !completedMediaIds.has(pendingClientIds[i] ?? ""),
806
+ id === null && !completedUploads.has(pendingClientIds[i] ?? ""),
825
807
  ).length;
826
808
  if (failedCount > 0) {
827
809
  if (detail.status === "published" && !isEdit) {
@@ -930,7 +912,12 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
930
912
 
931
913
  if (threadStatus === "published") {
932
914
  clearRecoveredLocalDraft();
933
- if (isPageMode) {
915
+ if (isDraftPreviewPage && detail.draftSourceId) {
916
+ queueSuccessToast(publishedMsg);
917
+ globalThis.location.assign(
918
+ threadPermalink ?? globalThis.location.pathname,
919
+ );
920
+ } else if (isPageMode) {
934
921
  await refreshComposeCollections();
935
922
  resetPageCompose();
936
923
  toastMsg(publishedMsg);
@@ -945,6 +932,11 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
945
932
  }
946
933
  } else {
947
934
  clearRecoveredLocalDraft();
935
+ if (isDraftPreviewPage && detail.draftSourceId) {
936
+ queueSuccessToast(threadToast ?? "Draft saved.");
937
+ globalThis.location.assign(globalThis.location.pathname);
938
+ return;
939
+ }
948
940
  await refreshComposeCollections();
949
941
  if (!leavePageAfterConfirmSave()) resetPageCompose();
950
942
  toastMsg(threadToast ?? "Draft saved.");
@@ -1033,21 +1025,50 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
1033
1025
  const editData = await readJsonObject(res);
1034
1026
  const editPostId = detail.editPostId ?? "";
1035
1027
  const newSlug = getJsonString(editData, "slug");
1036
- const newPath = newSlug ? publicPath(`/${newSlug}`) : null;
1028
+ const nextStatus = getJsonString(editData, "status") ?? detail.status;
1029
+ const newPath = newSlug
1030
+ ? publicPath(
1031
+ nextStatus === "draft" ? `/preview/${newSlug}` : `/${newSlug}`,
1032
+ )
1033
+ : null;
1034
+ const editSuccessMessage =
1035
+ nextStatus === "draft"
1036
+ ? "Draft saved."
1037
+ : detail.draftSourceId
1038
+ ? publishedMsg
1039
+ : "Post updated.";
1040
+
1041
+ // Draft preview pages cannot use the public post-view refresh endpoint.
1042
+ // Reload the authenticated preview so saved content stays visible.
1043
+ if (nextStatus === "draft") {
1044
+ if (isDraftPreviewPage) {
1045
+ queueSuccessToast(editSuccessMessage);
1046
+ globalThis.location.assign(newPath ?? globalThis.location.pathname);
1047
+ return;
1048
+ }
1049
+
1050
+ // Drafts opened from the compose panel stay in that workflow. The
1051
+ // completion event lets a pending "save and open drafts" action fetch
1052
+ // the updated list only after the write has finished.
1053
+ await refreshComposeCollections();
1054
+ toastMsg(editSuccessMessage);
1055
+ dispatchSubmitComplete(composeEl, "draft");
1056
+ return;
1057
+ }
1037
1058
 
1038
1059
  if (isPageMode) {
1039
1060
  // On the post detail page: refresh or navigate if slug changed
1040
1061
  if (newPath && newPath !== globalThis.location.pathname) {
1041
- queueSuccessToast("Post updated.");
1062
+ queueSuccessToast(editSuccessMessage);
1042
1063
  globalThis.location.assign(newPath);
1043
1064
  } else {
1044
1065
  const refreshed =
1045
1066
  editPostId && (await refreshPostPageView(editPostId));
1046
1067
  if (!refreshed) {
1047
- queueSuccessToast("Post updated.");
1068
+ queueSuccessToast(editSuccessMessage);
1048
1069
  globalThis.location.assign(globalThis.location.pathname);
1049
1070
  } else {
1050
- toastMsg("Post updated.");
1071
+ toastMsg(editSuccessMessage);
1051
1072
  }
1052
1073
  }
1053
1074
  } else if (editPostId) {
@@ -1057,15 +1078,15 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
1057
1078
  );
1058
1079
  if (postView) {
1059
1080
  if (newPath && newPath !== globalThis.location.pathname) {
1060
- queueSuccessToast("Post updated.");
1081
+ queueSuccessToast(editSuccessMessage);
1061
1082
  globalThis.location.assign(newPath);
1062
1083
  return;
1063
1084
  }
1064
1085
  const refreshed = await refreshPostPageView(editPostId);
1065
1086
  if (refreshed) {
1066
- toastMsg("Post updated.");
1087
+ toastMsg(editSuccessMessage);
1067
1088
  } else {
1068
- queueSuccessToast("Post updated.");
1089
+ queueSuccessToast(editSuccessMessage);
1069
1090
  globalThis.location.reload();
1070
1091
  }
1071
1092
  } else {
@@ -1080,14 +1101,14 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
1080
1101
  ? await refreshTimelineThreadView(threadRootId)
1081
1102
  : await refreshPostCardView(editPostId);
1082
1103
  if (refreshed) {
1083
- toastMsg("Post updated.");
1104
+ toastMsg(editSuccessMessage);
1084
1105
  } else {
1085
- queueSuccessToast("Post updated.");
1106
+ queueSuccessToast(editSuccessMessage);
1086
1107
  globalThis.location.reload();
1087
1108
  }
1088
1109
  }
1089
1110
  } else {
1090
- queueSuccessToast("Post updated.");
1111
+ queueSuccessToast(editSuccessMessage);
1091
1112
  globalThis.location.reload();
1092
1113
  }
1093
1114
  return;
@@ -1,4 +1,5 @@
1
1
  import type { JantComposeDialog } from "./components/jant-compose-dialog.js";
2
+ import { showToast } from "./toast.js";
2
3
 
3
4
  interface ReplyToData {
4
5
  contentHtml: string;
@@ -142,6 +143,18 @@ export async function openReplyForArticle(article: HTMLElement): Promise<void> {
142
143
  const dialog = getComposeDialog();
143
144
  if (!dialog) return;
144
145
 
146
+ // Only surfaces that hide the trailing draft redirect to it — the feed hands
147
+ // its ID down on the last published post. Where the draft is rendered the
148
+ // author can already see it, so Reply keeps its ordinary meaning: continue
149
+ // the thread after this post. (`create` keeps that continuation a draft, so
150
+ // the chain never publishes past an unpublished post.)
151
+ const hiddenDraftTailId = article.dataset.threadDraftTailId;
152
+ if (hiddenDraftTailId) {
153
+ showToast("Picking up the unfinished draft at the end of this thread.");
154
+ await dialog.openEdit(hiddenDraftTailId);
155
+ return;
156
+ }
157
+
145
158
  const threadRootId = article.dataset.threadRootId ?? postId;
146
159
  await dialog.openReply(
147
160
  postId,
@@ -68,7 +68,11 @@ function openEditFromQueryParam() {
68
68
  if (!postId || !composeEl) return;
69
69
 
70
70
  consumeEditQueryParam();
71
- void composeEl.openEdit(postId);
71
+ if (document.querySelector("[data-preview-status]")) {
72
+ void composeEl.openDraft(postId);
73
+ } else {
74
+ void composeEl.openEdit(postId);
75
+ }
72
76
  }
73
77
 
74
78
  async function toggleFeatured(