@jant/core 0.6.10 → 0.6.12
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/import-site.js +45 -20
- package/dist/app-B5P1b4TF.js +6 -0
- package/dist/{app-CGHkOdme.js → app-D-zAhayc.js} +847 -244
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-BUxR-E8O.css +2 -0
- package/dist/client/_assets/{client-DYrWuaIk.js → client-S1cdvulk.js} +1 -1
- package/dist/client/_assets/{client-auth-B5Re0uCd.js → client-auth-YW92ZH7u.js} +158 -97
- package/dist/{export-DY1v5Iqu.js → export-Ckwh4BiE.js} +96 -96
- package/dist/{github-sync-LefaslGJ.js → github-sync-BMmK3JZ0.js} +2 -2
- package/dist/{github-sync-2_T7nbOv.js → github-sync-Dl9wQMar.js} +1 -1
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/__tests__/import-site-command.test.ts +75 -0
- package/src/__tests__/vite-dev-plugins.test.ts +34 -0
- package/src/client/__tests__/compose-launch.test.ts +50 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
- package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
- package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +81 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
- package/src/client/components/compose-types.ts +4 -0
- package/src/client/components/jant-command-palette.ts +67 -2
- package/src/client/components/jant-compose-dialog.ts +8 -2
- package/src/client/components/jant-compose-editor.ts +111 -0
- package/src/client/components/jant-nav-manager.ts +142 -2
- package/src/client/components/jant-settings-general.ts +56 -0
- package/src/client/components/nav-manager-types.ts +14 -0
- package/src/client/components/settings-types.ts +30 -0
- package/src/client/compose-bridge.ts +25 -8
- package/src/client/compose-launch.ts +30 -0
- package/src/client/compose-shortcuts.ts +46 -3
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +143 -0
- package/src/client/tiptap/__tests__/paste-rehost-e2e.test.ts +65 -0
- package/src/client/tiptap/__tests__/rehost-images.test.ts +192 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +4 -0
- package/src/client/tiptap/inline-image-upload.ts +174 -50
- package/src/client/tiptap/rehost-images.ts +108 -0
- package/src/i18n/__tests__/fallback.test.ts +24 -0
- package/src/i18n/locales/public/en.po +10 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +10 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +10 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +54 -0
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +64 -10
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +56 -2
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +96 -10
- package/src/lib/__tests__/upload-sideload.test.ts +78 -0
- package/src/lib/__tests__/url-fetch.test.ts +181 -0
- package/src/lib/feed.ts +63 -3
- package/src/lib/post-display.ts +14 -8
- package/src/lib/upload.ts +111 -0
- package/src/lib/url-fetch.ts +263 -0
- package/src/preset.css +60 -18
- package/src/routes/api/__tests__/uploads.test.ts +63 -1
- package/src/routes/api/uploads.ts +52 -0
- package/src/routes/dash/settings.tsx +41 -2
- package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
- package/src/routes/pages/page.tsx +44 -12
- package/src/services/__tests__/about-page.test.ts +116 -0
- package/src/services/__tests__/media.test.ts +168 -1
- package/src/services/__tests__/navigation.test.ts +190 -1
- package/src/services/__tests__/post-timeline.test.ts +119 -2
- package/src/services/about-page.ts +143 -0
- package/src/services/export-theme/layouts/_default/rss.xml +2 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
- package/src/services/export-theme/styles/main.css +46 -14
- package/src/services/index.ts +27 -17
- package/src/services/media.ts +111 -0
- package/src/services/navigation.ts +155 -1
- package/src/services/post.ts +24 -7
- package/src/styles/components.css +20 -0
- package/src/styles/tokens.css +26 -8
- package/src/styles/ui.css +149 -7
- package/src/types/views.ts +13 -0
- package/src/ui/compose/ComposeDialog.tsx +16 -0
- package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
- package/src/ui/dash/settings/GeneralContent.tsx +44 -0
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
- package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
- package/src/ui/layouts/BaseLayout.tsx +12 -0
- package/dist/app-D24n0DoH.js +0 -6
- package/dist/client/_assets/client-xWDl78yi.css +0 -2
|
@@ -15,7 +15,11 @@ vi.mock("sortablejs", () => ({
|
|
|
15
15
|
},
|
|
16
16
|
}));
|
|
17
17
|
|
|
18
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
NavManagerItem,
|
|
20
|
+
NavManagerLabels,
|
|
21
|
+
NavManagerSuggestedLink,
|
|
22
|
+
} from "../nav-manager-types.js";
|
|
19
23
|
import "../jant-nav-manager.js";
|
|
20
24
|
import type { JantNavManager } from "../jant-nav-manager.js";
|
|
21
25
|
|
|
@@ -43,6 +47,10 @@ const labels: NavManagerLabels = {
|
|
|
43
47
|
addLinkDescription: "Add a custom nav link.",
|
|
44
48
|
urlPlaceholder: "/about",
|
|
45
49
|
labelAndUrlRequired: "Add a label and URL.",
|
|
50
|
+
suggestedLinks: "Suggested links",
|
|
51
|
+
suggestedLinksDescription: "Add common destinations.",
|
|
52
|
+
addSuggestedLink: "Add",
|
|
53
|
+
suggestedLinkAdded: "Link added to navigation.",
|
|
46
54
|
headerSection: "Header",
|
|
47
55
|
moreSection: "More",
|
|
48
56
|
moreEmptyHint: "Move links here to hide them under More.",
|
|
@@ -84,6 +92,18 @@ const items: NavManagerItem[] = [
|
|
|
84
92
|
},
|
|
85
93
|
];
|
|
86
94
|
|
|
95
|
+
const suggestedLinks: NavManagerSuggestedLink[] = [
|
|
96
|
+
{
|
|
97
|
+
key: "now",
|
|
98
|
+
label: "Now",
|
|
99
|
+
url: "/now",
|
|
100
|
+
targetType: "collection",
|
|
101
|
+
targetLabel: "Collection",
|
|
102
|
+
navItemType: "collection",
|
|
103
|
+
collectionId: "col_now",
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
87
107
|
function requireElement<T>(value: T | null, message: string): T {
|
|
88
108
|
if (!value) {
|
|
89
109
|
throw new Error(message);
|
|
@@ -133,6 +153,7 @@ async function createElement(): Promise<JantNavManager> {
|
|
|
133
153
|
el.labels = labels;
|
|
134
154
|
el.items = items;
|
|
135
155
|
el.systemNavItems = [];
|
|
156
|
+
el.suggestedLinks = [];
|
|
136
157
|
el.siteName = "Test Site";
|
|
137
158
|
document.body.appendChild(el);
|
|
138
159
|
await el.updateComplete;
|
|
@@ -251,6 +272,61 @@ describe("JantNavManager", () => {
|
|
|
251
272
|
expect(popover.getAttribute("aria-hidden")).toBe("true");
|
|
252
273
|
});
|
|
253
274
|
|
|
275
|
+
it("adds a suggested collection link through the nav items API", async () => {
|
|
276
|
+
const el = await createElement();
|
|
277
|
+
el.suggestedLinks = suggestedLinks;
|
|
278
|
+
el.requestUpdate();
|
|
279
|
+
await el.updateComplete;
|
|
280
|
+
|
|
281
|
+
const addButton = requireElement(
|
|
282
|
+
el.querySelector<HTMLButtonElement>(".nav-suggestion-item button"),
|
|
283
|
+
"expected suggested link add button",
|
|
284
|
+
);
|
|
285
|
+
expect(el.textContent).toContain("Now");
|
|
286
|
+
expect(el.textContent).toContain("/now · Collection");
|
|
287
|
+
|
|
288
|
+
const created: NavManagerItem = {
|
|
289
|
+
id: "nav-now",
|
|
290
|
+
type: "collection",
|
|
291
|
+
collectionId: "col_now",
|
|
292
|
+
label: "Now",
|
|
293
|
+
url: "/now",
|
|
294
|
+
placement: "header",
|
|
295
|
+
};
|
|
296
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
297
|
+
ok: true,
|
|
298
|
+
status: 201,
|
|
299
|
+
json: async () => created,
|
|
300
|
+
});
|
|
301
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
302
|
+
|
|
303
|
+
addButton.click();
|
|
304
|
+
await Promise.resolve();
|
|
305
|
+
await Promise.resolve();
|
|
306
|
+
await el.updateComplete;
|
|
307
|
+
|
|
308
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
309
|
+
"/api/nav-items",
|
|
310
|
+
expect.objectContaining({
|
|
311
|
+
method: "POST",
|
|
312
|
+
body: JSON.stringify({
|
|
313
|
+
type: "collection",
|
|
314
|
+
collectionId: "col_now",
|
|
315
|
+
placement: "header",
|
|
316
|
+
}),
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
expect(
|
|
320
|
+
getListIds(
|
|
321
|
+
requireElement(
|
|
322
|
+
el.querySelector<HTMLElement>("#nav-items-header"),
|
|
323
|
+
"expected header nav list",
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
).toContain("nav-now");
|
|
327
|
+
expect(el.querySelector(".nav-suggestion-item")).toBeNull();
|
|
328
|
+
});
|
|
329
|
+
|
|
254
330
|
it("confirms before dispatching nav deletion", async () => {
|
|
255
331
|
const el = await createElement();
|
|
256
332
|
const deleteHandler = vi.fn();
|
|
@@ -32,6 +32,12 @@ const labels: SettingsLabels = {
|
|
|
32
32
|
uploadError: "Upload failed.",
|
|
33
33
|
general: "General",
|
|
34
34
|
site: "Site",
|
|
35
|
+
aboutPage: "About page",
|
|
36
|
+
aboutPagePrompt: "Want to write a fuller introduction?",
|
|
37
|
+
aboutPageConflict:
|
|
38
|
+
"/about is already used. Rename that item before creating an About page.",
|
|
39
|
+
createAboutPage: "Create About page",
|
|
40
|
+
editAboutPage: "Edit About page",
|
|
35
41
|
languageAndTime: "Language & Time",
|
|
36
42
|
home: "Home",
|
|
37
43
|
search: "Search",
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
SettingsCjkFont,
|
|
8
8
|
SettingsDashboardLanguage,
|
|
9
9
|
SettingsSaveDetail,
|
|
10
|
+
SettingsAboutPageStatus,
|
|
10
11
|
} from "../settings-types.js";
|
|
11
12
|
import { MAX_SITE_NAME_LENGTH } from "../../../types.js";
|
|
12
13
|
import "../jant-settings-general.js";
|
|
@@ -76,6 +77,12 @@ const labels: SettingsLabels = {
|
|
|
76
77
|
uploadError: "Upload failed.",
|
|
77
78
|
general: "General",
|
|
78
79
|
site: "Site",
|
|
80
|
+
aboutPage: "About page",
|
|
81
|
+
aboutPagePrompt: "Want to write a fuller introduction?",
|
|
82
|
+
aboutPageConflict:
|
|
83
|
+
"/about is already used. Rename that item before creating an About page.",
|
|
84
|
+
createAboutPage: "Create About page",
|
|
85
|
+
editAboutPage: "Edit About page",
|
|
79
86
|
languageAndTime: "Language & Time",
|
|
80
87
|
home: "Home",
|
|
81
88
|
search: "Search",
|
|
@@ -167,6 +174,7 @@ function findCheckboxByLabel(
|
|
|
167
174
|
async function createElement(
|
|
168
175
|
opts: {
|
|
169
176
|
demoMode?: boolean;
|
|
177
|
+
aboutPage?: SettingsAboutPageStatus;
|
|
170
178
|
} = {},
|
|
171
179
|
): Promise<JantSettingsGeneral> {
|
|
172
180
|
const el = document.createElement(
|
|
@@ -182,6 +190,14 @@ async function createElement(
|
|
|
182
190
|
el.latestFeedUrl = "/latest/feed";
|
|
183
191
|
el.featuredFeedUrl = "/featured/feed";
|
|
184
192
|
el.archiveFeedUrl = "/archive/feed";
|
|
193
|
+
el.aboutPage =
|
|
194
|
+
opts.aboutPage ??
|
|
195
|
+
({
|
|
196
|
+
state: "missing",
|
|
197
|
+
path: "/about",
|
|
198
|
+
} satisfies SettingsAboutPageStatus);
|
|
199
|
+
el.aboutEditUrl = "/about?edit=1";
|
|
200
|
+
el.aboutCreateUrl = "/settings/general/about-page";
|
|
185
201
|
el.demoMode = opts.demoMode ?? false;
|
|
186
202
|
document.body.appendChild(el);
|
|
187
203
|
await el.updateComplete;
|
|
@@ -209,6 +225,61 @@ describe("JantSettingsGeneral", () => {
|
|
|
209
225
|
labels.home,
|
|
210
226
|
labels.search,
|
|
211
227
|
]);
|
|
228
|
+
|
|
229
|
+
const siteSection = requireElement(
|
|
230
|
+
findSectionByHeading(el, labels.site),
|
|
231
|
+
"expected site section",
|
|
232
|
+
);
|
|
233
|
+
expect(siteSection.querySelector("[data-about-page-row]")).not.toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("renders a create About form from the missing About prompt", async () => {
|
|
237
|
+
const el = await createElement();
|
|
238
|
+
const aboutRow = requireElement(
|
|
239
|
+
el.querySelector<HTMLElement>("[data-about-page-row]"),
|
|
240
|
+
"expected About page row",
|
|
241
|
+
);
|
|
242
|
+
const form = requireElement(
|
|
243
|
+
aboutRow.querySelector<HTMLFormElement>("form"),
|
|
244
|
+
"expected create About page form",
|
|
245
|
+
);
|
|
246
|
+
const button = requireElement(
|
|
247
|
+
form.querySelector<HTMLButtonElement>("button"),
|
|
248
|
+
"expected create About page button",
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(aboutRow.textContent).toContain(labels.aboutPagePrompt);
|
|
252
|
+
expect(form.method).toBe("post");
|
|
253
|
+
expect(form.action).toContain("/settings/general/about-page");
|
|
254
|
+
expect(button.textContent).toContain(labels.createAboutPage);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("renders an edit link when the About page exists", async () => {
|
|
258
|
+
const el = await createElement({
|
|
259
|
+
aboutPage: {
|
|
260
|
+
state: "ready",
|
|
261
|
+
path: "/about",
|
|
262
|
+
post: {
|
|
263
|
+
id: "pst_about000000000000000000000",
|
|
264
|
+
title: "About",
|
|
265
|
+
status: "published",
|
|
266
|
+
visibility: "latest_hidden",
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const aboutRow = requireElement(
|
|
272
|
+
el.querySelector<HTMLElement>("[data-about-page-row]"),
|
|
273
|
+
"expected About page row",
|
|
274
|
+
);
|
|
275
|
+
const editLink = requireElement(
|
|
276
|
+
aboutRow.querySelector<HTMLAnchorElement>("a"),
|
|
277
|
+
"expected About edit link",
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
expect(aboutRow.textContent).toContain(labels.aboutPagePrompt);
|
|
281
|
+
expect(editLink.textContent).toContain(labels.editAboutPage);
|
|
282
|
+
expect(editLink.href).toContain("/about?edit=1");
|
|
212
283
|
});
|
|
213
284
|
|
|
214
285
|
it("renders form fields with initial values", async () => {
|
|
@@ -145,6 +145,10 @@ export interface ComposeLabels {
|
|
|
145
145
|
addMore: string;
|
|
146
146
|
removeAttachment: string;
|
|
147
147
|
uploading: string;
|
|
148
|
+
/** Toast when exactly one pasted remote image couldn't be rehosted. */
|
|
149
|
+
imageNotRehosted?: string;
|
|
150
|
+
/** Toast when several pasted remote images couldn't be rehosted (uses a {count} placeholder). */
|
|
151
|
+
imagesNotRehosted?: string;
|
|
148
152
|
loadingPost: string;
|
|
149
153
|
loadPostFailed: string;
|
|
150
154
|
published: string;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* - Navigate (default): filter preloaded posts, collections, system pages
|
|
6
6
|
* - Command (`>` prefix): run actions like New Post
|
|
7
7
|
* - Search (`?` prefix): redirects to /search?q=... on Enter
|
|
8
|
+
* - Path (`/` prefix): opens a local path, with search as the second option
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { LitElement, html, nothing } from "lit";
|
|
@@ -45,10 +46,24 @@ const ICONS = {
|
|
|
45
46
|
system: `<svg ${ICON_ATTRS}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`,
|
|
46
47
|
// zap/lightning for commands
|
|
47
48
|
command: `<svg ${ICON_ATTRS}><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>`,
|
|
49
|
+
// corner-down-right for local path navigation
|
|
50
|
+
path: `<svg ${ICON_ATTRS}><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg>`,
|
|
48
51
|
// search
|
|
49
52
|
search: `<svg ${ICON_ATTRS}><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
|
|
50
53
|
};
|
|
51
54
|
|
|
55
|
+
export function getPathCommandTarget(query: string): string {
|
|
56
|
+
const stripped = query.trim().replace(/^[//]+/, "");
|
|
57
|
+
return stripped ? `/${stripped}` : "/";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function getPathCommandSearchQuery(query: string): string {
|
|
61
|
+
return query
|
|
62
|
+
.trim()
|
|
63
|
+
.replace(/^[//]+/, "")
|
|
64
|
+
.trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
// ---------------------------------------------------------------------------
|
|
53
68
|
// History helpers
|
|
54
69
|
// ---------------------------------------------------------------------------
|
|
@@ -208,12 +223,13 @@ export class JantCommandPalette extends LitElement {
|
|
|
208
223
|
// Mode detection
|
|
209
224
|
// -----------------------------------------------------------------------
|
|
210
225
|
|
|
211
|
-
get #mode(): "navigate" | "command" | "search" {
|
|
212
|
-
// Accept both halfwidth
|
|
226
|
+
get #mode(): "navigate" | "command" | "search" | "path" {
|
|
227
|
+
// Accept both halfwidth and fullwidth prefixes so CJK
|
|
213
228
|
// users don't have to toggle their IME to enter command/search mode.
|
|
214
229
|
const first = this._query.charAt(0);
|
|
215
230
|
if (first === ">" || first === ">") return "command";
|
|
216
231
|
if (first === "?" || first === "?") return "search";
|
|
232
|
+
if (first === "/" || first === "/") return "path";
|
|
217
233
|
return "navigate";
|
|
218
234
|
}
|
|
219
235
|
|
|
@@ -270,6 +286,8 @@ export class JantCommandPalette extends LitElement {
|
|
|
270
286
|
icon: string;
|
|
271
287
|
/** For search-mode items: the query to execute */
|
|
272
288
|
searchQuery?: string;
|
|
289
|
+
/** For path-mode items: the local path to open */
|
|
290
|
+
pathTarget?: string;
|
|
273
291
|
}> {
|
|
274
292
|
const mode = this.#mode;
|
|
275
293
|
|
|
@@ -299,12 +317,41 @@ export class JantCommandPalette extends LitElement {
|
|
|
299
317
|
];
|
|
300
318
|
}
|
|
301
319
|
|
|
320
|
+
if (mode === "path") {
|
|
321
|
+
const target = getPathCommandTarget(this._query);
|
|
322
|
+
const searchQuery = getPathCommandSearchQuery(this._query);
|
|
323
|
+
const items: Array<{
|
|
324
|
+
label: string;
|
|
325
|
+
secondary?: string;
|
|
326
|
+
icon: string;
|
|
327
|
+
searchQuery?: string;
|
|
328
|
+
pathTarget?: string;
|
|
329
|
+
}> = [
|
|
330
|
+
{
|
|
331
|
+
label: `Go to ${target}`,
|
|
332
|
+
icon: ICONS.path,
|
|
333
|
+
pathTarget: target,
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
if (searchQuery) {
|
|
338
|
+
items.push({
|
|
339
|
+
label: `Search for "${searchQuery}"`,
|
|
340
|
+
icon: ICONS.search,
|
|
341
|
+
searchQuery,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return items;
|
|
346
|
+
}
|
|
347
|
+
|
|
302
348
|
// Navigate mode — show all items when no query (autocomplete)
|
|
303
349
|
const navItems: Array<{
|
|
304
350
|
label: string;
|
|
305
351
|
secondary?: string;
|
|
306
352
|
icon: string;
|
|
307
353
|
searchQuery?: string;
|
|
354
|
+
pathTarget?: string;
|
|
308
355
|
}> = this.#navigateItems.map((item) => ({
|
|
309
356
|
label: item.title,
|
|
310
357
|
secondary: item.type === "system" ? item.path : item.path,
|
|
@@ -351,6 +398,24 @@ export class JantCommandPalette extends LitElement {
|
|
|
351
398
|
return;
|
|
352
399
|
}
|
|
353
400
|
|
|
401
|
+
if (mode === "path") {
|
|
402
|
+
const displayItem = this.#displayItems[index];
|
|
403
|
+
if (displayItem?.pathTarget) {
|
|
404
|
+
saveHistory(NAV_HISTORY_KEY, displayItem.pathTarget);
|
|
405
|
+
this.close();
|
|
406
|
+
window.location.href = displayItem.pathTarget;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (displayItem?.searchQuery) {
|
|
411
|
+
const q = displayItem.searchQuery;
|
|
412
|
+
saveHistory(SEARCH_HISTORY_KEY, q);
|
|
413
|
+
this.close();
|
|
414
|
+
window.location.href = `/search?q=${encodeURIComponent(q)}`;
|
|
415
|
+
}
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
354
419
|
// Navigate mode — check if the selected item is a search fallback
|
|
355
420
|
const displayItem = this.#displayItems[index];
|
|
356
421
|
if (displayItem?.searchQuery) {
|
|
@@ -5432,7 +5432,11 @@ export class JantComposeDialog extends LitElement {
|
|
|
5432
5432
|
|
|
5433
5433
|
return html`
|
|
5434
5434
|
<div
|
|
5435
|
-
class
|
|
5435
|
+
class=${classMap({
|
|
5436
|
+
"compose-thread-layout": true,
|
|
5437
|
+
"compose-thread-compose-layout": true,
|
|
5438
|
+
"compose-reply-compose-layout": isReply,
|
|
5439
|
+
})}
|
|
5436
5440
|
@jant:compose-content-changed=${() => this._scheduleDraftSave()}
|
|
5437
5441
|
>
|
|
5438
5442
|
${isReply ? this._renderReplyContext() : nothing}
|
|
@@ -5502,7 +5506,9 @@ export class JantComposeDialog extends LitElement {
|
|
|
5502
5506
|
? this._renderThreadComposeLayout()
|
|
5503
5507
|
: isReply
|
|
5504
5508
|
? html`
|
|
5505
|
-
<div
|
|
5509
|
+
<div
|
|
5510
|
+
class="compose-thread-layout compose-reply-compose-layout"
|
|
5511
|
+
>
|
|
5506
5512
|
${this._renderReplyContext()}
|
|
5507
5513
|
<div
|
|
5508
5514
|
class="compose-editor-row"
|
|
@@ -46,7 +46,11 @@ import { createTiptapEditor } from "../tiptap/create-editor.js";
|
|
|
46
46
|
import {
|
|
47
47
|
uploadAndInsertInlineImage,
|
|
48
48
|
adoptPendingInlineImageUploads,
|
|
49
|
+
rehostInlineImage,
|
|
50
|
+
sideloadImage,
|
|
49
51
|
} from "../tiptap/inline-image-upload.js";
|
|
52
|
+
import { clearRehostInFlight } from "../tiptap/rehost-images.js";
|
|
53
|
+
import { uploadWithMetadata } from "../upload-with-metadata.js";
|
|
50
54
|
import { getClipboardFiles } from "../tiptap/paste-media.js";
|
|
51
55
|
import { isSafeAbsoluteUrl } from "../../lib/url.js";
|
|
52
56
|
import { randomUUID } from "../random-uuid.js";
|
|
@@ -247,6 +251,8 @@ export class JantComposeEditor extends LitElement {
|
|
|
247
251
|
private _suppressContentChangedOnce = false;
|
|
248
252
|
#inlineImageUploadGeneration = 0;
|
|
249
253
|
#inlineImageUploadPromises = new Set<Promise<void>>();
|
|
254
|
+
#rehostFailureCount = 0;
|
|
255
|
+
#rehostFailureNoticeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
250
256
|
#sortable: { destroy(): void } | null = null;
|
|
251
257
|
#revertNextSibling: globalThis.Node | null = null;
|
|
252
258
|
|
|
@@ -429,6 +435,102 @@ export class JantComposeEditor extends LitElement {
|
|
|
429
435
|
return uploadPromise;
|
|
430
436
|
}
|
|
431
437
|
|
|
438
|
+
/**
|
|
439
|
+
* Decide whether a pasted image node's `src` should be rehosted into our own
|
|
440
|
+
* storage. Skips images that are already ours (same-origin or on the
|
|
441
|
+
* configured media CDN), relative paths, and `blob:` placeholders (handled by
|
|
442
|
+
* the insert flow). Remote `http(s)` and inline `data:` srcs are rehosted.
|
|
443
|
+
*/
|
|
444
|
+
#shouldRehostSrc(src: string): boolean {
|
|
445
|
+
if (src.startsWith("data:")) return true;
|
|
446
|
+
if (!/^https?:\/\//i.test(src)) return false;
|
|
447
|
+
let origin: string;
|
|
448
|
+
try {
|
|
449
|
+
origin = new URL(src).origin;
|
|
450
|
+
} catch {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
if (origin === window.location.origin) return false;
|
|
454
|
+
const mediaBase = document.documentElement.dataset.mediaBase;
|
|
455
|
+
if (mediaBase && src.startsWith(mediaBase)) return false;
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Rehost a pasted inline image into our storage and swap its `src`. Remote
|
|
461
|
+
* URLs go through the server sideload endpoint (bypasses CORS); `data:` URLs
|
|
462
|
+
* are decoded locally and run through the normal client upload pipeline
|
|
463
|
+
* (gaining WebP/resize/blurhash). Tracked like other inline uploads so submit
|
|
464
|
+
* waits for it; failures leave the node with its original src.
|
|
465
|
+
*/
|
|
466
|
+
#rehostInlineImage(src: string) {
|
|
467
|
+
const editor = this._editor;
|
|
468
|
+
if (!editor) {
|
|
469
|
+
clearRehostInFlight(src);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const resolver = src.startsWith("data:")
|
|
474
|
+
? async () => {
|
|
475
|
+
const blob = await (await fetch(src)).blob();
|
|
476
|
+
const file = new File([blob], "pasted-image", {
|
|
477
|
+
type: blob.type || "image/png",
|
|
478
|
+
});
|
|
479
|
+
return (await uploadWithMetadata(file)).url;
|
|
480
|
+
}
|
|
481
|
+
: async () => (await sideloadImage(src)).url;
|
|
482
|
+
|
|
483
|
+
// Surface failures so a blocked rehost (e.g. a host's hotlink protection)
|
|
484
|
+
// isn't silent — the node keeps its original link, but the author is told.
|
|
485
|
+
const trackedResolver = async () => {
|
|
486
|
+
try {
|
|
487
|
+
return await resolver();
|
|
488
|
+
} catch (error) {
|
|
489
|
+
this.#noteRehostFailure();
|
|
490
|
+
throw error;
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const generation = this.#inlineImageUploadGeneration;
|
|
495
|
+
const rehostPromise = rehostInlineImage(
|
|
496
|
+
editor,
|
|
497
|
+
src,
|
|
498
|
+
trackedResolver,
|
|
499
|
+
).finally(() => {
|
|
500
|
+
clearRehostInFlight(src);
|
|
501
|
+
if (generation !== this.#inlineImageUploadGeneration) return;
|
|
502
|
+
this.#inlineImageUploadPromises.delete(rehostPromise);
|
|
503
|
+
});
|
|
504
|
+
this.#inlineImageUploadPromises.add(rehostPromise);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Count a failed rehost and debounce a single batched notice. */
|
|
508
|
+
#noteRehostFailure() {
|
|
509
|
+
this.#rehostFailureCount += 1;
|
|
510
|
+
if (this.#rehostFailureNoticeTimer !== undefined) return;
|
|
511
|
+
this.#rehostFailureNoticeTimer = setTimeout(() => {
|
|
512
|
+
this.#flushRehostFailureNotice();
|
|
513
|
+
}, 800);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Show one toast summarizing all rehost failures collected in the window. */
|
|
517
|
+
#flushRehostFailureNotice() {
|
|
518
|
+
this.#rehostFailureNoticeTimer = undefined;
|
|
519
|
+
const count = this.#rehostFailureCount;
|
|
520
|
+
this.#rehostFailureCount = 0;
|
|
521
|
+
if (count <= 0 || !this.isConnected) return;
|
|
522
|
+
|
|
523
|
+
const message =
|
|
524
|
+
count === 1
|
|
525
|
+
? (this.labels.imageNotRehosted ??
|
|
526
|
+
"An image couldn't be saved to your library — its original link was kept.")
|
|
527
|
+
: (
|
|
528
|
+
this.labels.imagesNotRehosted ??
|
|
529
|
+
"{count} images couldn't be saved to your library — their original links were kept."
|
|
530
|
+
).replace("{count}", String(count));
|
|
531
|
+
showToast(message, "error");
|
|
532
|
+
}
|
|
533
|
+
|
|
432
534
|
hasPendingInlineImageUploads(): boolean {
|
|
433
535
|
return this.#inlineImageUploadPromises.size > 0;
|
|
434
536
|
}
|
|
@@ -446,6 +548,11 @@ export class JantComposeEditor extends LitElement {
|
|
|
446
548
|
#clearPendingInlineImageUploads() {
|
|
447
549
|
this.#inlineImageUploadGeneration += 1;
|
|
448
550
|
this.#inlineImageUploadPromises.clear();
|
|
551
|
+
if (this.#rehostFailureNoticeTimer !== undefined) {
|
|
552
|
+
clearTimeout(this.#rehostFailureNoticeTimer);
|
|
553
|
+
this.#rehostFailureNoticeTimer = undefined;
|
|
554
|
+
}
|
|
555
|
+
this.#rehostFailureCount = 0;
|
|
449
556
|
}
|
|
450
557
|
|
|
451
558
|
/** Adopt in-flight inline image uploads from another editor (e.g. fullscreen). */
|
|
@@ -824,6 +931,10 @@ export class JantComposeEditor extends LitElement {
|
|
|
824
931
|
this.addFiles(files);
|
|
825
932
|
},
|
|
826
933
|
},
|
|
934
|
+
rehostImages: {
|
|
935
|
+
shouldRehost: (src) => this.#shouldRehostSrc(src),
|
|
936
|
+
rehost: (src) => this.#rehostInlineImage(src),
|
|
937
|
+
},
|
|
827
938
|
});
|
|
828
939
|
this._lastEditorSelection = this._readEditorSelection();
|
|
829
940
|
|