@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
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
ComposeEditorSelection,
|
|
31
31
|
ComposeFullscreenOpenDetail,
|
|
32
32
|
} from "./compose-types.js";
|
|
33
|
+
import type { ComposeConvertFields } from "./compose-format-convert.js";
|
|
33
34
|
import {
|
|
34
35
|
UPLOAD_ACCEPT,
|
|
35
36
|
getMediaCategory,
|
|
@@ -178,6 +179,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
178
179
|
uploadMaxFileSize: { type: Number },
|
|
179
180
|
threadItem: { type: Boolean, attribute: "thread-item" },
|
|
180
181
|
removable: { type: Boolean },
|
|
182
|
+
inlineFormat: { type: Boolean, attribute: "inline-format" },
|
|
181
183
|
slashCommandDiscovered: { type: Boolean },
|
|
182
184
|
_title: { state: true },
|
|
183
185
|
_bodyJson: { state: true },
|
|
@@ -203,6 +205,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
203
205
|
declare uploadMaxFileSize: number;
|
|
204
206
|
declare threadItem: boolean;
|
|
205
207
|
declare removable: boolean;
|
|
208
|
+
declare inlineFormat: boolean;
|
|
206
209
|
declare slashCommandDiscovered: boolean;
|
|
207
210
|
declare _title: string;
|
|
208
211
|
declare _bodyJson: JSONContent | null;
|
|
@@ -234,6 +237,14 @@ export class JantComposeEditor extends LitElement {
|
|
|
234
237
|
private _scrollBufferApplied = false;
|
|
235
238
|
private _filePickerCleanup: (() => void) | null = null;
|
|
236
239
|
private _suppressAttachedTextOpenUntil = 0;
|
|
240
|
+
/**
|
|
241
|
+
* Set by {@link applyConvertedFields} so the format-conversion content writes
|
|
242
|
+
* don't emit a content-changed event (which would schedule a draft autosave).
|
|
243
|
+
* A bare format switch shouldn't persist a local draft — see `_switchFormat`.
|
|
244
|
+
* Always consumed: a switch also changes `format`, so `updated()` is guaranteed
|
|
245
|
+
* to run this cycle.
|
|
246
|
+
*/
|
|
247
|
+
private _suppressContentChangedOnce = false;
|
|
237
248
|
#inlineImageUploadGeneration = 0;
|
|
238
249
|
#inlineImageUploadPromises = new Set<Promise<void>>();
|
|
239
250
|
#sortable: { destroy(): void } | null = null;
|
|
@@ -247,9 +258,10 @@ export class JantComposeEditor extends LitElement {
|
|
|
247
258
|
super();
|
|
248
259
|
this.format = "note";
|
|
249
260
|
this.labels = {} as ComposeLabels;
|
|
250
|
-
this.uploadMaxFileSize =
|
|
261
|
+
this.uploadMaxFileSize = 1024;
|
|
251
262
|
this.threadItem = false;
|
|
252
263
|
this.removable = false;
|
|
264
|
+
this.inlineFormat = false;
|
|
253
265
|
this.slashCommandDiscovered = false;
|
|
254
266
|
this._title = "";
|
|
255
267
|
this._bodyJson = null;
|
|
@@ -903,13 +915,19 @@ export class JantComposeEditor extends LitElement {
|
|
|
903
915
|
}
|
|
904
916
|
}
|
|
905
917
|
|
|
906
|
-
// Notify parent dialog of content changes for draft auto-save
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
918
|
+
// Notify parent dialog of content changes for draft auto-save. A format
|
|
919
|
+
// conversion writes content fields too, but it's not a user edit, so skip
|
|
920
|
+
// the notification once when asked.
|
|
921
|
+
if (this._suppressContentChangedOnce) {
|
|
922
|
+
this._suppressContentChangedOnce = false;
|
|
923
|
+
} else {
|
|
924
|
+
for (const key of changed.keys()) {
|
|
925
|
+
if (JantComposeEditor._CONTENT_PROPS.has(key as string)) {
|
|
926
|
+
this.dispatchEvent(
|
|
927
|
+
new Event("jant:compose-content-changed", { bubbles: true }),
|
|
928
|
+
);
|
|
929
|
+
break;
|
|
930
|
+
}
|
|
913
931
|
}
|
|
914
932
|
}
|
|
915
933
|
}
|
|
@@ -924,6 +942,39 @@ export class JantComposeEditor extends LitElement {
|
|
|
924
942
|
};
|
|
925
943
|
}
|
|
926
944
|
|
|
945
|
+
/**
|
|
946
|
+
* Raw field values for format conversion. Unlike {@link getData}, this returns
|
|
947
|
+
* every field regardless of the current format (so a hidden quote/url survives
|
|
948
|
+
* a switch) plus the freshest, normalized body.
|
|
949
|
+
*/
|
|
950
|
+
getConvertibleFields(): ComposeConvertFields {
|
|
951
|
+
return {
|
|
952
|
+
title: this._title,
|
|
953
|
+
url: this._url,
|
|
954
|
+
quoteText: this._quoteText,
|
|
955
|
+
quoteAuthor: this._quoteAuthor,
|
|
956
|
+
showTitle: this._showTitle,
|
|
957
|
+
bodyJson: this._normalizeDocJson(
|
|
958
|
+
this._editor?.getJSON() ?? this._bodyJson,
|
|
959
|
+
),
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Write back fields produced by `convertComposeFormat`. The body is applied via
|
|
965
|
+
* `_bodyJson` only — the imminent format change recreates the editor from it, so
|
|
966
|
+
* calling `setContent` here would be redundant.
|
|
967
|
+
*/
|
|
968
|
+
applyConvertedFields(fields: ComposeConvertFields): void {
|
|
969
|
+
this._suppressContentChangedOnce = true;
|
|
970
|
+
this._title = fields.title;
|
|
971
|
+
this._url = fields.url;
|
|
972
|
+
this._quoteText = fields.quoteText;
|
|
973
|
+
this._quoteAuthor = fields.quoteAuthor;
|
|
974
|
+
this._showTitle = fields.showTitle;
|
|
975
|
+
this._bodyJson = fields.bodyJson;
|
|
976
|
+
}
|
|
977
|
+
|
|
927
978
|
/** Pre-fill all fields for edit mode or draft restore */
|
|
928
979
|
populate(data: {
|
|
929
980
|
format: string;
|
|
@@ -2433,7 +2484,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
2433
2484
|
`;
|
|
2434
2485
|
}
|
|
2435
2486
|
|
|
2436
|
-
private
|
|
2487
|
+
private _renderFormatHeader() {
|
|
2437
2488
|
const formatLabels: Record<ComposeFormat, string> = {
|
|
2438
2489
|
note: this.labels.note,
|
|
2439
2490
|
link: this.labels.link,
|
|
@@ -2463,7 +2514,7 @@ export class JantComposeEditor extends LitElement {
|
|
|
2463
2514
|
@click=${() => {
|
|
2464
2515
|
if (this.format !== f) {
|
|
2465
2516
|
this.dispatchEvent(
|
|
2466
|
-
new CustomEvent("jant:
|
|
2517
|
+
new CustomEvent("jant:format-change", {
|
|
2467
2518
|
bubbles: true,
|
|
2468
2519
|
detail: { format: f },
|
|
2469
2520
|
}),
|
|
@@ -2518,7 +2569,9 @@ export class JantComposeEditor extends LitElement {
|
|
|
2518
2569
|
|
|
2519
2570
|
render() {
|
|
2520
2571
|
return html`
|
|
2521
|
-
${this.threadItem
|
|
2572
|
+
${this.threadItem || this.inlineFormat
|
|
2573
|
+
? this._renderFormatHeader()
|
|
2574
|
+
: nothing}
|
|
2522
2575
|
<section class="compose-body">
|
|
2523
2576
|
${this.format === "note"
|
|
2524
2577
|
? this._renderNoteFields()
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
SettingsLabels,
|
|
21
21
|
SettingsTimezone,
|
|
22
22
|
SettingsCjkFont,
|
|
23
|
+
SettingsDashboardLanguage,
|
|
23
24
|
} from "./settings-types.js";
|
|
24
25
|
import { showToast } from "../toast.js";
|
|
25
26
|
import {
|
|
@@ -37,6 +38,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
37
38
|
type: String,
|
|
38
39
|
attribute: "sitedescription-fallback",
|
|
39
40
|
},
|
|
41
|
+
dashboardLanguages: { type: Array, attribute: "dashboard-languages" },
|
|
40
42
|
demoMode: { type: Boolean, attribute: "demo-mode" },
|
|
41
43
|
mainFeedUrl: { type: String, attribute: "main-feed-url" },
|
|
42
44
|
latestFeedUrl: { type: String, attribute: "latest-feed-url" },
|
|
@@ -52,6 +54,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
52
54
|
|
|
53
55
|
// Language, CJK & time group
|
|
54
56
|
_siteLanguage: { state: true },
|
|
57
|
+
_dashboardLanguage: { state: true },
|
|
55
58
|
_localeOpen: { state: true },
|
|
56
59
|
_localeQuery: { state: true },
|
|
57
60
|
_cjkSerifFont: { state: true },
|
|
@@ -80,6 +83,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
80
83
|
declare labels: SettingsLabels;
|
|
81
84
|
declare timezones: SettingsTimezone[];
|
|
82
85
|
declare cjkFonts: SettingsCjkFont[];
|
|
86
|
+
declare dashboardLanguages: SettingsDashboardLanguage[];
|
|
83
87
|
declare siteNameFallback: string;
|
|
84
88
|
declare siteDescriptionFallback: string;
|
|
85
89
|
declare demoMode: boolean;
|
|
@@ -101,6 +105,8 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
101
105
|
|
|
102
106
|
// Language, CJK & time
|
|
103
107
|
declare _siteLanguage: string;
|
|
108
|
+
/** Admin dashboard UI locale (one of the translated catalog locales). */
|
|
109
|
+
declare _dashboardLanguage: string;
|
|
104
110
|
/** Whether the locale combobox dropdown is currently open. */
|
|
105
111
|
declare _localeOpen: boolean;
|
|
106
112
|
/** Search query inside the locale combobox. */
|
|
@@ -109,6 +115,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
109
115
|
declare _timeZone: string;
|
|
110
116
|
declare _origLocale: {
|
|
111
117
|
siteLanguage: string;
|
|
118
|
+
dashboardLanguage: string;
|
|
112
119
|
cjkSerifFont: string;
|
|
113
120
|
timeZone: string;
|
|
114
121
|
};
|
|
@@ -145,6 +152,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
145
152
|
this.labels = {} as SettingsLabels;
|
|
146
153
|
this.timezones = [];
|
|
147
154
|
this.cjkFonts = [];
|
|
155
|
+
this.dashboardLanguages = [];
|
|
148
156
|
this.siteNameFallback = "";
|
|
149
157
|
this.siteDescriptionFallback = "";
|
|
150
158
|
this.demoMode = false;
|
|
@@ -164,12 +172,14 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
164
172
|
this._siteLoading = false;
|
|
165
173
|
|
|
166
174
|
this._siteLanguage = "en";
|
|
175
|
+
this._dashboardLanguage = "en";
|
|
167
176
|
this._localeOpen = false;
|
|
168
177
|
this._localeQuery = "";
|
|
169
178
|
this._cjkSerifFont = "off";
|
|
170
179
|
this._timeZone = "UTC";
|
|
171
180
|
this._origLocale = {
|
|
172
181
|
siteLanguage: "en",
|
|
182
|
+
dashboardLanguage: "en",
|
|
173
183
|
cjkSerifFont: "off",
|
|
174
184
|
timeZone: "UTC",
|
|
175
185
|
};
|
|
@@ -213,10 +223,12 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
213
223
|
this._siteFooter = data.siteFooter;
|
|
214
224
|
|
|
215
225
|
this._siteLanguage = data.siteLanguage;
|
|
226
|
+
this._dashboardLanguage = data.dashboardLanguage;
|
|
216
227
|
this._cjkSerifFont = data.cjkSerifFont;
|
|
217
228
|
this._timeZone = data.timeZone;
|
|
218
229
|
this._origLocale = {
|
|
219
230
|
siteLanguage: data.siteLanguage,
|
|
231
|
+
dashboardLanguage: data.dashboardLanguage,
|
|
220
232
|
cjkSerifFont: data.cjkSerifFont,
|
|
221
233
|
timeZone: data.timeZone,
|
|
222
234
|
};
|
|
@@ -255,6 +267,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
255
267
|
} else if (section === "language-time") {
|
|
256
268
|
this._origLocale = {
|
|
257
269
|
siteLanguage: this._siteLanguage,
|
|
270
|
+
dashboardLanguage: this._dashboardLanguage,
|
|
258
271
|
cjkSerifFont: this._cjkSerifFont,
|
|
259
272
|
timeZone: this._timeZone,
|
|
260
273
|
};
|
|
@@ -381,6 +394,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
381
394
|
private _syncLocaleDirty() {
|
|
382
395
|
this._localeDirty =
|
|
383
396
|
this._siteLanguage !== this._origLocale.siteLanguage ||
|
|
397
|
+
this._dashboardLanguage !== this._origLocale.dashboardLanguage ||
|
|
384
398
|
this._cjkSerifFont !== this._origLocale.cjkSerifFont ||
|
|
385
399
|
this._timeZone !== this._origLocale.timeZone;
|
|
386
400
|
}
|
|
@@ -395,6 +409,7 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
395
409
|
endpoint: "/settings/general/language-time",
|
|
396
410
|
data: {
|
|
397
411
|
siteLanguage: this._siteLanguage,
|
|
412
|
+
dashboardLanguage: this._dashboardLanguage,
|
|
398
413
|
cjkSerifFont: this._cjkSerifFont,
|
|
399
414
|
timeZone: this._timeZone,
|
|
400
415
|
},
|
|
@@ -465,28 +480,22 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
465
480
|
const noMatches = this.labels.siteLanguageNoMatches || "No matches.";
|
|
466
481
|
|
|
467
482
|
return html`
|
|
468
|
-
<div class="relative" data-locale-picker>
|
|
483
|
+
<div class="relative w-fit max-w-full" data-locale-picker>
|
|
469
484
|
<button
|
|
470
485
|
type="button"
|
|
471
|
-
class="
|
|
486
|
+
class="flex h-9 w-full cursor-pointer items-center rounded-md border border-input bg-transparent bg-[image:var(--chevron-down-icon-50)] bg-position-[center_right_0.75rem] bg-size-[1rem] bg-no-repeat py-2 pl-3 pr-9 text-left text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50"
|
|
472
487
|
aria-expanded=${this._localeOpen ? "true" : "false"}
|
|
473
488
|
aria-haspopup="listbox"
|
|
474
489
|
aria-labelledby="site-language-label"
|
|
475
490
|
@click=${this._toggleLocalePicker}
|
|
476
491
|
>
|
|
477
|
-
<span class="truncate">
|
|
478
|
-
${current.native}
|
|
479
|
-
<span class="ml-2 text-xs text-muted-foreground">
|
|
480
|
-
${current.tag} · ${Math.round(current.coverage * 100)}% translated
|
|
481
|
-
</span>
|
|
482
|
-
</span>
|
|
483
|
-
<span class="ml-2 text-muted-foreground" aria-hidden="true">▾</span>
|
|
492
|
+
<span class="min-w-0 truncate">${current.native}</span>
|
|
484
493
|
</button>
|
|
485
494
|
|
|
486
495
|
${this._localeOpen
|
|
487
496
|
? html`
|
|
488
497
|
<div
|
|
489
|
-
class="absolute left-0
|
|
498
|
+
class="absolute left-0 top-full z-10 mt-1 w-80 min-w-full max-w-[calc(100vw-2rem)] max-h-72 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md"
|
|
490
499
|
>
|
|
491
500
|
<div class="border-b p-2">
|
|
492
501
|
<input
|
|
@@ -518,21 +527,16 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
518
527
|
? "true"
|
|
519
528
|
: "false"}
|
|
520
529
|
class=${[
|
|
521
|
-
"flex w-full
|
|
530
|
+
"flex w-full flex-col px-3 py-2 text-left text-sm hover:bg-accent",
|
|
522
531
|
entry.tag === this._siteLanguage
|
|
523
532
|
? "bg-accent/60"
|
|
524
533
|
: "",
|
|
525
534
|
].join(" ")}
|
|
526
535
|
@click=${() => this._selectLocale(entry.tag)}
|
|
527
536
|
>
|
|
528
|
-
<span
|
|
529
|
-
<span>${entry.native}</span>
|
|
530
|
-
<span class="text-xs text-muted-foreground">
|
|
531
|
-
${entry.tag} · ${entry.english}
|
|
532
|
-
</span>
|
|
533
|
-
</span>
|
|
537
|
+
<span>${entry.native}</span>
|
|
534
538
|
<span class="text-xs text-muted-foreground">
|
|
535
|
-
${
|
|
539
|
+
${entry.english}
|
|
536
540
|
</span>
|
|
537
541
|
</button>
|
|
538
542
|
`,
|
|
@@ -819,6 +823,40 @@ export class JantSettingsGeneral extends LitElement {
|
|
|
819
823
|
<p class="text-sm text-muted-foreground mt-1">
|
|
820
824
|
${this.labels.siteLanguageHelp}
|
|
821
825
|
</p>
|
|
826
|
+
<p class="text-sm text-muted-foreground mt-1">
|
|
827
|
+
${this.labels.contentLanguagePreview}
|
|
828
|
+
<code class="rounded bg-muted px-1.5 py-0.5 text-xs"
|
|
829
|
+
>${`<html lang="${this._siteLanguage || "en"}">`}</code
|
|
830
|
+
>
|
|
831
|
+
</p>
|
|
832
|
+
</div>
|
|
833
|
+
|
|
834
|
+
<div class="field">
|
|
835
|
+
<label id="dashboard-language-label" class="label"
|
|
836
|
+
>${this.labels.dashboardLanguage}</label
|
|
837
|
+
>
|
|
838
|
+
<select
|
|
839
|
+
class="select"
|
|
840
|
+
aria-labelledby="dashboard-language-label"
|
|
841
|
+
@change=${(e: Event) => {
|
|
842
|
+
this._dashboardLanguage = (e.target as HTMLSelectElement).value;
|
|
843
|
+
this._syncLocaleDirty();
|
|
844
|
+
}}
|
|
845
|
+
>
|
|
846
|
+
${this.dashboardLanguages.map(
|
|
847
|
+
(lang) => html`
|
|
848
|
+
<option
|
|
849
|
+
value=${lang.value}
|
|
850
|
+
?selected=${this._dashboardLanguage === lang.value}
|
|
851
|
+
>
|
|
852
|
+
${lang.label}
|
|
853
|
+
</option>
|
|
854
|
+
`,
|
|
855
|
+
)}
|
|
856
|
+
</select>
|
|
857
|
+
<p class="text-sm text-muted-foreground mt-1">
|
|
858
|
+
${this.labels.dashboardLanguageHelp}
|
|
859
|
+
</p>
|
|
822
860
|
</div>
|
|
823
861
|
|
|
824
862
|
<div class="field">
|
|
@@ -47,6 +47,10 @@ export interface SettingsLabels {
|
|
|
47
47
|
siteLanguageSearchPlaceholder: string;
|
|
48
48
|
/** Empty-state message when the search filters out every option. */
|
|
49
49
|
siteLanguageNoMatches: string;
|
|
50
|
+
/** Lead text before the live `<html lang>` preview. */
|
|
51
|
+
contentLanguagePreview: string;
|
|
52
|
+
dashboardLanguage: string;
|
|
53
|
+
dashboardLanguageHelp: string;
|
|
50
54
|
cjkFont: string;
|
|
51
55
|
cjkFontHelp: string;
|
|
52
56
|
timeZone: string;
|
|
@@ -75,10 +79,17 @@ export interface SettingsCjkFont {
|
|
|
75
79
|
label: string;
|
|
76
80
|
}
|
|
77
81
|
|
|
82
|
+
/** Dashboard UI language option for the select dropdown */
|
|
83
|
+
export interface SettingsDashboardLanguage {
|
|
84
|
+
value: string;
|
|
85
|
+
label: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
78
88
|
export interface SettingsInitialData {
|
|
79
89
|
siteName: string;
|
|
80
90
|
siteDescription: string;
|
|
81
91
|
siteLanguage: string;
|
|
92
|
+
dashboardLanguage: string;
|
|
82
93
|
cjkSerifFont: string;
|
|
83
94
|
mainRssFeed: string;
|
|
84
95
|
timeZone: string;
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
queueToastForNextPage,
|
|
27
27
|
} from "./toast.js";
|
|
28
28
|
import { openReplyForArticle } from "./compose-launch.js";
|
|
29
|
+
import { hydratePartial } from "./hydrate-partial.js";
|
|
29
30
|
import { getJsonString, readJsonObject } from "./json.js";
|
|
30
31
|
import { uploadViaSession } from "./upload-session.js";
|
|
31
32
|
import { publicPath } from "./runtime-paths.js";
|
|
@@ -75,6 +76,10 @@ async function refreshTimelineThreadView(
|
|
|
75
76
|
if (!html) return false;
|
|
76
77
|
|
|
77
78
|
content.innerHTML = html;
|
|
79
|
+
// Swapped-in markup carries interactions whose per-element setup only runs
|
|
80
|
+
// on DOMContentLoaded (thread "Show more" toggle, feed video autoplay, audio
|
|
81
|
+
// waveform); re-initialize them or they stay inert until a full reload.
|
|
82
|
+
hydratePartial(content);
|
|
78
83
|
return true;
|
|
79
84
|
} catch {
|
|
80
85
|
return false;
|
|
@@ -97,6 +102,7 @@ async function refreshPostCardView(postId: string): Promise<boolean> {
|
|
|
97
102
|
);
|
|
98
103
|
if (!content) return false;
|
|
99
104
|
content.innerHTML = html;
|
|
105
|
+
hydratePartial(content);
|
|
100
106
|
return true;
|
|
101
107
|
}
|
|
102
108
|
|
|
@@ -106,6 +112,11 @@ async function refreshPostCardView(postId: string): Promise<boolean> {
|
|
|
106
112
|
if (!article) return false;
|
|
107
113
|
|
|
108
114
|
article.outerHTML = html;
|
|
115
|
+
// outerHTML detaches `article`; re-query the replacement to hydrate it.
|
|
116
|
+
const nextArticle = document.querySelector<HTMLElement>(
|
|
117
|
+
`article[data-post-id="${postId}"]`,
|
|
118
|
+
);
|
|
119
|
+
if (nextArticle) hydratePartial(nextArticle);
|
|
109
120
|
return true;
|
|
110
121
|
} catch {
|
|
111
122
|
return false;
|
|
@@ -125,6 +136,12 @@ async function refreshPostPageView(postId: string): Promise<boolean> {
|
|
|
125
136
|
if (!html) return false;
|
|
126
137
|
|
|
127
138
|
container.outerHTML = html;
|
|
139
|
+
// outerHTML detaches `container`; re-query the replacement to hydrate it
|
|
140
|
+
// (see refreshTimelineThreadView).
|
|
141
|
+
const next = document.querySelector<HTMLElement>(
|
|
142
|
+
`[data-post-view][data-post-view-id="${postId}"]`,
|
|
143
|
+
);
|
|
144
|
+
if (next) hydratePartial(next);
|
|
128
145
|
return true;
|
|
129
146
|
} catch {
|
|
130
147
|
return false;
|
|
@@ -337,7 +337,7 @@ function handleMuteToggle(event: Event): void {
|
|
|
337
337
|
}
|
|
338
338
|
|
|
339
339
|
export function initFeedVideoPlayer(
|
|
340
|
-
root: globalThis.
|
|
340
|
+
root: globalThis.Document | globalThis.Element = document,
|
|
341
341
|
): void {
|
|
342
342
|
const videos = root.querySelectorAll<HTMLVideoElement>(
|
|
343
343
|
"[data-feed-short-video]",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-initialize interactive behaviors inside a server-rendered fragment that
|
|
3
|
+
* was swapped into the DOM after page load — e.g. compose-bridge replacing a
|
|
4
|
+
* timeline item or post view after a reply or edit.
|
|
5
|
+
*
|
|
6
|
+
* Most interactions survive a swap on their own: note-expand and the audio
|
|
7
|
+
* transport use document-level event delegation, and media-scroll-hint runs its
|
|
8
|
+
* own MutationObserver. The ones gathered here need per-element setup
|
|
9
|
+
* (IntersectionObserver / ResizeObserver / canvas drawing) that otherwise only
|
|
10
|
+
* runs once on DOMContentLoaded, so a freshly swapped fragment would stay inert
|
|
11
|
+
* until a full reload. Each initializer is idempotent, so calling this on a root
|
|
12
|
+
* that already contains initialized nodes is safe.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { setupThreadContexts } from "./thread-context.js";
|
|
16
|
+
import { initFeedVideoPlayer } from "./feed-video-player.js";
|
|
17
|
+
import { initPrecomputedWaveforms } from "./audio-player.js";
|
|
18
|
+
|
|
19
|
+
export function hydratePartial(
|
|
20
|
+
root: globalThis.Document | globalThis.Element,
|
|
21
|
+
): void {
|
|
22
|
+
setupThreadContexts(root);
|
|
23
|
+
initFeedVideoPlayer(root);
|
|
24
|
+
initPrecomputedWaveforms(root);
|
|
25
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expand-in-place for truncated untitled notes.
|
|
3
|
+
*
|
|
4
|
+
* The card renders the full note body with a zero-width `data-note-break`
|
|
5
|
+
* marker at the summary boundary; CSS (`[data-note-clamp] [data-note-break] ~
|
|
6
|
+
* *`) hides everything after it until expanded. The "Show more" control (an
|
|
7
|
+
* `<a>` to the permalink, which is the no-JS fallback) toggles the clamp.
|
|
8
|
+
*
|
|
9
|
+
* Because the tail is already laid out below the visible summary, revealing it
|
|
10
|
+
* inserts content below the browser's scroll anchor — so the note grows in
|
|
11
|
+
* place without the page jumping. Collapsing pulls the note top back into view
|
|
12
|
+
* when the reader had scrolled into the now-hidden tail.
|
|
13
|
+
*
|
|
14
|
+
* Document-level click delegation keeps this working after compose-bridge
|
|
15
|
+
* replaces card DOM on edit/reply — per-element listeners would be orphaned.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** True for clicks that should keep their native behavior (open in new tab, etc.). */
|
|
19
|
+
function isModifiedClick(e: MouseEvent): boolean {
|
|
20
|
+
return e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function setLabel(control: HTMLElement, label: string | undefined): void {
|
|
24
|
+
if (label) control.textContent = label;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function handleClick(e: MouseEvent): void {
|
|
28
|
+
const target = e.target;
|
|
29
|
+
if (!(target instanceof globalThis.Element)) return;
|
|
30
|
+
|
|
31
|
+
const control = target.closest<HTMLAnchorElement>("a[data-note-expand]");
|
|
32
|
+
if (!control || isModifiedClick(e)) return;
|
|
33
|
+
|
|
34
|
+
// Scope the body to this card — other cards on the page use the same
|
|
35
|
+
// [data-post-body] attribute. With no clampable body (server fell back to a
|
|
36
|
+
// full render), let the link navigate to the permalink as the fallback.
|
|
37
|
+
const article = control.closest<HTMLElement>("article[data-post]");
|
|
38
|
+
const body = article?.querySelector<HTMLElement>("[data-post-body]");
|
|
39
|
+
if (!article || !body || !body.querySelector("[data-note-break]")) return;
|
|
40
|
+
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
|
|
43
|
+
if (body.hasAttribute("data-note-clamp")) {
|
|
44
|
+
body.removeAttribute("data-note-clamp");
|
|
45
|
+
control.setAttribute("aria-expanded", "true");
|
|
46
|
+
setLabel(control, control.dataset.labelLess);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
body.setAttribute("data-note-clamp", "");
|
|
51
|
+
control.setAttribute("aria-expanded", "false");
|
|
52
|
+
setLabel(control, control.dataset.labelMore);
|
|
53
|
+
// Re-clamping shrinks content above the control; if the reader had scrolled
|
|
54
|
+
// into the tail, bring the note top back into view.
|
|
55
|
+
if (
|
|
56
|
+
article.getBoundingClientRect().top < 0 &&
|
|
57
|
+
typeof article.scrollIntoView === "function"
|
|
58
|
+
) {
|
|
59
|
+
article.scrollIntoView({ block: "start" });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
document.addEventListener("click", handleClick);
|
|
@@ -20,6 +20,8 @@ function parseSettingsInitialData(data: unknown): SettingsInitialData | null {
|
|
|
20
20
|
const siteName = getJsonString(data, "siteName");
|
|
21
21
|
const siteDescription = getJsonString(data, "siteDescription");
|
|
22
22
|
const siteLanguage = getJsonString(data, "siteLanguage");
|
|
23
|
+
// Tolerate older payloads without the key: empty = follow content language.
|
|
24
|
+
const dashboardLanguage = getJsonString(data, "dashboardLanguage") ?? "";
|
|
23
25
|
const cjkSerifFont = getJsonString(data, "cjkSerifFont");
|
|
24
26
|
const mainRssFeed = getJsonString(data, "mainRssFeed");
|
|
25
27
|
const timeZone = getJsonString(data, "timeZone");
|
|
@@ -45,6 +47,7 @@ function parseSettingsInitialData(data: unknown): SettingsInitialData | null {
|
|
|
45
47
|
siteName,
|
|
46
48
|
siteDescription,
|
|
47
49
|
siteLanguage,
|
|
50
|
+
dashboardLanguage,
|
|
48
51
|
cjkSerifFont,
|
|
49
52
|
mainRssFeed,
|
|
50
53
|
timeZone,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { Editor } from "@tiptap/core";
|
|
5
|
+
import { createMarkdownContentExtensions } from "../../../lib/markdown-manager.js";
|
|
6
|
+
import { ExitableMarks } from "../exitable-marks.js";
|
|
7
|
+
import { toggleMarkAndExit } from "../bubble-menu.js";
|
|
8
|
+
|
|
9
|
+
const editors: Editor[] = [];
|
|
10
|
+
|
|
11
|
+
function createEditor(content: string): Editor {
|
|
12
|
+
const element = document.createElement("div");
|
|
13
|
+
document.body.appendChild(element);
|
|
14
|
+
const editor = new Editor({
|
|
15
|
+
element,
|
|
16
|
+
extensions: [...createMarkdownContentExtensions(), ExitableMarks],
|
|
17
|
+
content,
|
|
18
|
+
});
|
|
19
|
+
editor.view.dispatch(editor.state.tr);
|
|
20
|
+
editors.push(editor);
|
|
21
|
+
return editor;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Mimic real ProseMirror typed input: each char is offered to handleTextInput
|
|
25
|
+
// (used by mark input rules); if unhandled, insert it normally.
|
|
26
|
+
function type(editor: Editor, text: string): void {
|
|
27
|
+
const view = editor.view;
|
|
28
|
+
for (const ch of text) {
|
|
29
|
+
const { from, to } = view.state.selection;
|
|
30
|
+
const handled = view.someProp("handleTextInput", (f) =>
|
|
31
|
+
f(view, from, to, ch, () => view.state.tr.insertText(ch, from, to)),
|
|
32
|
+
);
|
|
33
|
+
if (!handled) view.dispatch(view.state.tr.insertText(ch, from, to));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Marks on the text node containing the last character of the doc. */
|
|
38
|
+
function marksOfLastText(editor: Editor): string[] {
|
|
39
|
+
const json = editor.getJSON();
|
|
40
|
+
const para = json.content?.[0];
|
|
41
|
+
const last = para?.content?.[para.content.length - 1];
|
|
42
|
+
return (last?.marks ?? []).map((m: { type: string }) => m.type);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
while (editors.length) editors.pop()?.destroy();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("toggleMarkAndExit", () => {
|
|
50
|
+
it("formats the selection, then continued typing is plain", () => {
|
|
51
|
+
const editor = createEditor("<p>hello</p>");
|
|
52
|
+
editor.chain().setTextSelection({ from: 1, to: 6 }).run();
|
|
53
|
+
|
|
54
|
+
toggleMarkAndExit(editor, "strike");
|
|
55
|
+
type(editor, "Z");
|
|
56
|
+
|
|
57
|
+
// "hello" struck, "Z" plain — cursor exited the inclusive mark.
|
|
58
|
+
expect(editor.getJSON().content?.[0]).toEqual({
|
|
59
|
+
type: "paragraph",
|
|
60
|
+
content: [
|
|
61
|
+
{ type: "text", marks: [{ type: "strike" }], text: "hello" },
|
|
62
|
+
{ type: "text", text: "Z" },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("works for bold the same way", () => {
|
|
68
|
+
const editor = createEditor("<p>word</p>");
|
|
69
|
+
editor.chain().setTextSelection({ from: 1, to: 5 }).run();
|
|
70
|
+
|
|
71
|
+
toggleMarkAndExit(editor, "bold");
|
|
72
|
+
type(editor, "!");
|
|
73
|
+
|
|
74
|
+
expect(marksOfLastText(editor)).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("toggling a mark off leaves the cursor plain", () => {
|
|
78
|
+
const editor = createEditor("<p><strong>bold</strong></p>");
|
|
79
|
+
editor.chain().setTextSelection({ from: 1, to: 5 }).run();
|
|
80
|
+
|
|
81
|
+
toggleMarkAndExit(editor, "bold");
|
|
82
|
+
type(editor, "x");
|
|
83
|
+
|
|
84
|
+
// Mark removed from the selection and from the trailing cursor.
|
|
85
|
+
expect(editor.isActive("bold")).toBe(false);
|
|
86
|
+
expect(marksOfLastText(editor)).toEqual([]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("with an empty selection it acts as a plain mode toggle (stays on)", () => {
|
|
90
|
+
const editor = createEditor("<p></p>");
|
|
91
|
+
editor.chain().setTextSelection(1).run();
|
|
92
|
+
|
|
93
|
+
toggleMarkAndExit(editor, "bold");
|
|
94
|
+
type(editor, "ab");
|
|
95
|
+
|
|
96
|
+
// No selection → mode toggle: typed text carries the mark.
|
|
97
|
+
expect(marksOfLastText(editor)).toEqual(["bold"]);
|
|
98
|
+
});
|
|
99
|
+
});
|