@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.
- package/bin/commands/uploads/cleanup.js +2 -0
- package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
- package/dist/app-DqHzOwL5.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-CGf2m3qp.css +2 -0
- package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
- package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
- package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-compose-dialog.ts +110 -44
- package/src/client/components/jant-compose-editor.ts +64 -11
- package/src/client/components/jant-settings-general.ts +56 -18
- package/src/client/components/settings-types.ts +11 -0
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/note-expand.ts +63 -0
- package/src/client/settings-bridge.ts +3 -0
- package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
- package/src/client/tiptap/bubble-menu.ts +37 -4
- package/src/client.ts +1 -0
- package/src/db/migrations/0026_absent_rhodey.sql +14 -0
- package/src/db/migrations/meta/0026_snapshot.json +2511 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0024_high_violations.sql +14 -0
- package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +36 -0
- package/src/db/schema.ts +36 -0
- package/src/i18n/__tests__/middleware.test.ts +46 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +37 -22
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +37 -22
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +37 -22
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +17 -8
- package/src/i18n/supported-locales.ts +5 -4
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/ids.ts +1 -0
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +3 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +16 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/__tests__/settings.test.ts +1 -4
- package/src/routes/api/__tests__/upload.test.ts +2 -0
- package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
- package/src/routes/api/internal/sites.ts +44 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/settings.ts +2 -1
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/auth/__tests__/setup.test.ts +14 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +23 -7
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +274 -30
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/__tests__/settings.test.ts +55 -0
- package/src/services/bootstrap.ts +7 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/layouts/_default/baseof.html +2 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +199 -42
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/settings.ts +49 -15
- package/src/services/upload-session.ts +28 -0
- package/src/styles/tokens.css +7 -5
- package/src/styles/ui.css +163 -34
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +14 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/GeneralContent.tsx +38 -4
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/layouts/BaseLayout.tsx +1 -0
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-C1QgMNRY.js +0 -6
- 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: "
|
|
42
|
-
siteLanguageHelp: "
|
|
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: "
|
|
85
|
-
siteLanguageHelp: "
|
|
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
|
|
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
|
|
289
|
-
|
|
290
|
-
expect(trigger.textContent).toMatch(/
|
|
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
|
+
}
|
|
@@ -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 =
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
${
|
|
3587
|
-
? html`<span class="compose-dialog-title"
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
"
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
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:
|
|
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
|
|
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>
|