@jant/core 0.6.11 → 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.
Files changed (72) hide show
  1. package/bin/commands/import-site.js +45 -20
  2. package/dist/app-B5P1b4TF.js +6 -0
  3. package/dist/{app-CpmficmQ.js → app-D-zAhayc.js} +331 -55
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-BUxR-E8O.css +2 -0
  6. package/dist/client/_assets/{client-Dd9U383b.js → client-S1cdvulk.js} +1 -1
  7. package/dist/client/_assets/{client-auth-DkpSdIDz.js → client-auth-YW92ZH7u.js} +116 -55
  8. package/dist/{export-Ba7NJImL.js → export-Ckwh4BiE.js} +4 -4
  9. package/dist/{github-sync-BD4w2m8-.js → github-sync-BMmK3JZ0.js} +2 -2
  10. package/dist/{github-sync-Cb4_6_i7.js → github-sync-Dl9wQMar.js} +1 -1
  11. package/dist/index.js +3 -3
  12. package/dist/node.js +4 -4
  13. package/package.json +1 -1
  14. package/src/__tests__/import-site-command.test.ts +75 -0
  15. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  16. package/src/client/__tests__/compose-launch.test.ts +50 -1
  17. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  18. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  19. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  20. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +20 -1
  21. package/src/client/components/__tests__/jant-nav-manager.test.ts +77 -1
  22. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  23. package/src/client/components/__tests__/jant-settings-general.test.ts +71 -0
  24. package/src/client/components/jant-command-palette.ts +67 -2
  25. package/src/client/components/jant-compose-dialog.ts +8 -2
  26. package/src/client/components/jant-nav-manager.ts +142 -2
  27. package/src/client/components/jant-settings-general.ts +56 -0
  28. package/src/client/components/nav-manager-types.ts +14 -0
  29. package/src/client/components/settings-types.ts +30 -0
  30. package/src/client/compose-launch.ts +30 -0
  31. package/src/client/compose-shortcuts.ts +46 -3
  32. package/src/client/tiptap/__tests__/rehost-images.test.ts +66 -13
  33. package/src/client/tiptap/rehost-images.ts +5 -1
  34. package/src/i18n/__tests__/fallback.test.ts +24 -0
  35. package/src/i18n/locales/public/zh-Hans.po +2 -2
  36. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  37. package/src/i18n/locales/public/zh-Hant.po +2 -2
  38. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  39. package/src/i18n/locales/settings/en.po +54 -0
  40. package/src/i18n/locales/settings/en.ts +1 -1
  41. package/src/i18n/locales/settings/zh-Hans.po +64 -10
  42. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  43. package/src/i18n/locales/settings/zh-Hant.po +56 -2
  44. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  45. package/src/lib/__tests__/feed.test.ts +96 -10
  46. package/src/lib/feed.ts +63 -3
  47. package/src/lib/post-display.ts +14 -8
  48. package/src/preset.css +60 -18
  49. package/src/routes/dash/settings.tsx +41 -2
  50. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  51. package/src/routes/pages/page.tsx +44 -12
  52. package/src/services/__tests__/about-page.test.ts +116 -0
  53. package/src/services/__tests__/navigation.test.ts +190 -1
  54. package/src/services/__tests__/post-timeline.test.ts +119 -2
  55. package/src/services/about-page.ts +143 -0
  56. package/src/services/export-theme/layouts/_default/rss.xml +2 -1
  57. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  58. package/src/services/export-theme/styles/main.css +46 -14
  59. package/src/services/index.ts +27 -17
  60. package/src/services/navigation.ts +155 -1
  61. package/src/services/post.ts +24 -7
  62. package/src/styles/components.css +20 -0
  63. package/src/styles/tokens.css +26 -8
  64. package/src/styles/ui.css +149 -7
  65. package/src/types/views.ts +13 -0
  66. package/src/ui/dash/appearance/NavigationContent.tsx +57 -0
  67. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  68. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  69. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  70. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  71. package/dist/app-DqKkZenB.js +0 -6
  72. package/dist/client/_assets/client-BhHHVvSY.css +0 -2
@@ -7,6 +7,7 @@ import type {
7
7
  SettingsCjkFont,
8
8
  SettingsDashboardLanguage,
9
9
  SettingsSaveDetail,
10
+ SettingsAboutPageStatus,
10
11
  } from "../settings-types.js";
11
12
  import { MAX_SITE_NAME_LENGTH } from "../../../types.js";
12
13
  import "../jant-settings-general.js";
@@ -76,6 +77,12 @@ const labels: SettingsLabels = {
76
77
  uploadError: "Upload failed.",
77
78
  general: "General",
78
79
  site: "Site",
80
+ aboutPage: "About page",
81
+ aboutPagePrompt: "Want to write a fuller introduction?",
82
+ aboutPageConflict:
83
+ "/about is already used. Rename that item before creating an About page.",
84
+ createAboutPage: "Create About page",
85
+ editAboutPage: "Edit About page",
79
86
  languageAndTime: "Language & Time",
80
87
  home: "Home",
81
88
  search: "Search",
@@ -167,6 +174,7 @@ function findCheckboxByLabel(
167
174
  async function createElement(
168
175
  opts: {
169
176
  demoMode?: boolean;
177
+ aboutPage?: SettingsAboutPageStatus;
170
178
  } = {},
171
179
  ): Promise<JantSettingsGeneral> {
172
180
  const el = document.createElement(
@@ -182,6 +190,14 @@ async function createElement(
182
190
  el.latestFeedUrl = "/latest/feed";
183
191
  el.featuredFeedUrl = "/featured/feed";
184
192
  el.archiveFeedUrl = "/archive/feed";
193
+ el.aboutPage =
194
+ opts.aboutPage ??
195
+ ({
196
+ state: "missing",
197
+ path: "/about",
198
+ } satisfies SettingsAboutPageStatus);
199
+ el.aboutEditUrl = "/about?edit=1";
200
+ el.aboutCreateUrl = "/settings/general/about-page";
185
201
  el.demoMode = opts.demoMode ?? false;
186
202
  document.body.appendChild(el);
187
203
  await el.updateComplete;
@@ -209,6 +225,61 @@ describe("JantSettingsGeneral", () => {
209
225
  labels.home,
210
226
  labels.search,
211
227
  ]);
228
+
229
+ const siteSection = requireElement(
230
+ findSectionByHeading(el, labels.site),
231
+ "expected site section",
232
+ );
233
+ expect(siteSection.querySelector("[data-about-page-row]")).not.toBeNull();
234
+ });
235
+
236
+ it("renders a create About form from the missing About prompt", async () => {
237
+ const el = await createElement();
238
+ const aboutRow = requireElement(
239
+ el.querySelector<HTMLElement>("[data-about-page-row]"),
240
+ "expected About page row",
241
+ );
242
+ const form = requireElement(
243
+ aboutRow.querySelector<HTMLFormElement>("form"),
244
+ "expected create About page form",
245
+ );
246
+ const button = requireElement(
247
+ form.querySelector<HTMLButtonElement>("button"),
248
+ "expected create About page button",
249
+ );
250
+
251
+ expect(aboutRow.textContent).toContain(labels.aboutPagePrompt);
252
+ expect(form.method).toBe("post");
253
+ expect(form.action).toContain("/settings/general/about-page");
254
+ expect(button.textContent).toContain(labels.createAboutPage);
255
+ });
256
+
257
+ it("renders an edit link when the About page exists", async () => {
258
+ const el = await createElement({
259
+ aboutPage: {
260
+ state: "ready",
261
+ path: "/about",
262
+ post: {
263
+ id: "pst_about000000000000000000000",
264
+ title: "About",
265
+ status: "published",
266
+ visibility: "latest_hidden",
267
+ },
268
+ },
269
+ });
270
+
271
+ const aboutRow = requireElement(
272
+ el.querySelector<HTMLElement>("[data-about-page-row]"),
273
+ "expected About page row",
274
+ );
275
+ const editLink = requireElement(
276
+ aboutRow.querySelector<HTMLAnchorElement>("a"),
277
+ "expected About edit link",
278
+ );
279
+
280
+ expect(aboutRow.textContent).toContain(labels.aboutPagePrompt);
281
+ expect(editLink.textContent).toContain(labels.editAboutPage);
282
+ expect(editLink.href).toContain("/about?edit=1");
212
283
  });
213
284
 
214
285
  it("renders form fields with initial values", async () => {
@@ -5,6 +5,7 @@
5
5
  * - Navigate (default): filter preloaded posts, collections, system pages
6
6
  * - Command (`>` prefix): run actions like New Post
7
7
  * - Search (`?` prefix): redirects to /search?q=... on Enter
8
+ * - Path (`/` prefix): opens a local path, with search as the second option
8
9
  */
9
10
 
10
11
  import { LitElement, html, nothing } from "lit";
@@ -45,10 +46,24 @@ const ICONS = {
45
46
  system: `<svg ${ICON_ATTRS}><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>`,
46
47
  // zap/lightning for commands
47
48
  command: `<svg ${ICON_ATTRS}><path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>`,
49
+ // corner-down-right for local path navigation
50
+ path: `<svg ${ICON_ATTRS}><polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg>`,
48
51
  // search
49
52
  search: `<svg ${ICON_ATTRS}><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>`,
50
53
  };
51
54
 
55
+ export function getPathCommandTarget(query: string): string {
56
+ const stripped = query.trim().replace(/^[//]+/, "");
57
+ return stripped ? `/${stripped}` : "/";
58
+ }
59
+
60
+ export function getPathCommandSearchQuery(query: string): string {
61
+ return query
62
+ .trim()
63
+ .replace(/^[//]+/, "")
64
+ .trim();
65
+ }
66
+
52
67
  // ---------------------------------------------------------------------------
53
68
  // History helpers
54
69
  // ---------------------------------------------------------------------------
@@ -208,12 +223,13 @@ export class JantCommandPalette extends LitElement {
208
223
  // Mode detection
209
224
  // -----------------------------------------------------------------------
210
225
 
211
- get #mode(): "navigate" | "command" | "search" {
212
- // Accept both halfwidth (>, ?) and fullwidth (>, ?) prefixes so CJK
226
+ get #mode(): "navigate" | "command" | "search" | "path" {
227
+ // Accept both halfwidth and fullwidth prefixes so CJK
213
228
  // users don't have to toggle their IME to enter command/search mode.
214
229
  const first = this._query.charAt(0);
215
230
  if (first === ">" || first === ">") return "command";
216
231
  if (first === "?" || first === "?") return "search";
232
+ if (first === "/" || first === "/") return "path";
217
233
  return "navigate";
218
234
  }
219
235
 
@@ -270,6 +286,8 @@ export class JantCommandPalette extends LitElement {
270
286
  icon: string;
271
287
  /** For search-mode items: the query to execute */
272
288
  searchQuery?: string;
289
+ /** For path-mode items: the local path to open */
290
+ pathTarget?: string;
273
291
  }> {
274
292
  const mode = this.#mode;
275
293
 
@@ -299,12 +317,41 @@ export class JantCommandPalette extends LitElement {
299
317
  ];
300
318
  }
301
319
 
320
+ if (mode === "path") {
321
+ const target = getPathCommandTarget(this._query);
322
+ const searchQuery = getPathCommandSearchQuery(this._query);
323
+ const items: Array<{
324
+ label: string;
325
+ secondary?: string;
326
+ icon: string;
327
+ searchQuery?: string;
328
+ pathTarget?: string;
329
+ }> = [
330
+ {
331
+ label: `Go to ${target}`,
332
+ icon: ICONS.path,
333
+ pathTarget: target,
334
+ },
335
+ ];
336
+
337
+ if (searchQuery) {
338
+ items.push({
339
+ label: `Search for "${searchQuery}"`,
340
+ icon: ICONS.search,
341
+ searchQuery,
342
+ });
343
+ }
344
+
345
+ return items;
346
+ }
347
+
302
348
  // Navigate mode — show all items when no query (autocomplete)
303
349
  const navItems: Array<{
304
350
  label: string;
305
351
  secondary?: string;
306
352
  icon: string;
307
353
  searchQuery?: string;
354
+ pathTarget?: string;
308
355
  }> = this.#navigateItems.map((item) => ({
309
356
  label: item.title,
310
357
  secondary: item.type === "system" ? item.path : item.path,
@@ -351,6 +398,24 @@ export class JantCommandPalette extends LitElement {
351
398
  return;
352
399
  }
353
400
 
401
+ if (mode === "path") {
402
+ const displayItem = this.#displayItems[index];
403
+ if (displayItem?.pathTarget) {
404
+ saveHistory(NAV_HISTORY_KEY, displayItem.pathTarget);
405
+ this.close();
406
+ window.location.href = displayItem.pathTarget;
407
+ return;
408
+ }
409
+
410
+ if (displayItem?.searchQuery) {
411
+ const q = displayItem.searchQuery;
412
+ saveHistory(SEARCH_HISTORY_KEY, q);
413
+ this.close();
414
+ window.location.href = `/search?q=${encodeURIComponent(q)}`;
415
+ }
416
+ return;
417
+ }
418
+
354
419
  // Navigate mode — check if the selected item is a search fallback
355
420
  const displayItem = this.#displayItems[index];
356
421
  if (displayItem?.searchQuery) {
@@ -5432,7 +5432,11 @@ export class JantComposeDialog extends LitElement {
5432
5432
 
5433
5433
  return html`
5434
5434
  <div
5435
- class="compose-thread-layout compose-thread-compose-layout"
5435
+ class=${classMap({
5436
+ "compose-thread-layout": true,
5437
+ "compose-thread-compose-layout": true,
5438
+ "compose-reply-compose-layout": isReply,
5439
+ })}
5436
5440
  @jant:compose-content-changed=${() => this._scheduleDraftSave()}
5437
5441
  >
5438
5442
  ${isReply ? this._renderReplyContext() : nothing}
@@ -5502,7 +5506,9 @@ export class JantComposeDialog extends LitElement {
5502
5506
  ? this._renderThreadComposeLayout()
5503
5507
  : isReply
5504
5508
  ? html`
5505
- <div class="compose-thread-layout">
5509
+ <div
5510
+ class="compose-thread-layout compose-reply-compose-layout"
5511
+ >
5506
5512
  ${this._renderReplyContext()}
5507
5513
  <div
5508
5514
  class="compose-editor-row"
@@ -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.#renderAddLinkSection()} ${this.#renderAddCollectionSection()}
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;
@@ -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 {