@jant/core 0.3.44 → 0.3.46

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 (62) hide show
  1. package/bin/commands/import-site.js +40 -39
  2. package/dist/app-CM7sb3xO.js +5 -0
  3. package/dist/{app-CtJDxZBb.js → app-DB-P66E5.js} +147 -203
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-DDs6NzB3.css +2 -0
  6. package/dist/client/_assets/{client-auth-CXILhW1b.js → client-auth-BLCUje4M.js} +193 -174
  7. package/dist/client/_assets/{client-D95FNDg5.js → client-dSfWfMe9.js} +7 -7
  8. package/dist/{github-sync-7y_nTXx1.js → github-sync-CQ1x271f.js} +3 -0
  9. package/dist/index.js +4 -87
  10. package/dist/node.js +3 -3
  11. package/package.json +1 -1
  12. package/src/__tests__/import-site-command.test.ts +18 -0
  13. package/src/client/components/jant-compose-dialog.ts +94 -15
  14. package/src/client/components/jant-compose-editor.ts +11 -6
  15. package/src/client/components/jant-post-menu.ts +23 -5
  16. package/src/client/compose-bridge.ts +2 -1
  17. package/src/client/random-uuid.ts +23 -0
  18. package/src/client/toast.ts +29 -2
  19. package/src/client/upload-session.ts +1 -1
  20. package/src/db/migrations/0020_free_zaladane.sql +1 -0
  21. package/src/db/migrations/meta/0020_snapshot.json +2129 -0
  22. package/src/db/migrations/meta/_journal.json +7 -0
  23. package/src/db/migrations/pg/0018_red_warlock.sql +1 -0
  24. package/src/db/migrations/pg/meta/0018_snapshot.json +2739 -0
  25. package/src/db/migrations/pg/meta/_journal.json +7 -0
  26. package/src/db/pg/schema.ts +0 -30
  27. package/src/db/schema.ts +0 -39
  28. package/src/i18n/locales/public/en.po +10 -5
  29. package/src/i18n/locales/public/en.ts +1 -1
  30. package/src/i18n/locales/public/zh-Hans.po +10 -5
  31. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  32. package/src/i18n/locales/public/zh-Hant.po +10 -5
  33. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  34. package/src/index.ts +0 -3
  35. package/src/lib/__tests__/resolve-config.test.ts +4 -4
  36. package/src/lib/__tests__/startup-config.test.ts +27 -2
  37. package/src/lib/constants.ts +1 -0
  38. package/src/lib/github-sync-trigger.ts +7 -51
  39. package/src/lib/startup-config.ts +53 -6
  40. package/src/routes/api/github-sync.tsx +36 -14
  41. package/src/routes/pages/home.tsx +2 -0
  42. package/src/routes/pages/latest.tsx +2 -0
  43. package/src/runtime/__tests__/readiness.test.ts +34 -0
  44. package/src/runtime/readiness.ts +8 -4
  45. package/src/services/__tests__/collection.test.ts +13 -11
  46. package/src/services/github-sync.ts +6 -0
  47. package/src/styles/components.css +14 -0
  48. package/src/styles/ui.css +97 -0
  49. package/src/types/bindings.ts +0 -2
  50. package/src/types/config.ts +1 -1
  51. package/src/types/props.ts +2 -0
  52. package/src/ui/__tests__/font-themes.test.ts +2 -2
  53. package/src/ui/dash/settings/SettingsRootContent.tsx +17 -17
  54. package/src/ui/font-themes.ts +17 -17
  55. package/src/ui/pages/HomePage.tsx +18 -5
  56. package/dist/app-BI9bnCkO.js +0 -5
  57. package/dist/client/_assets/client-BQH7AQ24.css +0 -2
  58. package/src/lib/github-sync-queue-handler.ts +0 -69
  59. package/src/lib/github-sync-worker.ts +0 -72
  60. package/src/lib/job-queue-cf.ts +0 -18
  61. package/src/lib/job-queue-db.ts +0 -149
  62. package/src/lib/job-queue.ts +0 -35
@@ -40,6 +40,7 @@ import { getMediaCategory } from "../../lib/upload.js";
40
40
  import { getSlugValidationIssue } from "../../lib/slug-format.js";
41
41
  import { createTiptapEditor } from "../tiptap/create-editor.js";
42
42
  import { MAX_THREAD_POSTS } from "../../types.js";
43
+ import { randomUUID } from "../random-uuid.js";
43
44
 
44
45
  interface ReplyToMedia {
45
46
  url: string;
@@ -641,6 +642,7 @@ export class JantComposeDialog extends LitElement {
641
642
  private _suppressBeforeUnload = false;
642
643
  private _dialogEl: HTMLDialogElement | null = null;
643
644
  private _mousedownOnBackdrop = false;
645
+ private _mousedownPos: { x: number; y: number } | null = null;
644
646
  private _filePickerActive = false;
645
647
  private _ignoreNextEscapeClose = false;
646
648
  private _openEditRequestId = 0;
@@ -2264,13 +2266,36 @@ export class JantComposeDialog extends LitElement {
2264
2266
  this.requestClose();
2265
2267
  };
2266
2268
 
2269
+ // Returns true if the given point is inside any open top-layer popover.
2270
+ // Browsers sometimes fire backdrop click events even when the pointer is
2271
+ // over a popover that is rendered above the dialog in the top layer —
2272
+ // document.elementFromPoint() ignores the top layer, so we check bounding
2273
+ // rects manually.
2274
+ private _pointInOpenPopover(x: number, y: number): boolean {
2275
+ for (const el of document.querySelectorAll<HTMLElement>(":popover-open")) {
2276
+ const r = el.getBoundingClientRect();
2277
+ if (x >= r.left && x <= r.right && y >= r.top && y <= r.bottom) {
2278
+ return true;
2279
+ }
2280
+ }
2281
+ return false;
2282
+ }
2283
+
2267
2284
  private _handleDialogMousedown = (e: Event) => {
2268
2285
  // Track whether the mousedown originated on the backdrop (the <dialog>
2269
2286
  // itself). When the user drag-selects text inside the editor and the
2270
2287
  // pointer overshoots to the backdrop, the subsequent click event fires
2271
2288
  // with target === dialog. Without this guard, that click triggers
2272
2289
  // requestClose() and the unsaved-changes confirmation pops up.
2273
- this._mousedownOnBackdrop = e.target === this._dialogEl;
2290
+ const me = e as MouseEvent;
2291
+ // Treat as backdrop only when target is the dialog AND the cursor is not
2292
+ // over an open popover (e.g. a toast notification in the top layer).
2293
+ this._mousedownOnBackdrop =
2294
+ e.target === this._dialogEl &&
2295
+ !this._pointInOpenPopover(me.clientX, me.clientY);
2296
+ this._mousedownPos = this._mousedownOnBackdrop
2297
+ ? { x: me.clientX, y: me.clientY }
2298
+ : null;
2274
2299
  };
2275
2300
 
2276
2301
  private _handleDialogClick = (e: Event) => {
@@ -2279,6 +2304,26 @@ export class JantComposeDialog extends LitElement {
2279
2304
  if (!this._mousedownOnBackdrop) return;
2280
2305
 
2281
2306
  const mouseEvent = e as MouseEvent;
2307
+
2308
+ // If the pointer moved more than 4px since mousedown, the user was
2309
+ // dragging (e.g. selecting text in a toast on top of the backdrop) —
2310
+ // don't treat that as an intentional dismiss click.
2311
+ if (this._mousedownPos) {
2312
+ const dx = mouseEvent.clientX - this._mousedownPos.x;
2313
+ const dy = mouseEvent.clientY - this._mousedownPos.y;
2314
+ if (dx * dx + dy * dy > 16) return;
2315
+ }
2316
+
2317
+ // Also guard against text-selection drags that end back on the backdrop.
2318
+ const selection = document.getSelection();
2319
+ if (selection && !selection.isCollapsed) return;
2320
+
2321
+ // Guard against click pass-through from a top-layer popover: browsers can
2322
+ // route a click on a popover to the dialog backdrop simultaneously.
2323
+ if (this._pointInOpenPopover(mouseEvent.clientX, mouseEvent.clientY)) {
2324
+ return;
2325
+ }
2326
+
2282
2327
  const hitTarget = document.elementFromPoint(
2283
2328
  mouseEvent.clientX,
2284
2329
  mouseEvent.clientY,
@@ -2744,9 +2789,9 @@ export class JantComposeDialog extends LitElement {
2744
2789
 
2745
2790
  // Enter thread mode
2746
2791
  this._threadItems = [
2747
- { id: crypto.randomUUID(), format: post.format },
2792
+ { id: randomUUID(), format: post.format },
2748
2793
  ...ordered.map((p) => ({
2749
- id: crypto.randomUUID(),
2794
+ id: randomUUID(),
2750
2795
  format: p.format,
2751
2796
  })),
2752
2797
  ];
@@ -3117,7 +3162,7 @@ export class JantComposeDialog extends LitElement {
3117
3162
  if (draft.threadItems && draft.threadItems.length >= 2) {
3118
3163
  this._format = draft.threadItems[0].format;
3119
3164
  this._threadItems = draft.threadItems.map((item) => ({
3120
- id: crypto.randomUUID(),
3165
+ id: randomUUID(),
3121
3166
  format: item.format,
3122
3167
  }));
3123
3168
  this._focusedThreadIndex = 0;
@@ -4962,6 +5007,38 @@ export class JantComposeDialog extends LitElement {
4962
5007
  `;
4963
5008
  }
4964
5009
 
5010
+ private _renderHideFromLatestQuickToggleRow() {
5011
+ if (this._visibilityLocked) return nothing;
5012
+ if (this._visibility === "private") return nothing;
5013
+ return html`
5014
+ <div class="compose-quick-actions-row">
5015
+ ${this._renderHideFromLatestQuickToggle()}
5016
+ </div>
5017
+ `;
5018
+ }
5019
+
5020
+ private _renderHideFromLatestQuickToggle() {
5021
+ if (this._visibilityLocked) return nothing;
5022
+ if (this._visibility === "private") return nothing;
5023
+
5024
+ const checked = this._visibility === "latest_hidden";
5025
+ return html`
5026
+ <label class="compose-publish-quick-toggle">
5027
+ <input
5028
+ type="checkbox"
5029
+ class="input compose-publish-quick-toggle-input"
5030
+ .checked=${checked}
5031
+ ?disabled=${this._loading}
5032
+ @change=${(e: Event) => {
5033
+ const target = e.target as HTMLInputElement;
5034
+ this._setVisibility(target.checked ? "latest_hidden" : "public");
5035
+ }}
5036
+ />
5037
+ <span>${this.labels.publishHideFromLatest}</span>
5038
+ </label>
5039
+ `;
5040
+ }
5041
+
4965
5042
  private _renderEditLoadingState() {
4966
5043
  return html`
4967
5044
  <div
@@ -5021,8 +5098,8 @@ export class JantComposeDialog extends LitElement {
5021
5098
  const bodyJson = currentEditor?.getNormalizedBodyJson() ?? null;
5022
5099
 
5023
5100
  this._threadItems = [
5024
- { id: crypto.randomUUID(), format: this._format },
5025
- { id: crypto.randomUUID(), format: lastFormat },
5101
+ { id: randomUUID(), format: this._format },
5102
+ { id: randomUUID(), format: lastFormat },
5026
5103
  ];
5027
5104
 
5028
5105
  // Capture rating state before re-render (these can't change asynchronously)
@@ -5081,7 +5158,7 @@ export class JantComposeDialog extends LitElement {
5081
5158
  } else {
5082
5159
  this._threadItems = [
5083
5160
  ...this._threadItems,
5084
- { id: crypto.randomUUID(), format: lastFormat },
5161
+ { id: randomUUID(), format: lastFormat },
5085
5162
  ];
5086
5163
  }
5087
5164
 
@@ -5343,14 +5420,16 @@ export class JantComposeDialog extends LitElement {
5343
5420
  ${isOpeningEdit
5344
5421
  ? nothing
5345
5422
  : html`<div
5346
- class=${classMap({
5347
- "compose-action-row": true,
5348
- "compose-action-row-overlay-open":
5349
- this._showPublishPanel || this._showCollection,
5350
- })}
5351
- >
5352
- ${this._renderCollectionSelector()} ${this._renderPublishButton()}
5353
- </div>`}
5423
+ class=${classMap({
5424
+ "compose-action-row": true,
5425
+ "compose-action-row-overlay-open":
5426
+ this._showPublishPanel || this._showCollection,
5427
+ })}
5428
+ >
5429
+ ${this._renderCollectionSelector()}
5430
+ ${this._renderPublishButton()}
5431
+ </div>
5432
+ ${this._renderHideFromLatestQuickToggleRow()}`}
5354
5433
  ${this._renderMobilePublishPanel()} ${this._renderAttachedPanel()}
5355
5434
  ${this._renderAltPanel()} ${this._renderDraftsPanel()}
5356
5435
  ${this._renderConfirmPanel()}
@@ -47,6 +47,7 @@ import {
47
47
  adoptPendingInlineImageUploads,
48
48
  } from "../tiptap/inline-image-upload.js";
49
49
  import { isSafeAbsoluteUrl } from "../../lib/url.js";
50
+ import { randomUUID } from "../random-uuid.js";
50
51
 
51
52
  interface ComposeFilePickerCloseDetail {
52
53
  cancelled: boolean;
@@ -220,7 +221,7 @@ export class JantComposeEditor extends LitElement {
220
221
  private _lastEditorSelection: ComposeEditorSelection | null = null;
221
222
  private _emojiPickerEl: HTMLElement | null = null;
222
223
  private _emojiContainer: HTMLElement | null = null;
223
- private readonly _urlStatusId = `compose-url-status-${crypto.randomUUID()}`;
224
+ private readonly _urlStatusId = `compose-url-status-${randomUUID()}`;
224
225
  private _onDocClickBound = this._onDocumentClick.bind(this);
225
226
  private _scrollBufferApplied = false;
226
227
  private _filePickerCleanup: (() => void) | null = null;
@@ -880,7 +881,7 @@ export class JantComposeEditor extends LitElement {
880
881
  // Convert media attachments to ComposeAttachment[] with status "done"
881
882
  if (data.media?.length) {
882
883
  const attachments = data.media.map((m) => ({
883
- clientId: crypto.randomUUID(),
884
+ clientId: randomUUID(),
884
885
  file: new File([], m.originalName ?? "existing", { type: m.mimeType }),
885
886
  previewUrl: m.previewUrl,
886
887
  posterUrl: null,
@@ -906,7 +907,7 @@ export class JantComposeEditor extends LitElement {
906
907
  // Invalid JSON — leave as null
907
908
  }
908
909
  return {
909
- clientId: t.clientId ?? crypto.randomUUID(),
910
+ clientId: t.clientId ?? randomUUID(),
910
911
  bodyJson: parsed,
911
912
  bodyHtml: t.bodyHtml ?? "",
912
913
  summary: t.summary,
@@ -979,7 +980,7 @@ export class JantComposeEditor extends LitElement {
979
980
 
980
981
  private _openAttachedText() {
981
982
  const item: AttachedTextItem = {
982
- clientId: crypto.randomUUID(),
983
+ clientId: randomUUID(),
983
984
  bodyJson: null,
984
985
  bodyHtml: "",
985
986
  summary: "",
@@ -1242,7 +1243,7 @@ export class JantComposeEditor extends LitElement {
1242
1243
  continue;
1243
1244
  }
1244
1245
 
1245
- const clientId = crypto.randomUUID();
1246
+ const clientId = randomUUID();
1246
1247
  const previewUrl = URL.createObjectURL(file);
1247
1248
  newAttachments.push({
1248
1249
  clientId,
@@ -1961,6 +1962,11 @@ export class JantComposeEditor extends LitElement {
1961
1962
  </svg>
1962
1963
  <span class="compose-retry-label">${this.labels.retryAll}</span>
1963
1964
  </span>
1965
+ ${a.error
1966
+ ? html`<span class="compose-attachment-error-msg"
1967
+ >${a.error}</span
1968
+ >`
1969
+ : nothing}
1964
1970
  </button>
1965
1971
  `
1966
1972
  : nothing}
@@ -1976,7 +1982,6 @@ export class JantComposeEditor extends LitElement {
1976
1982
  <button
1977
1983
  type="button"
1978
1984
  class="compose-attachment-remove"
1979
- title=${this.labels.removeAttachment}
1980
1985
  aria-label=${this.labels.removeAttachment}
1981
1986
  @click=${onClick}
1982
1987
  >
@@ -719,11 +719,29 @@ export class JantPostMenu extends LitElement {
719
719
  const article = document.querySelector<HTMLElement>(
720
720
  `article[data-post-id="${this._data.id}"]`,
721
721
  );
722
- // Remove the feed item wrapper if it exists, otherwise the article itself
723
- const feedItem = article?.closest(".feed-item");
724
- const feedContainer = feedItem?.parentElement;
725
- (feedItem ?? article)?.remove();
726
- removeLeadingFeedDivider(feedContainer);
722
+ // If the post is inside a thread group, only remove its .thread-item
723
+ // wrapper so sibling posts in the same thread stay visible. Fall back
724
+ // to removing the .feed-item wrapper (or the article itself) only when
725
+ // the post is standalone or the thread group is now empty.
726
+ const threadItem = article?.closest<HTMLElement>(".thread-item");
727
+ const threadGroup = threadItem?.closest<HTMLElement>(".thread-group");
728
+ if (threadItem && threadGroup) {
729
+ threadItem.remove();
730
+ const remainingPosts = threadGroup.querySelectorAll(
731
+ ".thread-item:not(.thread-item-gap)",
732
+ );
733
+ if (remainingPosts.length === 0) {
734
+ const feedItem = threadGroup.closest<HTMLElement>(".feed-item");
735
+ const feedContainer = feedItem?.parentElement ?? null;
736
+ (feedItem ?? threadGroup).remove();
737
+ removeLeadingFeedDivider(feedContainer);
738
+ }
739
+ } else {
740
+ const feedItem = article?.closest<HTMLElement>(".feed-item");
741
+ const feedContainer = feedItem?.parentElement ?? null;
742
+ (feedItem ?? article)?.remove();
743
+ removeLeadingFeedDivider(feedContainer);
744
+ }
727
745
 
728
746
  showToast("Post deleted.");
729
747
  } catch {
@@ -423,7 +423,8 @@ async function uploadFile(
423
423
  } catch (error) {
424
424
  const message = error instanceof Error ? error.message : "Upload failed";
425
425
  editor?.updateAttachmentStatus(clientId, "error", null, message);
426
- showToast(message, "error");
426
+ // Error is shown on the attachment thumbnail; only toast when there's no editor context.
427
+ if (!editor) showToast(message, "error");
427
428
  return null;
428
429
  }
429
430
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * RFC4122 v4 UUID generator with fallback for insecure contexts.
3
+ *
4
+ * `crypto.randomUUID()` is only available in secure contexts (HTTPS or
5
+ * localhost). Self-hosted Jant deployments are often accessed over plain
6
+ * HTTP on a LAN address, where the native API is `undefined`. This helper
7
+ * uses the native API when available and falls back to a `Math.random`-based
8
+ * v4 string otherwise. The fallback is not cryptographically strong, but
9
+ * client-side IDs here only need to be unique within a single page session.
10
+ */
11
+ export const randomUUID = (): string => {
12
+ if (
13
+ typeof crypto !== "undefined" &&
14
+ typeof crypto.randomUUID === "function"
15
+ ) {
16
+ return crypto.randomUUID();
17
+ }
18
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
19
+ const r = (Math.random() * 16) | 0;
20
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
21
+ return v.toString(16);
22
+ });
23
+ };
@@ -22,10 +22,17 @@ export const QUEUED_TOAST_STORAGE_KEY = "jant.pendingToast";
22
22
 
23
23
  /** Ensure the toast container is in the top layer (above <dialog> etc.) */
24
24
  function ensureTopLayer(container: HTMLElement): void {
25
+ if (typeof container.showPopover !== "function") return;
26
+
27
+ // Re-promote above any modal dialog that was opened after the toast container.
25
28
  if (
26
- !container.matches(":popover-open") &&
27
- typeof container.showPopover === "function"
29
+ container.matches(":popover-open") &&
30
+ document.querySelector("dialog[open]")
28
31
  ) {
32
+ container.hidePopover();
33
+ }
34
+
35
+ if (!container.matches(":popover-open")) {
29
36
  container.showPopover();
30
37
  }
31
38
  }
@@ -85,6 +92,11 @@ const TOAST_ICONS = {
85
92
  '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6M9 9l6 6"/></svg>',
86
93
  };
87
94
 
95
+ const COPY_ICON =
96
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>';
97
+ const CHECK_ICON =
98
+ '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path d="M20 6 9 17l-5-5"/></svg>';
99
+
88
100
  /** Build toast inner content using safe DOM APIs (icon is trusted, text uses textContent). */
89
101
  function setToastContent(
90
102
  toast: HTMLElement,
@@ -103,6 +115,21 @@ function setToastContent(
103
115
  a.textContent = action.label;
104
116
  toast.appendChild(a);
105
117
  }
118
+ if (type === "error" && navigator.clipboard) {
119
+ const btn = document.createElement("button");
120
+ btn.className = "toast-copy";
121
+ btn.setAttribute("aria-label", "Copy error message");
122
+ btn.innerHTML = COPY_ICON;
123
+ btn.addEventListener("click", () => {
124
+ navigator.clipboard.writeText(message).then(() => {
125
+ btn.innerHTML = CHECK_ICON;
126
+ setTimeout(() => {
127
+ btn.innerHTML = COPY_ICON;
128
+ }, 1500);
129
+ });
130
+ });
131
+ toast.appendChild(btn);
132
+ }
106
133
  }
107
134
 
108
135
  /**
@@ -108,7 +108,7 @@ async function initiateUpload(file: File): Promise<InitiateResponse> {
108
108
  headers: { "Content-Type": "application/json" },
109
109
  body: JSON.stringify({
110
110
  filename: file.name,
111
- contentType: file.type,
111
+ contentType: file.type || "application/octet-stream",
112
112
  size: file.size,
113
113
  checksumSha256: await sha256Base64(file),
114
114
  }),
@@ -0,0 +1 @@
1
+ DROP TABLE `sync_job`;