@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
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
NavManagerLabels,
|
|
34
34
|
NavManagerUpdateDetail,
|
|
35
35
|
NavManagerDeleteDetail,
|
|
36
|
+
NavManagerSuggestedLink,
|
|
36
37
|
SystemNavConfig,
|
|
37
38
|
} from "./nav-manager-types.js";
|
|
38
39
|
|
|
@@ -42,6 +43,7 @@ export class JantNavManager extends LitElement {
|
|
|
42
43
|
labels: { type: Object },
|
|
43
44
|
systemNavItems: { type: Array, attribute: "system-nav-items" },
|
|
44
45
|
collections: { type: Array },
|
|
46
|
+
suggestedLinks: { type: Array, attribute: "suggested-links" },
|
|
45
47
|
siteName: { type: String, attribute: "site-name" },
|
|
46
48
|
|
|
47
49
|
_items: { state: true },
|
|
@@ -56,12 +58,14 @@ export class JantNavManager extends LitElement {
|
|
|
56
58
|
_showPreviewMore: { state: true },
|
|
57
59
|
_addingCollectionId: { state: true },
|
|
58
60
|
_showCollectionPicker: { state: true },
|
|
61
|
+
_addingSuggestedKey: { state: true },
|
|
59
62
|
};
|
|
60
63
|
|
|
61
64
|
declare items: NavManagerItem[];
|
|
62
65
|
declare labels: NavManagerLabels;
|
|
63
66
|
declare systemNavItems: SystemNavConfig[];
|
|
64
67
|
declare collections: NavManagerCollection[];
|
|
68
|
+
declare suggestedLinks: NavManagerSuggestedLink[];
|
|
65
69
|
declare siteName: string;
|
|
66
70
|
|
|
67
71
|
declare _items: NavManagerItem[];
|
|
@@ -78,6 +82,8 @@ export class JantNavManager extends LitElement {
|
|
|
78
82
|
/** ID of the collection currently being added (for loading state) */
|
|
79
83
|
declare _addingCollectionId: string | null;
|
|
80
84
|
declare _showCollectionPicker: boolean;
|
|
85
|
+
/** Key of the suggested link currently being added */
|
|
86
|
+
declare _addingSuggestedKey: string | null;
|
|
81
87
|
|
|
82
88
|
#sortableHeader: { destroy(): void } | null = null;
|
|
83
89
|
#sortableMore: { destroy(): void } | null = null;
|
|
@@ -124,6 +130,7 @@ export class JantNavManager extends LitElement {
|
|
|
124
130
|
this.labels = {} as NavManagerLabels;
|
|
125
131
|
this.systemNavItems = [];
|
|
126
132
|
this.collections = [];
|
|
133
|
+
this.suggestedLinks = [];
|
|
127
134
|
this.siteName = "";
|
|
128
135
|
|
|
129
136
|
this._items = [];
|
|
@@ -138,6 +145,7 @@ export class JantNavManager extends LitElement {
|
|
|
138
145
|
this._showPreviewMore = false;
|
|
139
146
|
this._addingCollectionId = null;
|
|
140
147
|
this._showCollectionPicker = false;
|
|
148
|
+
this._addingSuggestedKey = null;
|
|
141
149
|
}
|
|
142
150
|
|
|
143
151
|
protected update(changedProperties: PropertyValueMap<JantNavManager>): void {
|
|
@@ -448,6 +456,99 @@ export class JantNavManager extends LitElement {
|
|
|
448
456
|
}
|
|
449
457
|
}
|
|
450
458
|
|
|
459
|
+
// ===========================================================================
|
|
460
|
+
// Suggested link handlers
|
|
461
|
+
// ===========================================================================
|
|
462
|
+
|
|
463
|
+
#getComparableInternalPath(url: string): string | null {
|
|
464
|
+
if (
|
|
465
|
+
url.startsWith("http://") ||
|
|
466
|
+
url.startsWith("https://") ||
|
|
467
|
+
url.startsWith("//") ||
|
|
468
|
+
url.startsWith("mailto:") ||
|
|
469
|
+
url.startsWith("tel:") ||
|
|
470
|
+
url.startsWith("#")
|
|
471
|
+
) {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const pathname = new URL(url, "https://jant.invalid").pathname;
|
|
477
|
+
const normalized = pathname
|
|
478
|
+
.trim()
|
|
479
|
+
.toLowerCase()
|
|
480
|
+
.replace(/^\/+|\/+$/g, "")
|
|
481
|
+
.replace(/\/+/g, "/");
|
|
482
|
+
return normalized ? `/${normalized}` : "/";
|
|
483
|
+
} catch {
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#isSuggestedLinkAdded(link: NavManagerSuggestedLink): boolean {
|
|
489
|
+
const path = this.#getComparableInternalPath(link.url);
|
|
490
|
+
const hasMatchingPath =
|
|
491
|
+
path !== null &&
|
|
492
|
+
this._items.some(
|
|
493
|
+
(item) => this.#getComparableInternalPath(item.url) === path,
|
|
494
|
+
);
|
|
495
|
+
if (hasMatchingPath) return true;
|
|
496
|
+
|
|
497
|
+
return Boolean(
|
|
498
|
+
link.collectionId &&
|
|
499
|
+
this._items.some(
|
|
500
|
+
(item) =>
|
|
501
|
+
item.type === "collection" && item.collectionId === link.collectionId,
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
get #availableSuggestedLinks(): NavManagerSuggestedLink[] {
|
|
507
|
+
return (this.suggestedLinks ?? []).filter(
|
|
508
|
+
(link) => !this.#isSuggestedLinkAdded(link),
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async #handleAddSuggestedLink(link: NavManagerSuggestedLink) {
|
|
513
|
+
if (this._addingSuggestedKey) return;
|
|
514
|
+
|
|
515
|
+
this._addingSuggestedKey = link.key;
|
|
516
|
+
try {
|
|
517
|
+
const body =
|
|
518
|
+
link.navItemType === "collection" && link.collectionId
|
|
519
|
+
? {
|
|
520
|
+
type: "collection",
|
|
521
|
+
collectionId: link.collectionId,
|
|
522
|
+
placement: "header",
|
|
523
|
+
}
|
|
524
|
+
: {
|
|
525
|
+
type: "link",
|
|
526
|
+
label: link.label,
|
|
527
|
+
url: link.url,
|
|
528
|
+
placement: "header",
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const res = await fetch("/api/nav-items", {
|
|
532
|
+
method: "POST",
|
|
533
|
+
headers: {
|
|
534
|
+
"Content-Type": "application/json",
|
|
535
|
+
Accept: "application/json",
|
|
536
|
+
},
|
|
537
|
+
body: JSON.stringify(body),
|
|
538
|
+
});
|
|
539
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
540
|
+
|
|
541
|
+
const created: NavManagerItem = await res.json();
|
|
542
|
+
this.#destroySortables();
|
|
543
|
+
this._items = [...this._items, created];
|
|
544
|
+
showToast(this.labels.suggestedLinkAdded);
|
|
545
|
+
} catch {
|
|
546
|
+
showToast(this.labels.saveFailed, "error");
|
|
547
|
+
} finally {
|
|
548
|
+
this._addingSuggestedKey = null;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
451
552
|
// ===========================================================================
|
|
452
553
|
// System toggle handlers
|
|
453
554
|
// ===========================================================================
|
|
@@ -834,6 +935,45 @@ export class JantNavManager extends LitElement {
|
|
|
834
935
|
`;
|
|
835
936
|
}
|
|
836
937
|
|
|
938
|
+
#renderSuggestedLinksSection() {
|
|
939
|
+
const available = this.#availableSuggestedLinks;
|
|
940
|
+
if (available.length === 0) return nothing;
|
|
941
|
+
|
|
942
|
+
return html`
|
|
943
|
+
<section class="mt-8">
|
|
944
|
+
<h2 class="text-lg font-semibold mb-1">
|
|
945
|
+
${this.labels.suggestedLinks}
|
|
946
|
+
</h2>
|
|
947
|
+
<p class="text-sm text-muted-foreground mb-3">
|
|
948
|
+
${this.labels.suggestedLinksDescription}
|
|
949
|
+
</p>
|
|
950
|
+
<div class="nav-suggestions-list">
|
|
951
|
+
${available.map((link) => {
|
|
952
|
+
const adding = this._addingSuggestedKey === link.key;
|
|
953
|
+
return html`
|
|
954
|
+
<div class="nav-suggestion-item">
|
|
955
|
+
<div class="nav-suggestion-info">
|
|
956
|
+
<span class="nav-suggestion-title">${link.label}</span>
|
|
957
|
+
<span class="nav-suggestion-meta">
|
|
958
|
+
${link.url} · ${link.targetLabel}
|
|
959
|
+
</span>
|
|
960
|
+
</div>
|
|
961
|
+
<button
|
|
962
|
+
type="button"
|
|
963
|
+
class="btn-sm"
|
|
964
|
+
?disabled=${adding || this._addingSuggestedKey !== null}
|
|
965
|
+
@click=${() => this.#handleAddSuggestedLink(link)}
|
|
966
|
+
>
|
|
967
|
+
${this.labels.addSuggestedLink}
|
|
968
|
+
</button>
|
|
969
|
+
</div>
|
|
970
|
+
`;
|
|
971
|
+
})}
|
|
972
|
+
</div>
|
|
973
|
+
</section>
|
|
974
|
+
`;
|
|
975
|
+
}
|
|
976
|
+
|
|
837
977
|
#renderAddLinkSection() {
|
|
838
978
|
return html`
|
|
839
979
|
<section class="mt-8">
|
|
@@ -1175,8 +1315,8 @@ export class JantNavManager extends LitElement {
|
|
|
1175
1315
|
</div>
|
|
1176
1316
|
</section>
|
|
1177
1317
|
|
|
1178
|
-
${this.#
|
|
1179
|
-
${this.#renderSystemToggles()}
|
|
1318
|
+
${this.#renderSuggestedLinksSection()} ${this.#renderAddLinkSection()}
|
|
1319
|
+
${this.#renderAddCollectionSection()} ${this.#renderSystemToggles()}
|
|
1180
1320
|
`;
|
|
1181
1321
|
}
|
|
1182
1322
|
}
|
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
SettingsTimezone,
|
|
22
22
|
SettingsCjkFont,
|
|
23
23
|
SettingsDashboardLanguage,
|
|
24
|
+
SettingsAboutPageStatus,
|
|
24
25
|
} from "./settings-types.js";
|
|
25
26
|
import { showToast } from "../toast.js";
|
|
26
27
|
import {
|
|
@@ -44,6 +45,12 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
44
45
|
latestFeedUrl: { type: String, attribute: "latest-feed-url" },
|
|
45
46
|
featuredFeedUrl: { type: String, attribute: "featured-feed-url" },
|
|
46
47
|
archiveFeedUrl: { type: String, attribute: "archive-feed-url" },
|
|
48
|
+
aboutPage: { type: Object, attribute: "about-page" },
|
|
49
|
+
aboutEditUrl: { type: String, attribute: "about-edit-url" },
|
|
50
|
+
aboutCreateUrl: {
|
|
51
|
+
type: String,
|
|
52
|
+
attribute: "about-create-url",
|
|
53
|
+
},
|
|
47
54
|
|
|
48
55
|
// Site group
|
|
49
56
|
_siteName: { state: true },
|
|
@@ -92,6 +99,9 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
92
99
|
declare latestFeedUrl: string;
|
|
93
100
|
declare featuredFeedUrl: string;
|
|
94
101
|
declare archiveFeedUrl: string;
|
|
102
|
+
declare aboutPage: SettingsAboutPageStatus;
|
|
103
|
+
declare aboutEditUrl: string;
|
|
104
|
+
declare aboutCreateUrl: string;
|
|
95
105
|
|
|
96
106
|
// Site
|
|
97
107
|
declare _siteName: string;
|
|
@@ -162,6 +172,12 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
162
172
|
this.latestFeedUrl = "/latest/feed";
|
|
163
173
|
this.featuredFeedUrl = "/featured/feed";
|
|
164
174
|
this.archiveFeedUrl = "/archive/feed";
|
|
175
|
+
this.aboutPage = {
|
|
176
|
+
state: "missing",
|
|
177
|
+
path: "/about",
|
|
178
|
+
};
|
|
179
|
+
this.aboutEditUrl = "/about?edit=1";
|
|
180
|
+
this.aboutCreateUrl = "/settings/general/about-page";
|
|
165
181
|
|
|
166
182
|
this._siteName = "";
|
|
167
183
|
this._siteDescription = "";
|
|
@@ -757,6 +773,45 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
757
773
|
`;
|
|
758
774
|
}
|
|
759
775
|
|
|
776
|
+
private _renderAboutPageRow() {
|
|
777
|
+
const status = this.aboutPage;
|
|
778
|
+
|
|
779
|
+
return html`
|
|
780
|
+
<div class="mt-2 text-sm text-muted-foreground" data-about-page-row>
|
|
781
|
+
${this.labels.aboutPagePrompt}
|
|
782
|
+
${status.state === "ready"
|
|
783
|
+
? html`
|
|
784
|
+
<a
|
|
785
|
+
class="font-medium text-foreground underline-offset-4 hover:underline"
|
|
786
|
+
href=${this.aboutEditUrl}
|
|
787
|
+
>
|
|
788
|
+
${this.labels.editAboutPage}
|
|
789
|
+
</a>
|
|
790
|
+
`
|
|
791
|
+
: status.state === "missing"
|
|
792
|
+
? html`
|
|
793
|
+
<form
|
|
794
|
+
class="inline"
|
|
795
|
+
method="post"
|
|
796
|
+
action=${this.aboutCreateUrl}
|
|
797
|
+
>
|
|
798
|
+
<button
|
|
799
|
+
type="submit"
|
|
800
|
+
class="inline cursor-pointer border-0 bg-transparent p-0 font-medium text-foreground underline-offset-4 hover:underline"
|
|
801
|
+
>
|
|
802
|
+
${this.labels.createAboutPage}
|
|
803
|
+
</button>
|
|
804
|
+
</form>
|
|
805
|
+
`
|
|
806
|
+
: html`
|
|
807
|
+
<span class="text-destructive"
|
|
808
|
+
>${this.labels.aboutPageConflict}</span
|
|
809
|
+
>
|
|
810
|
+
`}
|
|
811
|
+
</div>
|
|
812
|
+
`;
|
|
813
|
+
}
|
|
814
|
+
|
|
760
815
|
private _renderGeneralForm() {
|
|
761
816
|
return html`
|
|
762
817
|
<div class="flex flex-col gap-8">
|
|
@@ -796,6 +851,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
796
851
|
<p class="text-sm text-muted-foreground mt-1">
|
|
797
852
|
${this.labels.aboutBlogHelp}
|
|
798
853
|
</p>
|
|
854
|
+
${this._renderAboutPageRow()}
|
|
799
855
|
</div>
|
|
800
856
|
|
|
801
857
|
<div class="field">
|
|
@@ -30,6 +30,16 @@ export interface NavManagerCollection {
|
|
|
30
30
|
group?: string | null;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
export interface NavManagerSuggestedLink {
|
|
34
|
+
key: string;
|
|
35
|
+
label: string;
|
|
36
|
+
url: string;
|
|
37
|
+
targetType: "page" | "collection" | "archive";
|
|
38
|
+
targetLabel: string;
|
|
39
|
+
navItemType: "link" | "collection";
|
|
40
|
+
collectionId?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
export interface NavManagerLabels {
|
|
34
44
|
preview: string;
|
|
35
45
|
navigationItems: string;
|
|
@@ -54,6 +64,10 @@ export interface NavManagerLabels {
|
|
|
54
64
|
addLinkDescription: string;
|
|
55
65
|
urlPlaceholder: string;
|
|
56
66
|
labelAndUrlRequired: string;
|
|
67
|
+
suggestedLinks: string;
|
|
68
|
+
suggestedLinksDescription: string;
|
|
69
|
+
addSuggestedLink: string;
|
|
70
|
+
suggestedLinkAdded: string;
|
|
57
71
|
collection: string;
|
|
58
72
|
addCollection: string;
|
|
59
73
|
addCollectionToNavigation: string;
|
|
@@ -18,6 +18,11 @@ export interface SettingsLabels {
|
|
|
18
18
|
// General
|
|
19
19
|
general: string;
|
|
20
20
|
site: string;
|
|
21
|
+
aboutPage: string;
|
|
22
|
+
aboutPagePrompt: string;
|
|
23
|
+
aboutPageConflict: string;
|
|
24
|
+
createAboutPage: string;
|
|
25
|
+
editAboutPage: string;
|
|
21
26
|
languageAndTime: string;
|
|
22
27
|
home: string;
|
|
23
28
|
search: string;
|
|
@@ -100,6 +105,31 @@ export interface SettingsInitialData {
|
|
|
100
105
|
noindex: boolean;
|
|
101
106
|
}
|
|
102
107
|
|
|
108
|
+
export type SettingsAboutPageStatus =
|
|
109
|
+
| {
|
|
110
|
+
state: "missing";
|
|
111
|
+
path: "/about";
|
|
112
|
+
}
|
|
113
|
+
| {
|
|
114
|
+
state: "ready";
|
|
115
|
+
path: "/about";
|
|
116
|
+
post: {
|
|
117
|
+
id: string;
|
|
118
|
+
title: string | null;
|
|
119
|
+
status: "draft" | "published";
|
|
120
|
+
visibility: "public" | "latest_hidden" | "private";
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
| {
|
|
124
|
+
state: "conflict";
|
|
125
|
+
path: "/about";
|
|
126
|
+
conflict: {
|
|
127
|
+
targetType: "collection" | "redirect" | "archive" | "post";
|
|
128
|
+
id: string | null;
|
|
129
|
+
title: string | null;
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
103
133
|
/** Event detail dispatched when a settings form is saved */
|
|
104
134
|
export interface SettingsSaveDetail {
|
|
105
135
|
endpoint: string;
|
|
@@ -32,7 +32,23 @@ import { uploadViaSession } from "./upload-session.js";
|
|
|
32
32
|
import { publicPath } from "./runtime-paths.js";
|
|
33
33
|
import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
|
|
34
34
|
import { getMediaCategory } from "../lib/upload.js";
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
resolveInlineImageUrls,
|
|
37
|
+
hasPendingInlineImagePlaceholders,
|
|
38
|
+
} from "./tiptap/inline-image-upload.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a serialized post body still references inline image placeholders
|
|
42
|
+
* pending upload or paste-rehost. Drives the "uploading" toast and whether to
|
|
43
|
+
* resolve placeholders before submit.
|
|
44
|
+
*/
|
|
45
|
+
function bodyHasPendingInline(body: string): boolean {
|
|
46
|
+
try {
|
|
47
|
+
return hasPendingInlineImagePlaceholders(JSON.parse(body));
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
36
52
|
|
|
37
53
|
function getComposeEditorFromEventTarget(
|
|
38
54
|
target: globalThis.EventTarget | null,
|
|
@@ -679,10 +695,10 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
|
|
|
679
695
|
// Get labels for toast messages
|
|
680
696
|
const labels = composeEl?.labels;
|
|
681
697
|
const uploadingMsg = labels?.uploading ?? "Uploading...";
|
|
682
|
-
const
|
|
683
|
-
? detail.threadPosts.some((p) => p.body
|
|
684
|
-
: detail.body
|
|
685
|
-
const hasPending = detail.pendingAttachments.length > 0 ||
|
|
698
|
+
const hasInlinePending = detail.threadPosts
|
|
699
|
+
? detail.threadPosts.some((p) => bodyHasPendingInline(p.body))
|
|
700
|
+
: bodyHasPendingInline(detail.body);
|
|
701
|
+
const hasPending = detail.pendingAttachments.length > 0 || hasInlinePending;
|
|
686
702
|
const publishedMsg = labels?.published ?? "Published!";
|
|
687
703
|
const viewLabel = labels?.view ?? "View";
|
|
688
704
|
|
|
@@ -831,7 +847,7 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
|
|
|
831
847
|
const resolvedPosts = await Promise.all(
|
|
832
848
|
threadPosts.map(async (post) => {
|
|
833
849
|
let body = post.body;
|
|
834
|
-
if (body
|
|
850
|
+
if (bodyHasPendingInline(body)) {
|
|
835
851
|
try {
|
|
836
852
|
const bodyJson = JSON.parse(body);
|
|
837
853
|
const resolved = await resolveInlineImageUrls(bodyJson);
|
|
@@ -940,8 +956,9 @@ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
|
|
|
940
956
|
mediaClientIdMap,
|
|
941
957
|
);
|
|
942
958
|
|
|
943
|
-
// Resolve any
|
|
944
|
-
|
|
959
|
+
// Resolve any pending inline image placeholders (blob upload + paste rehost)
|
|
960
|
+
// to their stored URLs before submitting.
|
|
961
|
+
if (hasInlinePending) {
|
|
945
962
|
try {
|
|
946
963
|
const bodyJson = JSON.parse(detail.body);
|
|
947
964
|
const resolved = await resolveInlineImageUrls(bodyJson);
|
|
@@ -57,6 +57,36 @@ export function getCurrentDetailPostArticle(
|
|
|
57
57
|
return null;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function getLastThreadDetailArticle(
|
|
61
|
+
root: globalThis.Document | globalThis.Element,
|
|
62
|
+
): HTMLElement | null {
|
|
63
|
+
const threadGroup = root.querySelector<HTMLElement>(
|
|
64
|
+
".thread-group-detail[data-page='post']",
|
|
65
|
+
);
|
|
66
|
+
if (!threadGroup) return null;
|
|
67
|
+
|
|
68
|
+
const articles = threadGroup.querySelectorAll<HTMLElement>(
|
|
69
|
+
".thread-detail-item article[data-post]",
|
|
70
|
+
);
|
|
71
|
+
return articles.length > 0 ? articles.item(articles.length - 1) : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getReplyTargetArticle(
|
|
75
|
+
root: globalThis.Document | globalThis.Element = document,
|
|
76
|
+
): HTMLElement | null {
|
|
77
|
+
const lastThreadPost = getLastThreadDetailArticle(root);
|
|
78
|
+
if (lastThreadPost) return lastThreadPost;
|
|
79
|
+
|
|
80
|
+
if (root === document) {
|
|
81
|
+
const hoveredPost = document.querySelector<HTMLElement>(
|
|
82
|
+
"[data-page='post'] article[data-post]:hover",
|
|
83
|
+
);
|
|
84
|
+
if (hoveredPost) return hoveredPost;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return getCurrentDetailPostArticle(root);
|
|
88
|
+
}
|
|
89
|
+
|
|
60
90
|
export function getReplyRefreshTarget(
|
|
61
91
|
article: HTMLElement,
|
|
62
92
|
): ReplyRefreshTarget | null {
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
getActiveCollectionId,
|
|
3
3
|
getComposeDialog,
|
|
4
4
|
getCurrentDetailPostArticle,
|
|
5
|
+
getReplyTargetArticle,
|
|
5
6
|
openNewCompose,
|
|
6
7
|
openReplyForArticle,
|
|
7
8
|
} from "./compose-launch.js";
|
|
@@ -42,6 +43,34 @@ function shouldIgnoreShortcut(event: globalThis.KeyboardEvent): boolean {
|
|
|
42
43
|
);
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
function hasEditQueryParam(): boolean {
|
|
47
|
+
return new URL(window.location.href).searchParams.get("edit") === "1";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function consumeEditQueryParam() {
|
|
51
|
+
const url = new URL(window.location.href);
|
|
52
|
+
url.searchParams.delete("edit");
|
|
53
|
+
const nextSearch = url.searchParams.toString();
|
|
54
|
+
globalThis.history.replaceState(
|
|
55
|
+
globalThis.history.state,
|
|
56
|
+
"",
|
|
57
|
+
`${url.pathname}${nextSearch ? `?${nextSearch}` : ""}${url.hash}`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function openEditFromQueryParam() {
|
|
62
|
+
if (!document.body?.hasAttribute("data-authenticated")) return;
|
|
63
|
+
if (!hasEditQueryParam()) return;
|
|
64
|
+
|
|
65
|
+
const article = getCurrentDetailPostArticle();
|
|
66
|
+
const postId = article?.dataset.postId;
|
|
67
|
+
const composeEl = getComposeDialog();
|
|
68
|
+
if (!postId || !composeEl) return;
|
|
69
|
+
|
|
70
|
+
consumeEditQueryParam();
|
|
71
|
+
void composeEl.openEdit(postId);
|
|
72
|
+
}
|
|
73
|
+
|
|
45
74
|
async function toggleFeatured(
|
|
46
75
|
postId: string,
|
|
47
76
|
featured: boolean,
|
|
@@ -93,15 +122,17 @@ document.addEventListener("keydown", (event: globalThis.KeyboardEvent) => {
|
|
|
93
122
|
return;
|
|
94
123
|
}
|
|
95
124
|
|
|
96
|
-
const article = getCurrentDetailPostArticle();
|
|
97
|
-
if (!article) return;
|
|
98
|
-
|
|
99
125
|
if (key === "r") {
|
|
126
|
+
const article = getReplyTargetArticle();
|
|
127
|
+
if (!article) return;
|
|
100
128
|
event.preventDefault();
|
|
101
129
|
void openReplyForArticle(article);
|
|
102
130
|
return;
|
|
103
131
|
}
|
|
104
132
|
|
|
133
|
+
const article = getCurrentDetailPostArticle();
|
|
134
|
+
if (!article) return;
|
|
135
|
+
|
|
105
136
|
if (key === "e") {
|
|
106
137
|
const postId = article.dataset.postId;
|
|
107
138
|
if (!postId) return;
|
|
@@ -131,3 +162,15 @@ document.addEventListener("keydown", (event: globalThis.KeyboardEvent) => {
|
|
|
131
162
|
return;
|
|
132
163
|
}
|
|
133
164
|
});
|
|
165
|
+
|
|
166
|
+
if (document.readyState === "loading") {
|
|
167
|
+
document.addEventListener("DOMContentLoaded", openEditFromQueryParam, {
|
|
168
|
+
once: true,
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
openEditFromQueryParam();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const __testOnly = {
|
|
175
|
+
openEditFromQueryParam,
|
|
176
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { Editor, type JSONContent } from "@tiptap/core";
|
|
5
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
6
|
+
import { ImageNode } from "../image-node.js";
|
|
7
|
+
import {
|
|
8
|
+
rehostInlineImage,
|
|
9
|
+
resolveInlineImageUrls,
|
|
10
|
+
hasPendingInlineImagePlaceholders,
|
|
11
|
+
} from "../inline-image-upload.js";
|
|
12
|
+
|
|
13
|
+
const editors: Editor[] = [];
|
|
14
|
+
|
|
15
|
+
function createEditor(): Editor {
|
|
16
|
+
const element = document.createElement("div");
|
|
17
|
+
document.body.appendChild(element);
|
|
18
|
+
const editor = new Editor({
|
|
19
|
+
element,
|
|
20
|
+
extensions: [
|
|
21
|
+
StarterKit.configure({
|
|
22
|
+
heading: { levels: [1, 2, 3] },
|
|
23
|
+
link: { openOnClick: false, autolink: false },
|
|
24
|
+
}),
|
|
25
|
+
ImageNode,
|
|
26
|
+
],
|
|
27
|
+
content: "<p></p>",
|
|
28
|
+
});
|
|
29
|
+
editors.push(editor);
|
|
30
|
+
return editor;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function imageSrcs(editor: Editor): string[] {
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
editor.state.doc.descendants((node) => {
|
|
36
|
+
if (node.type.name === "image") out.push(node.attrs.src as string);
|
|
37
|
+
});
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function jsonImageSrcs(node: JSONContent | null): string[] {
|
|
42
|
+
if (!node) return [];
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
const walk = (n: JSONContent) => {
|
|
45
|
+
if (n.type === "image" && typeof n.attrs?.src === "string") {
|
|
46
|
+
out.push(n.attrs.src);
|
|
47
|
+
}
|
|
48
|
+
for (const child of n.content ?? []) walk(child);
|
|
49
|
+
};
|
|
50
|
+
walk(node);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
while (editors.length > 0) editors.pop()?.destroy();
|
|
56
|
+
document.body.innerHTML = "";
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("rehostInlineImage", () => {
|
|
60
|
+
it("swaps the node src on a successful rehost", async () => {
|
|
61
|
+
const editor = createEditor();
|
|
62
|
+
const src = "https://ext.example/keep1.png";
|
|
63
|
+
editor.commands.setImage({ src });
|
|
64
|
+
|
|
65
|
+
await rehostInlineImage(
|
|
66
|
+
editor,
|
|
67
|
+
src,
|
|
68
|
+
async () => "https://cdn.local/keep1.webp",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(imageSrcs(editor)).toEqual(["https://cdn.local/keep1.webp"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("keeps a remote node's original src when the rehost fails", async () => {
|
|
75
|
+
const editor = createEditor();
|
|
76
|
+
const src = "https://ext.example/fail1.png";
|
|
77
|
+
editor.commands.setImage({ src });
|
|
78
|
+
|
|
79
|
+
await rehostInlineImage(editor, src, async () => {
|
|
80
|
+
throw new Error("boom");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(imageSrcs(editor)).toEqual([src]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("replaces every node sharing a deduped remote src", async () => {
|
|
87
|
+
const editor = createEditor();
|
|
88
|
+
const src = "https://ext.example/dupe.png";
|
|
89
|
+
editor.commands.setImage({ src });
|
|
90
|
+
editor.commands.setImage({ src });
|
|
91
|
+
|
|
92
|
+
await rehostInlineImage(
|
|
93
|
+
editor,
|
|
94
|
+
src,
|
|
95
|
+
async () => "https://cdn.local/dupe.webp",
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(imageSrcs(editor)).toEqual([
|
|
99
|
+
"https://cdn.local/dupe.webp",
|
|
100
|
+
"https://cdn.local/dupe.webp",
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("resolveInlineImageUrls + hasPendingInlineImagePlaceholders", () => {
|
|
106
|
+
it("tracks an in-flight rehost and resolves its placeholder to the stored URL", async () => {
|
|
107
|
+
const editor = createEditor();
|
|
108
|
+
const src = "https://ext.example/pending.png";
|
|
109
|
+
editor.commands.setImage({ src });
|
|
110
|
+
|
|
111
|
+
let release!: (url: string) => void;
|
|
112
|
+
const deferred = new Promise<string>((resolve) => {
|
|
113
|
+
release = resolve;
|
|
114
|
+
});
|
|
115
|
+
const rehostDone = rehostInlineImage(editor, src, () => deferred);
|
|
116
|
+
|
|
117
|
+
const json = editor.getJSON();
|
|
118
|
+
expect(hasPendingInlineImagePlaceholders(json)).toBe(true);
|
|
119
|
+
|
|
120
|
+
const resolvePromise = resolveInlineImageUrls(json);
|
|
121
|
+
release("https://cdn.local/pending.webp");
|
|
122
|
+
const resolved = await resolvePromise;
|
|
123
|
+
await rehostDone;
|
|
124
|
+
|
|
125
|
+
expect(jsonImageSrcs(resolved)).toEqual(["https://cdn.local/pending.webp"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("is a no-op for content with no pending placeholders", async () => {
|
|
129
|
+
const json: JSONContent = {
|
|
130
|
+
type: "doc",
|
|
131
|
+
content: [
|
|
132
|
+
{
|
|
133
|
+
type: "image",
|
|
134
|
+
attrs: { src: "https://cdn.local/already-stored.webp" },
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
expect(hasPendingInlineImagePlaceholders(json)).toBe(false);
|
|
140
|
+
const resolved = await resolveInlineImageUrls(json);
|
|
141
|
+
expect(resolved).toBe(json);
|
|
142
|
+
});
|
|
143
|
+
});
|