@jant/core 0.6.7 → 0.6.9

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 (131) hide show
  1. package/bin/commands/uploads/cleanup.js +2 -0
  2. package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
  3. package/dist/app-DqHzOwL5.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-CGf2m3qp.css +2 -0
  6. package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
  7. package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
  13. package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/note-expand.test.ts +130 -0
  22. package/src/client/archive-nav.js +2 -1
  23. package/src/client/audio-player.ts +7 -3
  24. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
  29. package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
  30. package/src/client/components/compose-format-convert.ts +255 -0
  31. package/src/client/components/compose-types.ts +2 -0
  32. package/src/client/components/jant-compose-dialog.ts +110 -44
  33. package/src/client/components/jant-compose-editor.ts +64 -11
  34. package/src/client/components/jant-settings-general.ts +56 -18
  35. package/src/client/components/settings-types.ts +11 -0
  36. package/src/client/compose-bridge.ts +17 -0
  37. package/src/client/feed-video-player.ts +1 -1
  38. package/src/client/hydrate-partial.ts +25 -0
  39. package/src/client/note-expand.ts +63 -0
  40. package/src/client/settings-bridge.ts +3 -0
  41. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  42. package/src/client/tiptap/bubble-menu.ts +37 -4
  43. package/src/client.ts +1 -0
  44. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  45. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  46. package/src/db/migrations/meta/_journal.json +7 -0
  47. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  48. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  49. package/src/db/migrations/pg/meta/_journal.json +7 -0
  50. package/src/db/pg/schema.ts +36 -0
  51. package/src/db/schema.ts +36 -0
  52. package/src/i18n/__tests__/middleware.test.ts +46 -0
  53. package/src/i18n/locales/public/en.po +41 -0
  54. package/src/i18n/locales/public/en.ts +1 -1
  55. package/src/i18n/locales/public/zh-Hans.po +41 -0
  56. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  57. package/src/i18n/locales/public/zh-Hant.po +41 -0
  58. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  59. package/src/i18n/locales/settings/en.po +37 -22
  60. package/src/i18n/locales/settings/en.ts +1 -1
  61. package/src/i18n/locales/settings/zh-Hans.po +37 -22
  62. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hant.po +37 -22
  64. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  65. package/src/i18n/middleware.ts +17 -8
  66. package/src/i18n/supported-locales.ts +5 -4
  67. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  68. package/src/lib/__tests__/markdown.test.ts +1 -1
  69. package/src/lib/__tests__/summary.test.ts +87 -0
  70. package/src/lib/__tests__/timeline.test.ts +48 -1
  71. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  72. package/src/lib/__tests__/url.test.ts +44 -0
  73. package/src/lib/__tests__/view.test.ts +168 -1
  74. package/src/lib/ids.ts +1 -0
  75. package/src/lib/navigation.ts +1 -0
  76. package/src/lib/resolve-config.ts +3 -2
  77. package/src/lib/summary.ts +42 -3
  78. package/src/lib/tiptap-render.ts +6 -2
  79. package/src/lib/upload.ts +16 -2
  80. package/src/lib/url.ts +41 -0
  81. package/src/lib/view.ts +102 -40
  82. package/src/preset.css +7 -1
  83. package/src/routes/api/__tests__/settings.test.ts +1 -4
  84. package/src/routes/api/__tests__/upload.test.ts +2 -0
  85. package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
  86. package/src/routes/api/internal/sites.ts +44 -1
  87. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  88. package/src/routes/api/public/archive.ts +22 -6
  89. package/src/routes/api/settings.ts +2 -1
  90. package/src/routes/api/telegram.ts +2 -1
  91. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  92. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  93. package/src/routes/dash/custom-urls.tsx +1 -1
  94. package/src/routes/dash/settings.tsx +23 -7
  95. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  96. package/src/routes/pages/archive.tsx +116 -20
  97. package/src/routes/pages/collections.tsx +1 -0
  98. package/src/services/__tests__/media.test.ts +274 -30
  99. package/src/services/__tests__/post.test.ts +81 -0
  100. package/src/services/__tests__/settings.test.ts +55 -0
  101. package/src/services/bootstrap.ts +7 -0
  102. package/src/services/export-theme/assets/client-site.js +1 -1
  103. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  104. package/src/services/export-theme/styles/main.css +49 -15
  105. package/src/services/media.ts +199 -42
  106. package/src/services/post.ts +22 -2
  107. package/src/services/search.ts +4 -4
  108. package/src/services/settings.ts +49 -15
  109. package/src/services/upload-session.ts +28 -0
  110. package/src/styles/tokens.css +7 -5
  111. package/src/styles/ui.css +163 -34
  112. package/src/types/bindings.ts +1 -0
  113. package/src/types/config.ts +14 -1
  114. package/src/types/props.ts +3 -0
  115. package/src/ui/compose/ComposeDialog.tsx +13 -0
  116. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  117. package/src/ui/dash/settings/GeneralContent.tsx +38 -4
  118. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  119. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  120. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  121. package/src/ui/feed/NoteCard.tsx +54 -5
  122. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  123. package/src/ui/layouts/BaseLayout.tsx +1 -0
  124. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
  125. package/src/ui/pages/ArchivePage.tsx +89 -6
  126. package/src/ui/pages/CollectionsPage.tsx +7 -1
  127. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  128. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  129. package/src/ui/shared/CollectionsManager.tsx +3 -0
  130. package/dist/app-C1QgMNRY.js +0 -6
  131. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -38,10 +38,13 @@ const labels: SettingsLabels = {
38
38
  siteName: "Site Name",
39
39
  aboutBlog: "About this blog",
40
40
  aboutBlogHelp: "Displayed above your blog posts.",
41
- siteLanguage: "Site Language",
42
- siteLanguageHelp: "Language used for the site UI.",
41
+ siteLanguage: "Content language",
42
+ siteLanguageHelp: "The language your posts are written in.",
43
43
  siteLanguageSearchPlaceholder: "Search…",
44
44
  siteLanguageNoMatches: "No matches.",
45
+ contentLanguagePreview: "Readers and search engines see",
46
+ dashboardLanguage: "Dashboard language",
47
+ dashboardLanguageHelp: "The language this admin dashboard shows in.",
45
48
  cjkFont: "CJK Font",
46
49
  cjkFontHelp:
47
50
  "Load a serif font optimized for Chinese, Japanese, or Korean content.",
@@ -5,6 +5,7 @@ import type {
5
5
  SettingsLabels,
6
6
  SettingsTimezone,
7
7
  SettingsCjkFont,
8
+ SettingsDashboardLanguage,
8
9
  SettingsSaveDetail,
9
10
  } from "../settings-types.js";
10
11
  import { MAX_SITE_NAME_LENGTH } from "../../../types.js";
@@ -81,10 +82,13 @@ const labels: SettingsLabels = {
81
82
  siteName: "Site Name",
82
83
  aboutBlog: "About this blog",
83
84
  aboutBlogHelp: "Displayed above your blog posts.",
84
- siteLanguage: "Site Language",
85
- siteLanguageHelp: "Language used for the site UI.",
85
+ siteLanguage: "Content language",
86
+ siteLanguageHelp: "The language your posts are written in.",
86
87
  siteLanguageSearchPlaceholder: "Search…",
87
88
  siteLanguageNoMatches: "No matches.",
89
+ contentLanguagePreview: "Readers and search engines see",
90
+ dashboardLanguage: "Dashboard language",
91
+ dashboardLanguageHelp: "The language this admin dashboard shows in.",
88
92
  cjkFont: "CJK Font",
89
93
  cjkFontHelp:
90
94
  "Load a serif font optimized for Chinese, Japanese, or Korean content.",
@@ -127,10 +131,17 @@ const cjkFonts: SettingsCjkFont[] = [
127
131
  { value: "zh-Hans", label: "简体中文 (Simplified Chinese)" },
128
132
  ];
129
133
 
134
+ const dashboardLanguages: SettingsDashboardLanguage[] = [
135
+ { value: "en", label: "English" },
136
+ { value: "zh-Hans", label: "简体中文" },
137
+ { value: "zh-Hant", label: "繁體中文" },
138
+ ];
139
+
130
140
  const initialData = {
131
141
  siteName: "My Blog",
132
142
  siteDescription: "A test blog",
133
143
  siteLanguage: "en",
144
+ dashboardLanguage: "en",
134
145
  cjkSerifFont: "off",
135
146
  timeZone: "UTC",
136
147
  mainRssFeed: "featured",
@@ -161,6 +172,7 @@ async function createElement(
161
172
  el.labels = labels;
162
173
  el.timezones = timezones;
163
174
  el.cjkFonts = cjkFonts;
175
+ el.dashboardLanguages = dashboardLanguages;
164
176
  el.siteNameFallback = "Fallback Name";
165
177
  el.siteDescriptionFallback = "Fallback Description";
166
178
  el.mainFeedUrl = "/feed";
@@ -239,10 +251,11 @@ describe("JantSettingsGeneral", () => {
239
251
  expect(trigger.getAttribute("aria-expanded")).toBe("true");
240
252
 
241
253
  const options = el.querySelectorAll<HTMLButtonElement>('[role="option"]');
254
+ // The content-language picker lists the full BCP 47 catalog so any public
255
+ // content language is reachable. Coverage / raw tags are not shown here.
242
256
  expect(options.length).toBeGreaterThanOrEqual(20);
243
- // Each option carries the universal "translated" coverage suffix.
244
257
  for (const option of options) {
245
- expect(option.textContent).toMatch(/% translated/);
258
+ expect(option.textContent).not.toMatch(/% translated/);
246
259
  }
247
260
 
248
261
  const search = requireElement(
@@ -258,7 +271,7 @@ describe("JantSettingsGeneral", () => {
258
271
  expect(filtered[0]?.textContent).toMatch(/Suomi|Finnish/);
259
272
  });
260
273
 
261
- it("selects a non-catalog locale and reports 0% translated coverage on it", async () => {
274
+ it("selects a non-catalog content language and shows its native name", async () => {
262
275
  const el = await createElement();
263
276
  const trigger = requireElement(
264
277
  el.querySelector<HTMLButtonElement>(
@@ -285,9 +298,43 @@ describe("JantSettingsGeneral", () => {
285
298
 
286
299
  // Picker closes after selection.
287
300
  expect(trigger.getAttribute("aria-expanded")).toBe("false");
288
- // Trigger reflects the new tag and shows 0% coverage.
289
- expect(trigger.textContent).toMatch(/fi/);
290
- expect(trigger.textContent).toMatch(/0% translated/);
301
+ // Trigger shows the selected language's native name only — no raw BCP 47
302
+ // tag, no coverage metric.
303
+ expect(trigger.textContent).toMatch(/suomi|finnish/i);
304
+ expect(trigger.textContent).not.toMatch(/% translated/);
305
+ expect(trigger.textContent).not.toMatch(/\bfi\b/);
306
+ });
307
+
308
+ it("renders dashboard language options and saves the selection", async () => {
309
+ const el = await createElement();
310
+ const select = requireElement(
311
+ el.querySelector(
312
+ 'select[aria-labelledby="dashboard-language-label"]',
313
+ ) as globalThis.HTMLSelectElement | null,
314
+ "expected dashboard language select",
315
+ );
316
+ const values = Array.from(select.options).map((o) => o.value);
317
+ expect(values).toEqual(["en", "zh-Hans", "zh-Hant"]);
318
+ expect(select.value).toBe("en");
319
+
320
+ const saves: SettingsSaveDetail[] = [];
321
+ el.addEventListener("jant:settings-save", (e) => {
322
+ saves.push((e as CustomEvent<SettingsSaveDetail>).detail);
323
+ });
324
+
325
+ select.value = "zh-Hant";
326
+ select.dispatchEvent(new Event("change", { bubbles: true }));
327
+ await el.updateComplete;
328
+
329
+ const saveButton = requireElement(
330
+ findSaveButtonByHeading(el, labels.languageAndTime),
331
+ "expected language & time save button",
332
+ );
333
+ saveButton.click();
334
+
335
+ expect(saves).toHaveLength(1);
336
+ expect(saves[0]?.endpoint).toBe("/settings/general/language-time");
337
+ expect(saves[0]?.data.dashboardLanguage).toBe("zh-Hant");
291
338
  });
292
339
 
293
340
  it("renders CJK font options", async () => {
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Compose format conversion
3
+ *
4
+ * When a post's format changes while editing (note / link / quote), each format
5
+ * stores a different subset of structured fields. This module converts those
6
+ * fields so nothing is silently lost:
7
+ *
8
+ * - **Fold** (when *leaving* a format): any field the target format can't hold is
9
+ * pushed into the body as a visible block (a blockquote, a heading, or a link
10
+ * paragraph) and the field is cleared. This never loses data.
11
+ * - **Extract** (when *entering* a format): only `blockquote → quoteText` is
12
+ * recovered from the body front, which keeps the common `quote → note → quote`
13
+ * round-trip lossless. url/title are not auto-extracted — they stay visible in
14
+ * the body and the author re-fills the field if needed.
15
+ *
16
+ * Pure and DOM-free so it can be unit-tested in isolation. It deep-clones the
17
+ * body it is given and never mutates its input.
18
+ */
19
+
20
+ import type { JSONContent } from "@tiptap/core";
21
+
22
+ import type { ComposeFormat } from "./compose-types.js";
23
+
24
+ /** The subset of compose fields that participate in format conversion. */
25
+ export interface ComposeConvertFields {
26
+ title: string;
27
+ url: string;
28
+ quoteText: string;
29
+ quoteAuthor: string;
30
+ showTitle: boolean;
31
+ bodyJson: JSONContent | null;
32
+ }
33
+
34
+ /** Matches an attribution paragraph: `— Author` or `— Author https://source`. */
35
+ const ATTRIBUTION_RE = /^—\s*(.*?)(?:\s+(https?:\/\/\S+))?\s*$/;
36
+
37
+ function cloneDoc(doc: JSONContent | null): JSONContent | null {
38
+ return doc ? (JSON.parse(JSON.stringify(doc)) as JSONContent) : null;
39
+ }
40
+
41
+ /** Concatenate all descendant text of a node. */
42
+ function nodeText(node: JSONContent | undefined): string {
43
+ if (!node) return "";
44
+ if (typeof node.text === "string") return node.text;
45
+ if (!Array.isArray(node.content)) return "";
46
+ return node.content.map(nodeText).join("");
47
+ }
48
+
49
+ function makeHeading(text: string, level = 2): JSONContent {
50
+ return {
51
+ type: "heading",
52
+ attrs: { level },
53
+ content: [{ type: "text", text }],
54
+ };
55
+ }
56
+
57
+ function makeParagraph(text: string): JSONContent {
58
+ return text
59
+ ? { type: "paragraph", content: [{ type: "text", text }] }
60
+ : { type: "paragraph" };
61
+ }
62
+
63
+ function makeLinkParagraph(url: string): JSONContent {
64
+ return {
65
+ type: "paragraph",
66
+ content: [
67
+ {
68
+ type: "text",
69
+ text: url,
70
+ marks: [{ type: "link", attrs: { href: url } }],
71
+ },
72
+ ],
73
+ };
74
+ }
75
+
76
+ function parseAttribution(text: string): {
77
+ author: string;
78
+ url: string | null;
79
+ } {
80
+ const match = ATTRIBUTION_RE.exec(text.trim());
81
+ if (!match) return { author: "", url: null };
82
+ return { author: match[1].trim(), url: match[2] ?? null };
83
+ }
84
+
85
+ /** First link href found anywhere within a node (depth-first), or null. */
86
+ function findLinkHref(node: JSONContent | undefined): string | null {
87
+ if (!node) return null;
88
+ if (Array.isArray(node.marks)) {
89
+ const href = node.marks.find((mark) => mark.type === "link")?.attrs?.href;
90
+ if (typeof href === "string" && href) return href;
91
+ }
92
+ if (Array.isArray(node.content)) {
93
+ for (const child of node.content) {
94
+ const found = findLinkHref(child);
95
+ if (found) return found;
96
+ }
97
+ }
98
+ return null;
99
+ }
100
+
101
+ /**
102
+ * Build the `— Author` attribution paragraph for a folded quote. The author name
103
+ * is linked to the source url so the body stays clean (no raw url) while the
104
+ * `note → quote` round-trip can still recover the url from the link mark. When
105
+ * there is no author, the url itself becomes the link text. Null if neither.
106
+ */
107
+ function makeAttributionParagraph(
108
+ author: string,
109
+ url: string | null,
110
+ ): JSONContent | null {
111
+ const name = author.trim();
112
+ const href = url?.trim() ?? "";
113
+ if (!name && !href) return null;
114
+
115
+ const linked = (text: string): JSONContent => ({
116
+ type: "text",
117
+ text,
118
+ marks: [{ type: "link", attrs: { href } }],
119
+ });
120
+ if (name && href) {
121
+ return {
122
+ type: "paragraph",
123
+ content: [{ type: "text", text: "— " }, linked(name)],
124
+ };
125
+ }
126
+ if (name) {
127
+ return {
128
+ type: "paragraph",
129
+ content: [{ type: "text", text: `— ${name}` }],
130
+ };
131
+ }
132
+ return {
133
+ type: "paragraph",
134
+ content: [{ type: "text", text: "— " }, linked(href)],
135
+ };
136
+ }
137
+
138
+ function makeBlockquote(
139
+ quoteText: string,
140
+ attribution: JSONContent | null,
141
+ ): JSONContent {
142
+ const paragraphs = quoteText.split("\n").map((line) => makeParagraph(line));
143
+ if (attribution) paragraphs.push(attribution);
144
+ return { type: "blockquote", content: paragraphs };
145
+ }
146
+
147
+ /**
148
+ * Convert compose fields from one post format to another.
149
+ *
150
+ * @param from - the current format
151
+ * @param to - the target format
152
+ * @param fields - the current field values (not mutated)
153
+ * @returns new field values appropriate for the target format
154
+ * @example
155
+ * // quote → note: the quote becomes a leading blockquote in the body
156
+ * convertComposeFormat("quote", "note", {
157
+ * title: "", url: "", quoteText: "Stay hungry", quoteAuthor: "Jobs",
158
+ * showTitle: false, bodyJson: null,
159
+ * });
160
+ */
161
+ export function convertComposeFormat(
162
+ from: ComposeFormat,
163
+ to: ComposeFormat,
164
+ fields: ComposeConvertFields,
165
+ ): ComposeConvertFields {
166
+ if (from === to) return fields;
167
+
168
+ const out: ComposeConvertFields = { ...fields };
169
+ const body = cloneDoc(fields.bodyJson);
170
+ const content: JSONContent[] = Array.isArray(body?.content)
171
+ ? [...body.content]
172
+ : [];
173
+
174
+ // ── Extract: leading bare-link paragraph → url (reverses the link→note
175
+ // fold so link↔note round-trips). Only a "bare" link (text === href) is
176
+ // pulled out, so a labeled link line keeps its label instead of silently
177
+ // losing it. ─────────────────────────────────────────────────────────
178
+ if (to === "link" && out.url === "") {
179
+ const first = content[0];
180
+ const href = findLinkHref(first);
181
+ if (
182
+ href &&
183
+ first?.type === "paragraph" &&
184
+ Array.isArray(first.content) &&
185
+ first.content.length === 1 &&
186
+ nodeText(first) === href
187
+ ) {
188
+ out.url = href;
189
+ content.shift();
190
+ }
191
+ }
192
+
193
+ // ── Extract (focused: blockquote → quoteText only) ──────────────────
194
+ if (
195
+ to === "quote" &&
196
+ out.quoteText === "" &&
197
+ content[0]?.type === "blockquote"
198
+ ) {
199
+ const paragraphs = Array.isArray(content[0].content)
200
+ ? [...content[0].content]
201
+ : [];
202
+ const last = paragraphs[paragraphs.length - 1];
203
+ const lastText = nodeText(last);
204
+ if (paragraphs.length > 0 && /^—/.test(lastText.trim())) {
205
+ const parsed = parseAttribution(lastText);
206
+ // Prefer the link mark's href (the linked-author form) over a url parsed
207
+ // from plain text (legacy `— Author https://…`).
208
+ const url = findLinkHref(last) ?? parsed.url;
209
+ // When the attribution is url-only, the link text is the url itself —
210
+ // don't mistake it for an author.
211
+ const author = parsed.author === url ? "" : parsed.author;
212
+ if (author) out.quoteAuthor = out.quoteAuthor || author;
213
+ if (url && out.url === "") out.url = url;
214
+ paragraphs.pop();
215
+ }
216
+ out.quoteText = paragraphs.map(nodeText).join("\n").trim();
217
+ content.shift();
218
+ }
219
+
220
+ // ── Harvest (fold fields the target can't hold into the body) ───────
221
+ const prepend: JSONContent[] = [];
222
+
223
+ // Title → heading (only quote can't hold a title)
224
+ if (to === "quote" && out.title.trim() !== "") {
225
+ prepend.push(makeHeading(out.title.trim()));
226
+ out.title = "";
227
+ }
228
+
229
+ // quoteText → blockquote (note and link can't hold a quote)
230
+ if (to !== "quote" && out.quoteText.trim() !== "") {
231
+ // For a note, the source url has no home either, so fold it into the
232
+ // attribution. For a link, url maps to link.url and is preserved.
233
+ const foldUrl = to === "note" ? out.url : null;
234
+ prepend.push(
235
+ makeBlockquote(
236
+ out.quoteText,
237
+ makeAttributionParagraph(out.quoteAuthor, foldUrl),
238
+ ),
239
+ );
240
+ out.quoteText = "";
241
+ out.quoteAuthor = "";
242
+ if (to === "note") out.url = "";
243
+ } else if (to === "note" && out.url.trim() !== "") {
244
+ // Source was a link (no quote text): notes can't hold a url, so fold it.
245
+ prepend.push(makeLinkParagraph(out.url.trim()));
246
+ out.url = "";
247
+ }
248
+
249
+ if (prepend.length) content.unshift(...prepend);
250
+
251
+ out.bodyJson = content.length ? { type: "doc", content } : null;
252
+ out.showTitle = to === "note" && out.title.trim().length > 0;
253
+
254
+ return out;
255
+ }
@@ -214,6 +214,8 @@ export interface ComposeLabels {
214
214
  showMore: string;
215
215
  showLess: string;
216
216
  newThread: string;
217
+ replyTitle: string;
218
+ editTitle: string;
217
219
  slashHint: string;
218
220
  collectionFormLabels: CollectionFormLabels;
219
221
  }
@@ -36,6 +36,7 @@ import {
36
36
  getSelectedFirstOrder,
37
37
  } from "../collection-picker-order.js";
38
38
  import type { JantComposeEditor } from "./jant-compose-editor.js";
39
+ import { convertComposeFormat } from "./compose-format-convert.js";
39
40
  import { getMediaCategory } from "../../lib/upload.js";
40
41
  import { getSlugValidationIssue } from "../../lib/slug-format.js";
41
42
  import { createTiptapEditor } from "../tiptap/create-editor.js";
@@ -666,7 +667,7 @@ export class JantComposeDialog extends LitElement {
666
667
  super();
667
668
  this.collections = [];
668
669
  this.labels = {} as ComposeLabels;
669
- this.uploadMaxFileSize = 500;
670
+ this.uploadMaxFileSize = 1024;
670
671
  this.pageMode = false;
671
672
  this.closeHref = "/";
672
673
  this.autoRestoreDraft = false;
@@ -743,13 +744,14 @@ export class JantComposeDialog extends LitElement {
743
744
  this._scheduleCollectionPickerAutofocus();
744
745
  }
745
746
  if (
746
- changed.has("_format") ||
747
747
  changed.has("_collectionIds") ||
748
748
  changed.has("_slug") ||
749
749
  changed.has("_publishedAtInput") ||
750
750
  changed.has("_visibility")
751
751
  ) {
752
- // Schedule draft auto-save (new-post and edit modes, not draft-load)
752
+ // Schedule draft auto-save (new-post and edit modes, not draft-load).
753
+ // `_format` is intentionally excluded: a bare format switch is exploratory
754
+ // and shouldn't persist a draft on its own (see `_switchFormat`).
753
755
  if (!this._draftSourceId) {
754
756
  this._scheduleDraftSave();
755
757
  }
@@ -2456,7 +2458,7 @@ export class JantComposeDialog extends LitElement {
2456
2458
  "jant-compose-editor",
2457
2459
  )[this._focusedThreadIndex];
2458
2460
  editor?.dispatchEvent(
2459
- new CustomEvent("jant:thread-format-change", {
2461
+ new CustomEvent("jant:format-change", {
2460
2462
  detail: { format: target },
2461
2463
  bubbles: true,
2462
2464
  }),
@@ -3052,6 +3054,14 @@ export class JantComposeDialog extends LitElement {
3052
3054
  const editor = this._editor;
3053
3055
  if (!editor) return;
3054
3056
 
3057
+ // Only persist genuine unsaved changes. Without this, merely opening a post
3058
+ // for edit (or restoring a draft) would write a local draft of the
3059
+ // unchanged content, since loading fires content-change events.
3060
+ if (!this._hasUnsavedChanges()) {
3061
+ globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3062
+ return;
3063
+ }
3064
+
3055
3065
  const data = editor.getData();
3056
3066
  const hasContent =
3057
3067
  !!data.body ||
@@ -3063,7 +3073,7 @@ export class JantComposeDialog extends LitElement {
3063
3073
  data.attachedTexts.length > 0;
3064
3074
 
3065
3075
  if (!hasContent) {
3066
- globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
3076
+ globalThis.localStorage.removeItem(this._currentDraftStorageKey());
3067
3077
  return;
3068
3078
  }
3069
3079
 
@@ -3098,7 +3108,7 @@ export class JantComposeDialog extends LitElement {
3098
3108
 
3099
3109
  try {
3100
3110
  globalThis.localStorage.setItem(
3101
- JantComposeDialog._DRAFT_KEY,
3111
+ this._currentDraftStorageKey(),
3102
3112
  JSON.stringify(draft),
3103
3113
  );
3104
3114
  } catch {
@@ -3549,10 +3559,43 @@ export class JantComposeDialog extends LitElement {
3549
3559
 
3550
3560
  private static readonly _FORMATS: ComposeFormat[] = ["note", "link", "quote"];
3551
3561
 
3562
+ /**
3563
+ * Whether a format switch should convert fields (fold/extract). Only when
3564
+ * editing an existing post or a server draft — for a brand-new post, switching
3565
+ * just hides/shows fields and nothing is persisted yet, so conversion would
3566
+ * pollute the body for no benefit.
3567
+ */
3568
+ private _shouldConvertOnFormatSwitch(): boolean {
3569
+ return !!(this._editPostId || this._draftSourceId);
3570
+ }
3571
+
3552
3572
  private _switchFormat(target: ComposeFormat) {
3553
3573
  if (this._format === target) return;
3554
- if (this._editPostId) return;
3574
+ const editor = this._editor;
3575
+ if (editor && this._shouldConvertOnFormatSwitch()) {
3576
+ // Fold fields the target can't hold into the body before the format
3577
+ // change recreates the editor from `_bodyJson`. Synchronous, so the old
3578
+ // Tiptap instance can't fire onUpdate and clobber what we just wrote.
3579
+ // `applyConvertedFields` suppresses the one content-change event the
3580
+ // conversion emits, so the switch itself never schedules a draft save.
3581
+ editor.applyConvertedFields(
3582
+ convertComposeFormat(
3583
+ this._format,
3584
+ target,
3585
+ editor.getConvertibleFields(),
3586
+ ),
3587
+ );
3588
+ }
3589
+ // A bare format switch shouldn't persist a local draft, so drop any save
3590
+ // already pending from loading the post.
3591
+ this._cancelDraftSaveTimer();
3555
3592
  this._format = target;
3593
+ // Sync the editor's format this tick. Lit commits the `.format` binding
3594
+ // only after this render returns, so `_canPublish()` (which reads
3595
+ // `editor.getData()`, keyed on the editor's format) would otherwise compute
3596
+ // against the stale format for one render and leave the submit button
3597
+ // wrongly disabled.
3598
+ if (editor) editor.format = target;
3556
3599
  this._showPublishPanel = false;
3557
3600
  if (this._shouldAutofocusFormatInput()) {
3558
3601
  globalThis.requestAnimationFrame(() => this._editor?.focusInput());
@@ -3571,6 +3614,16 @@ export class JantComposeDialog extends LitElement {
3571
3614
  const draftButtonLabel = this._hasContent()
3572
3615
  ? this.labels.saveAsDraft
3573
3616
  : this.labels.drafts;
3617
+ // Format selector sits inline (above each post) whenever more than one post
3618
+ // is on screen — a reply (parent shown above) or a multi-post thread. The
3619
+ // header then shows a plain title instead of the format selector.
3620
+ const isReply = !!(this._replyToId && this._replyToData);
3621
+ const showTitle = isReply || this._threadItems.length > 0;
3622
+ const headerTitle = this._editPostId
3623
+ ? this.labels.editTitle
3624
+ : isReply
3625
+ ? this.labels.replyTitle
3626
+ : this.labels.newThread;
3574
3627
 
3575
3628
  return html`
3576
3629
  <header class="compose-dialog-header">
@@ -3583,39 +3636,33 @@ export class JantComposeDialog extends LitElement {
3583
3636
  </button>
3584
3637
 
3585
3638
  <div class="compose-dialog-header-center">
3586
- ${this._editPostId
3587
- ? html`<span class="compose-dialog-title"
3588
- >${this.labels.editPost}</span
3589
- >`
3590
- : this._threadItems.length > 0
3591
- ? html`<span class="compose-dialog-title"
3592
- >${this.labels.newThread}</span
3593
- >`
3594
- : html`
3595
- <div class="compose-segmented">
3596
- <div
3597
- class=${classMap({
3598
- "compose-format-pill": true,
3599
- "compose-format-pill-link": this._format === "link",
3600
- "compose-format-pill-quote": this._format === "quote",
3601
- })}
3602
- ></div>
3603
- ${formats.map(
3604
- (f) => html`
3605
- <button
3606
- type="button"
3607
- class=${classMap({
3608
- "compose-segmented-item": true,
3609
- "compose-segmented-item-active": this._format === f,
3610
- })}
3611
- @click=${() => this._switchFormat(f)}
3612
- >
3613
- ${formatLabels[f]}
3614
- </button>
3615
- `,
3616
- )}
3617
- </div>
3618
- `}
3639
+ ${showTitle
3640
+ ? html`<span class="compose-dialog-title">${headerTitle}</span>`
3641
+ : html`
3642
+ <div class="compose-segmented">
3643
+ <div
3644
+ class=${classMap({
3645
+ "compose-format-pill": true,
3646
+ "compose-format-pill-link": this._format === "link",
3647
+ "compose-format-pill-quote": this._format === "quote",
3648
+ })}
3649
+ ></div>
3650
+ ${formats.map(
3651
+ (f) => html`
3652
+ <button
3653
+ type="button"
3654
+ class=${classMap({
3655
+ "compose-segmented-item": true,
3656
+ "compose-segmented-item-active": this._format === f,
3657
+ })}
3658
+ @click=${() => this._switchFormat(f)}
3659
+ >
3660
+ ${formatLabels[f]}
3661
+ </button>
3662
+ `,
3663
+ )}
3664
+ </div>
3665
+ `}
3619
3666
  </div>
3620
3667
 
3621
3668
  <div class="compose-dialog-header-actions">
@@ -5297,14 +5344,24 @@ export class JantComposeDialog extends LitElement {
5297
5344
  @focusin=${() => {
5298
5345
  this._focusedThreadIndex = index;
5299
5346
  }}
5300
- @jant:thread-format-change=${(
5301
- e: CustomEvent<{ format: ComposeFormat }>,
5302
- ) => {
5347
+ @jant:format-change=${(e: CustomEvent<{ format: ComposeFormat }>) => {
5303
5348
  e.stopPropagation();
5304
5349
  this._threadItems = this._threadItems.map((it, i) =>
5305
5350
  i === index ? { ...it, format: e.detail.format } : it,
5306
5351
  );
5307
5352
  this._format = e.detail.format;
5353
+ // Move focus to the new format's input, mirroring the single-post
5354
+ // composer's `_switchFormat`. The editor re-renders its fields only
5355
+ // after both this dialog and the editor itself finish updating, so
5356
+ // wait for both before routing focus to the now-visible control.
5357
+ if (this._shouldAutofocusFormatInput()) {
5358
+ this.updateComplete.then(() => {
5359
+ const editor = this.querySelectorAll<JantComposeEditor>(
5360
+ "jant-compose-editor",
5361
+ )[index];
5362
+ editor?.updateComplete.then(() => editor.focusInput());
5363
+ });
5364
+ }
5308
5365
  }}
5309
5366
  @jant:thread-remove=${(e: Event) => {
5310
5367
  e.stopPropagation();
@@ -5423,6 +5480,7 @@ export class JantComposeDialog extends LitElement {
5423
5480
  .format=${this._format}
5424
5481
  .labels=${this.labels}
5425
5482
  .uploadMaxFileSize=${this.uploadMaxFileSize}
5483
+ .inlineFormat=${isReply}
5426
5484
  .slashCommandDiscovered=${this.slashCommandDiscovered}
5427
5485
  ></jant-compose-editor>`;
5428
5486
 
@@ -5446,7 +5504,15 @@ export class JantComposeDialog extends LitElement {
5446
5504
  ? html`
5447
5505
  <div class="compose-thread-layout">
5448
5506
  ${this._renderReplyContext()}
5449
- <div class="compose-editor-row">
5507
+ <div
5508
+ class="compose-editor-row"
5509
+ @jant:format-change=${(
5510
+ e: CustomEvent<{ format: ComposeFormat }>,
5511
+ ) => {
5512
+ e.stopPropagation();
5513
+ this._switchFormat(e.detail.format);
5514
+ }}
5515
+ >
5450
5516
  <div class="compose-thread-dot"></div>
5451
5517
  ${editor}
5452
5518
  </div>