@jant/core 0.6.7 → 0.6.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/bin/commands/uploads/cleanup.js +2 -0
  2. package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
  3. package/dist/app-DqHzOwL5.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-CGf2m3qp.css +2 -0
  6. package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
  7. package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
  13. package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/note-expand.test.ts +130 -0
  22. package/src/client/archive-nav.js +2 -1
  23. package/src/client/audio-player.ts +7 -3
  24. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
  29. package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
  30. package/src/client/components/compose-format-convert.ts +255 -0
  31. package/src/client/components/compose-types.ts +2 -0
  32. package/src/client/components/jant-compose-dialog.ts +110 -44
  33. package/src/client/components/jant-compose-editor.ts +64 -11
  34. package/src/client/components/jant-settings-general.ts +56 -18
  35. package/src/client/components/settings-types.ts +11 -0
  36. package/src/client/compose-bridge.ts +17 -0
  37. package/src/client/feed-video-player.ts +1 -1
  38. package/src/client/hydrate-partial.ts +25 -0
  39. package/src/client/note-expand.ts +63 -0
  40. package/src/client/settings-bridge.ts +3 -0
  41. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  42. package/src/client/tiptap/bubble-menu.ts +37 -4
  43. package/src/client.ts +1 -0
  44. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  45. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  46. package/src/db/migrations/meta/_journal.json +7 -0
  47. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  48. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  49. package/src/db/migrations/pg/meta/_journal.json +7 -0
  50. package/src/db/pg/schema.ts +36 -0
  51. package/src/db/schema.ts +36 -0
  52. package/src/i18n/__tests__/middleware.test.ts +46 -0
  53. package/src/i18n/locales/public/en.po +41 -0
  54. package/src/i18n/locales/public/en.ts +1 -1
  55. package/src/i18n/locales/public/zh-Hans.po +41 -0
  56. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  57. package/src/i18n/locales/public/zh-Hant.po +41 -0
  58. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  59. package/src/i18n/locales/settings/en.po +37 -22
  60. package/src/i18n/locales/settings/en.ts +1 -1
  61. package/src/i18n/locales/settings/zh-Hans.po +37 -22
  62. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hant.po +37 -22
  64. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  65. package/src/i18n/middleware.ts +17 -8
  66. package/src/i18n/supported-locales.ts +5 -4
  67. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  68. package/src/lib/__tests__/markdown.test.ts +1 -1
  69. package/src/lib/__tests__/summary.test.ts +87 -0
  70. package/src/lib/__tests__/timeline.test.ts +48 -1
  71. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  72. package/src/lib/__tests__/url.test.ts +44 -0
  73. package/src/lib/__tests__/view.test.ts +168 -1
  74. package/src/lib/ids.ts +1 -0
  75. package/src/lib/navigation.ts +1 -0
  76. package/src/lib/resolve-config.ts +3 -2
  77. package/src/lib/summary.ts +42 -3
  78. package/src/lib/tiptap-render.ts +6 -2
  79. package/src/lib/upload.ts +16 -2
  80. package/src/lib/url.ts +41 -0
  81. package/src/lib/view.ts +102 -40
  82. package/src/preset.css +7 -1
  83. package/src/routes/api/__tests__/settings.test.ts +1 -4
  84. package/src/routes/api/__tests__/upload.test.ts +2 -0
  85. package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
  86. package/src/routes/api/internal/sites.ts +44 -1
  87. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  88. package/src/routes/api/public/archive.ts +22 -6
  89. package/src/routes/api/settings.ts +2 -1
  90. package/src/routes/api/telegram.ts +2 -1
  91. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  92. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  93. package/src/routes/dash/custom-urls.tsx +1 -1
  94. package/src/routes/dash/settings.tsx +23 -7
  95. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  96. package/src/routes/pages/archive.tsx +116 -20
  97. package/src/routes/pages/collections.tsx +1 -0
  98. package/src/services/__tests__/media.test.ts +274 -30
  99. package/src/services/__tests__/post.test.ts +81 -0
  100. package/src/services/__tests__/settings.test.ts +55 -0
  101. package/src/services/bootstrap.ts +7 -0
  102. package/src/services/export-theme/assets/client-site.js +1 -1
  103. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  104. package/src/services/export-theme/styles/main.css +49 -15
  105. package/src/services/media.ts +199 -42
  106. package/src/services/post.ts +22 -2
  107. package/src/services/search.ts +4 -4
  108. package/src/services/settings.ts +49 -15
  109. package/src/services/upload-session.ts +28 -0
  110. package/src/styles/tokens.css +7 -5
  111. package/src/styles/ui.css +163 -34
  112. package/src/types/bindings.ts +1 -0
  113. package/src/types/config.ts +14 -1
  114. package/src/types/props.ts +3 -0
  115. package/src/ui/compose/ComposeDialog.tsx +13 -0
  116. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  117. package/src/ui/dash/settings/GeneralContent.tsx +38 -4
  118. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  119. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  120. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  121. package/src/ui/feed/NoteCard.tsx +54 -5
  122. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  123. package/src/ui/layouts/BaseLayout.tsx +1 -0
  124. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
  125. package/src/ui/pages/ArchivePage.tsx +89 -6
  126. package/src/ui/pages/CollectionsPage.tsx +7 -1
  127. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  128. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  129. package/src/ui/shared/CollectionsManager.tsx +3 -0
  130. package/dist/app-C1QgMNRY.js +0 -6
  131. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -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 = 500;
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
- for (const key of changed.keys()) {
908
- if (JantComposeEditor._CONTENT_PROPS.has(key as string)) {
909
- this.dispatchEvent(
910
- new Event("jant:compose-content-changed", { bubbles: true }),
911
- );
912
- break;
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 _renderThreadPostHeader() {
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:thread-format-change", {
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 ? this._renderThreadPostHeader() : nothing}
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="input flex w-full items-center justify-between text-left"
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 right-0 top-full z-10 mt-1 max-h-72 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md"
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 items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-accent",
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 class="flex flex-col">
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
- ${Math.round(entry.coverage * 100)}% translated
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.ParentNode = document,
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
+ });