@jant/core 0.6.11 → 0.6.13

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 (139) hide show
  1. package/bin/commands/import-site.js +47 -30
  2. package/bin/commands/migrate-text-attachments.js +1 -3
  3. package/bin/commands/site/export.js +1 -3
  4. package/bin/commands/telegram/register-webhooks.js +9 -3
  5. package/bin/lib/site-snapshot.js +1 -5
  6. package/dist/app-CWJFPjuR.js +6 -0
  7. package/dist/{app-CpmficmQ.js → app-HnzmsaqU.js} +721 -473
  8. package/dist/client/.vite/manifest.json +3 -3
  9. package/dist/client/_assets/{client-Dd9U383b.js → client-3_OaxhTs.js} +1 -1
  10. package/dist/client/_assets/client-DCrcHVJl.css +2 -0
  11. package/dist/client/_assets/{client-auth-DkpSdIDz.js → client-auth-TsYq90xm.js} +625 -561
  12. package/dist/{export-Ba7NJImL.js → export-Dfbq9aot.js} +12 -15
  13. package/dist/{github-sync-Cb4_6_i7.js → github-sync--PSoE-Ne.js} +1 -1
  14. package/dist/{github-sync-BD4w2m8-.js → github-sync-BGTLx8Mf.js} +2 -2
  15. package/dist/index.js +3 -3
  16. package/dist/node.js +4 -4
  17. package/package.json +1 -1
  18. package/src/__tests__/export-service.test.ts +1 -2
  19. package/src/__tests__/helpers/app.ts +3 -0
  20. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  21. package/src/__tests__/import-site-command.test.ts +76 -2
  22. package/src/__tests__/vite-dev-plugins.test.ts +34 -0
  23. package/src/client/__tests__/compose-launch.test.ts +50 -1
  24. package/src/client/__tests__/compose-shortcuts.test.ts +74 -18
  25. package/src/client/__tests__/site-header-nav.test.ts +36 -2
  26. package/src/client/components/__tests__/compose-title-from-heading.test.ts +57 -0
  27. package/src/client/components/__tests__/jant-command-palette.test.ts +63 -0
  28. package/src/client/components/__tests__/jant-compose-dialog.test.ts +33 -0
  29. package/src/client/components/__tests__/jant-compose-editor-rehost-notice.test.ts +20 -1
  30. package/src/client/components/__tests__/jant-compose-editor.test.ts +94 -2
  31. package/src/client/components/__tests__/jant-nav-manager.test.ts +181 -20
  32. package/src/client/components/__tests__/jant-settings-avatar.test.ts +6 -0
  33. package/src/client/components/__tests__/jant-settings-general.test.ts +72 -0
  34. package/src/client/components/compose-title-from-heading.ts +66 -0
  35. package/src/client/components/compose-types.ts +8 -0
  36. package/src/client/components/inline-image-issues.ts +17 -0
  37. package/src/client/components/jant-command-palette.ts +67 -2
  38. package/src/client/components/jant-compose-dialog.ts +10 -2
  39. package/src/client/components/jant-compose-editor.ts +78 -1
  40. package/src/client/components/jant-compose-fullscreen.ts +2 -0
  41. package/src/client/components/jant-nav-manager.ts +382 -152
  42. package/src/client/components/jant-settings-general.ts +56 -0
  43. package/src/client/components/nav-manager-types.ts +14 -10
  44. package/src/client/components/settings-types.ts +30 -0
  45. package/src/client/compose-launch.ts +30 -0
  46. package/src/client/compose-shortcuts.ts +46 -3
  47. package/src/client/site-header-nav.d.ts +1 -0
  48. package/src/client/site-header-nav.js +105 -47
  49. package/src/client/tiptap/__tests__/block-insertion.test.ts +35 -0
  50. package/src/client/tiptap/__tests__/image-input-rules.test.ts +117 -0
  51. package/src/client/tiptap/__tests__/rehost-images.test.ts +66 -13
  52. package/src/client/tiptap/create-editor.ts +7 -0
  53. package/src/client/tiptap/extensions.ts +7 -2
  54. package/src/client/tiptap/image-input-rules.ts +48 -0
  55. package/src/client/tiptap/image-node.ts +189 -7
  56. package/src/client/tiptap/rehost-images.ts +5 -1
  57. package/src/client-auth.ts +0 -1
  58. package/src/i18n/__tests__/fallback.test.ts +24 -0
  59. package/src/i18n/locales/public/en.po +20 -4
  60. package/src/i18n/locales/public/en.ts +1 -1
  61. package/src/i18n/locales/public/zh-Hans.po +22 -6
  62. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/public/zh-Hant.po +22 -6
  64. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  65. package/src/i18n/locales/settings/en.po +77 -20
  66. package/src/i18n/locales/settings/en.ts +1 -1
  67. package/src/i18n/locales/settings/zh-Hans.po +85 -28
  68. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  69. package/src/i18n/locales/settings/zh-Hant.po +79 -22
  70. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  71. package/src/lib/__tests__/feed.test.ts +96 -10
  72. package/src/lib/__tests__/navigation.test.ts +4 -37
  73. package/src/lib/__tests__/resolve-config.test.ts +6 -1
  74. package/src/lib/__tests__/view.test.ts +6 -30
  75. package/src/lib/feed.ts +63 -3
  76. package/src/lib/github-sync-site-config.ts +0 -2
  77. package/src/lib/hugo-markdown.ts +1 -1
  78. package/src/lib/navigation.ts +5 -22
  79. package/src/lib/post-display.ts +14 -8
  80. package/src/lib/resolve-config.ts +9 -2
  81. package/src/lib/site-header-fragment.tsx +37 -0
  82. package/src/lib/view.ts +2 -7
  83. package/src/node/__tests__/cli-site-snapshot.test.ts +45 -45
  84. package/src/preset.css +60 -18
  85. package/src/routes/api/__tests__/nav-items.test.ts +26 -0
  86. package/src/routes/api/export.ts +0 -2
  87. package/src/routes/api/internal/__tests__/uploads.test.ts +9 -13
  88. package/src/routes/api/internal/sites.ts +5 -5
  89. package/src/routes/api/nav-items.ts +25 -4
  90. package/src/routes/dash/settings.tsx +48 -3
  91. package/src/routes/feed/__tests__/feed.test.ts +42 -0
  92. package/src/routes/feed/__tests__/sitemap.test.ts +2 -13
  93. package/src/routes/feed/feed.ts +1 -0
  94. package/src/routes/feed/sitemap.ts +2 -6
  95. package/src/routes/pages/__tests__/draft-about-edit.test.ts +65 -0
  96. package/src/routes/pages/archive.tsx +1 -0
  97. package/src/routes/pages/collection.tsx +1 -0
  98. package/src/routes/pages/featured.tsx +0 -4
  99. package/src/routes/pages/home.tsx +4 -51
  100. package/src/routes/pages/latest.tsx +2 -51
  101. package/src/routes/pages/page.tsx +44 -12
  102. package/src/services/__tests__/about-page.test.ts +116 -0
  103. package/src/services/__tests__/github-app-installations.test.ts +10 -10
  104. package/src/services/__tests__/navigation.test.ts +231 -1
  105. package/src/services/__tests__/post-timeline.test.ts +175 -2
  106. package/src/services/__tests__/post.test.ts +33 -0
  107. package/src/services/__tests__/settings.test.ts +5 -20
  108. package/src/services/about-page.ts +143 -0
  109. package/src/services/export-theme/assets/client-site.js +10 -10
  110. package/src/services/export-theme/layouts/_default/rss.xml +7 -16
  111. package/src/services/export-theme/layouts/index.html +26 -39
  112. package/src/services/export-theme/layouts/partials/feed-post-content.xml +25 -1
  113. package/src/services/export-theme/styles/main.css +46 -14
  114. package/src/services/export.ts +5 -10
  115. package/src/services/github-app-installations.ts +1 -3
  116. package/src/services/index.ts +27 -17
  117. package/src/services/navigation.ts +186 -1
  118. package/src/services/post.ts +103 -63
  119. package/src/services/settings.ts +0 -10
  120. package/src/services/site-admin.ts +0 -2
  121. package/src/services/upload-session.ts +4 -18
  122. package/src/styles/components.css +20 -0
  123. package/src/styles/tokens.css +28 -10
  124. package/src/styles/ui.css +349 -13
  125. package/src/types/bindings.ts +0 -1
  126. package/src/types/config.ts +3 -7
  127. package/src/types/views.ts +13 -0
  128. package/src/ui/compose/ComposeDialog.tsx +28 -0
  129. package/src/ui/dash/appearance/NavigationContent.tsx +58 -1
  130. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +19 -0
  131. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  132. package/src/ui/dash/settings/GeneralContent.tsx +44 -0
  133. package/src/ui/dash/settings/SettingsRootContent.tsx +3 -3
  134. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +8 -0
  135. package/src/ui/feed/__tests__/timeline-cards.test.ts +162 -1
  136. package/src/ui/layouts/SiteLayout.tsx +249 -216
  137. package/dist/app-DqKkZenB.js +0 -6
  138. package/dist/client/_assets/client-BhHHVvSY.css +0 -2
  139. package/src/client/nav-manager-bridge.ts +0 -54
@@ -7,7 +7,7 @@
7
7
  * - SortableJS drag-and-drop reorder with immediate preview update
8
8
  * - Add link forms
9
9
  * - System nav item toggles with immediate list/preview update
10
- * - Dispatches events for update/delete (handled by bridge)
10
+ * - Applies server-rendered site header fragments after saved changes
11
11
  *
12
12
  * Light DOM only — BaseCoat and Tailwind classes apply directly.
13
13
  */
@@ -27,21 +27,31 @@ import {
27
27
  import { showConfirmDialog } from "../confirm.js";
28
28
  import { showToast } from "../toast.js";
29
29
  import { publicPath } from "../runtime-paths.js";
30
+ import { destroySiteHeaderNav, initSiteHeaderNav } from "../site-header-nav.js";
30
31
  import type {
31
32
  NavManagerCollection,
32
33
  NavManagerItem,
33
34
  NavManagerLabels,
34
- NavManagerUpdateDetail,
35
- NavManagerDeleteDetail,
35
+ NavManagerSuggestedLink,
36
36
  SystemNavConfig,
37
37
  } from "./nav-manager-types.js";
38
38
 
39
+ const SITE_HEADER_REQUEST_HEADER = "X-Jant-Site-Header";
40
+ const INCLUDE_SITE_HEADER_RESPONSE = "include";
41
+
42
+ type NavManagerMutationResponse<T extends object> = T & {
43
+ headerHtml?: string;
44
+ };
45
+
46
+ type SortableEndEvent = Parameters<NonNullable<SortableOptions["onEnd"]>>[0];
47
+
39
48
  export class JantNavManager extends LitElement {
40
49
  static properties = {
41
50
  items: { type: Array },
42
51
  labels: { type: Object },
43
52
  systemNavItems: { type: Array, attribute: "system-nav-items" },
44
53
  collections: { type: Array },
54
+ suggestedLinks: { type: Array, attribute: "suggested-links" },
45
55
  siteName: { type: String, attribute: "site-name" },
46
56
 
47
57
  _items: { state: true },
@@ -56,12 +66,14 @@ export class JantNavManager extends LitElement {
56
66
  _showPreviewMore: { state: true },
57
67
  _addingCollectionId: { state: true },
58
68
  _showCollectionPicker: { state: true },
69
+ _addingSuggestedKey: { state: true },
59
70
  };
60
71
 
61
72
  declare items: NavManagerItem[];
62
73
  declare labels: NavManagerLabels;
63
74
  declare systemNavItems: SystemNavConfig[];
64
75
  declare collections: NavManagerCollection[];
76
+ declare suggestedLinks: NavManagerSuggestedLink[];
65
77
  declare siteName: string;
66
78
 
67
79
  declare _items: NavManagerItem[];
@@ -78,6 +90,8 @@ export class JantNavManager extends LitElement {
78
90
  /** ID of the collection currently being added (for loading state) */
79
91
  declare _addingCollectionId: string | null;
80
92
  declare _showCollectionPicker: boolean;
93
+ /** Key of the suggested link currently being added */
94
+ declare _addingSuggestedKey: string | null;
81
95
 
82
96
  #sortableHeader: { destroy(): void } | null = null;
83
97
  #sortableMore: { destroy(): void } | null = null;
@@ -124,6 +138,7 @@ export class JantNavManager extends LitElement {
124
138
  this.labels = {} as NavManagerLabels;
125
139
  this.systemNavItems = [];
126
140
  this.collections = [];
141
+ this.suggestedLinks = [];
127
142
  this.siteName = "";
128
143
 
129
144
  this._items = [];
@@ -138,6 +153,7 @@ export class JantNavManager extends LitElement {
138
153
  this._showPreviewMore = false;
139
154
  this._addingCollectionId = null;
140
155
  this._showCollectionPicker = false;
156
+ this._addingSuggestedKey = null;
141
157
  }
142
158
 
143
159
  protected update(changedProperties: PropertyValueMap<JantNavManager>): void {
@@ -166,6 +182,69 @@ export class JantNavManager extends LitElement {
166
182
  this.#closePreviewMore();
167
183
  }
168
184
 
185
+ #jsonMutationHeaders(): Record<string, string> {
186
+ return {
187
+ "Content-Type": "application/json",
188
+ Accept: "application/json",
189
+ [SITE_HEADER_REQUEST_HEADER]: INCLUDE_SITE_HEADER_RESPONSE,
190
+ };
191
+ }
192
+
193
+ #deleteMutationHeaders(): Record<string, string> {
194
+ return {
195
+ Accept: "application/json",
196
+ [SITE_HEADER_REQUEST_HEADER]: INCLUDE_SITE_HEADER_RESPONSE,
197
+ };
198
+ }
199
+
200
+ #stripHeaderHtml<T extends object>(
201
+ response: NavManagerMutationResponse<T>,
202
+ ): T {
203
+ const body = { ...response };
204
+ delete (body as { headerHtml?: string }).headerHtml;
205
+ return body as T;
206
+ }
207
+
208
+ #normalizeItem(item: NavManagerItem): NavManagerItem {
209
+ const systemLabel =
210
+ item.type === "system"
211
+ ? this.systemNavItems.find((config) => config.key === item.systemKey)
212
+ ?.label
213
+ : undefined;
214
+ const displayLabel =
215
+ item.label.trim() || systemLabel || item.displayLabel || item.label;
216
+
217
+ return { ...item, displayLabel };
218
+ }
219
+
220
+ #applySiteHeaderHtml(headerHtml: string | undefined) {
221
+ if (!headerHtml) return;
222
+
223
+ const template = document.createElement("template");
224
+ template.innerHTML = headerHtml.trim();
225
+
226
+ const selectors = [
227
+ '[data-site-header-fragment="header"]',
228
+ '[data-site-header-fragment="drawer-backdrop"]',
229
+ '[data-site-header-fragment="drawer"]',
230
+ ];
231
+ let replaced = false;
232
+
233
+ destroySiteHeaderNav(document);
234
+ for (const selector of selectors) {
235
+ const current = document.querySelector<HTMLElement>(selector);
236
+ const next = template.content.querySelector<HTMLElement>(selector);
237
+ if (!current || !next) continue;
238
+
239
+ current.replaceWith(next);
240
+ replaced = true;
241
+ }
242
+
243
+ if (replaced) {
244
+ initSiteHeaderNav(document);
245
+ }
246
+ }
247
+
169
248
  // ===========================================================================
170
249
  // SortableJS
171
250
  // ===========================================================================
@@ -203,119 +282,115 @@ export class JantNavManager extends LitElement {
203
282
  this.#revertNextSibling = captureSortableRevertNextSibling(evt);
204
283
  },
205
284
  onEnd: (evt) => {
206
- const targetList = evt.to;
207
- const sourceList = evt.from;
208
- const crossList = sourceList !== targetList;
209
- const targetPlacement: "header" | "more" =
210
- targetList.id === "nav-items-header" ? "header" : "more";
211
- const headerList = this.querySelector<HTMLElement>("#nav-items-header");
212
- const moreList = this.querySelector<HTMLElement>("#nav-items-more");
213
-
214
- const movedId = evt.item?.dataset?.navId;
215
- if (!movedId || !headerList || !moreList) {
216
- this.#revertNextSibling = null;
217
- return;
218
- }
285
+ void this.#handleSortableEnd(evt);
286
+ },
287
+ };
288
+ }
219
289
 
220
- const headerIds = readSortableDataIds(
221
- headerList,
222
- "[data-nav-id]",
223
- "navId",
224
- );
225
- const moreIds = readSortableDataIds(moreList, "[data-nav-id]", "navId");
226
-
227
- if (crossList) {
228
- evt.item.parentNode?.removeChild(evt.item);
229
- if (this.#revertNextSibling) {
230
- sourceList.insertBefore(evt.item, this.#revertNextSibling);
231
- } else if (
232
- evt.oldIndex != null &&
233
- evt.oldIndex < sourceList.children.length
234
- ) {
235
- sourceList.insertBefore(
236
- evt.item,
237
- sourceList.children[evt.oldIndex] ?? null,
238
- );
239
- } else {
240
- sourceList.appendChild(evt.item);
241
- }
242
- } else {
243
- revertSortableDomMove(targetList, evt, this.#revertNextSibling);
244
- }
290
+ async #handleSortableEnd(evt: SortableEndEvent) {
291
+ const targetList = evt.to;
292
+ const sourceList = evt.from;
293
+ const crossList = sourceList !== targetList;
294
+ const targetPlacement: "header" | "more" =
295
+ targetList.id === "nav-items-header" ? "header" : "more";
296
+ const headerList = this.querySelector<HTMLElement>("#nav-items-header");
297
+ const moreList = this.querySelector<HTMLElement>("#nav-items-more");
298
+ const previousItems = this._items.map((item) => ({ ...item }));
245
299
 
246
- this.#revertNextSibling = null;
247
- this.#destroySortables();
300
+ const movedId = evt.item?.dataset?.navId;
301
+ if (!movedId || !headerList || !moreList) {
302
+ this.#revertNextSibling = null;
303
+ return;
304
+ }
248
305
 
249
- const orderedIds = [...new Set([...headerIds, ...moreIds])];
250
- const itemMap = new Map(
251
- this._items.map((item) => [
252
- item.id,
253
- item.id === movedId
254
- ? { ...item, placement: targetPlacement }
255
- : { ...item },
256
- ]),
306
+ const headerIds = readSortableDataIds(headerList, "[data-nav-id]", "navId");
307
+ const moreIds = readSortableDataIds(moreList, "[data-nav-id]", "navId");
308
+
309
+ if (crossList) {
310
+ evt.item.parentNode?.removeChild(evt.item);
311
+ if (this.#revertNextSibling) {
312
+ sourceList.insertBefore(evt.item, this.#revertNextSibling);
313
+ } else if (
314
+ evt.oldIndex != null &&
315
+ evt.oldIndex < sourceList.children.length
316
+ ) {
317
+ sourceList.insertBefore(
318
+ evt.item,
319
+ sourceList.children[evt.oldIndex] ?? null,
257
320
  );
258
- const reorderedItems = orderedIds
259
- .map((id) => itemMap.get(id))
260
- .filter((item): item is NavManagerItem => item !== undefined);
261
- const remainingItems = this._items
262
- .filter((item) => !orderedIds.includes(item.id))
263
- .map((item) =>
264
- item.id === movedId
265
- ? { ...item, placement: targetPlacement }
266
- : { ...item },
267
- );
268
- this._items = [...reorderedItems, ...remainingItems];
269
-
270
- const targetIds = targetPlacement === "header" ? headerIds : moreIds;
271
- const {
272
- movedId: persistedId,
273
- afterId,
274
- beforeId,
275
- } = getSortableMove(targetIds, evt.newIndex);
276
- if (!persistedId) return;
277
-
278
- if (crossList) {
279
- fetch(`/api/nav-items/${movedId}`, {
280
- method: "PUT",
281
- headers: { "Content-Type": "application/json" },
282
- body: JSON.stringify({ placement: targetPlacement }),
283
- })
284
- .then(async (res) => {
285
- if (!res.ok) return false;
286
-
287
- const moveRes = await fetch(
288
- `/api/nav-items/${persistedId}/move`,
289
- {
290
- method: "PUT",
291
- headers: { "Content-Type": "application/json" },
292
- body: JSON.stringify({
293
- after: afterId ?? null,
294
- before: beforeId ?? null,
295
- }),
296
- },
297
- );
298
- return moveRes.ok;
299
- })
300
- .then((ok) => {
301
- if (ok) showToast(this.labels.placementSaved);
302
- else showToast(this.labels.saveFailed, "error");
303
- });
304
- } else {
305
- fetch(`/api/nav-items/${persistedId}/move`, {
306
- method: "PUT",
307
- headers: { "Content-Type": "application/json" },
308
- body: JSON.stringify({
309
- after: afterId ?? null,
310
- before: beforeId ?? null,
311
- }),
312
- }).then((res) => {
313
- if (res.ok) showToast(this.labels.orderSaved);
314
- else showToast(this.labels.saveFailed, "error");
315
- });
316
- }
317
- },
318
- };
321
+ } else {
322
+ sourceList.appendChild(evt.item);
323
+ }
324
+ } else {
325
+ revertSortableDomMove(targetList, evt, this.#revertNextSibling);
326
+ }
327
+
328
+ this.#revertNextSibling = null;
329
+ this.#destroySortables();
330
+
331
+ const orderedIds = [...new Set([...headerIds, ...moreIds])];
332
+ const itemMap = new Map(
333
+ this._items.map((item) => [
334
+ item.id,
335
+ item.id === movedId
336
+ ? { ...item, placement: targetPlacement }
337
+ : { ...item },
338
+ ]),
339
+ );
340
+ const reorderedItems = orderedIds
341
+ .map((id) => itemMap.get(id))
342
+ .filter((item): item is NavManagerItem => item !== undefined);
343
+ const remainingItems = this._items
344
+ .filter((item) => !orderedIds.includes(item.id))
345
+ .map((item) =>
346
+ item.id === movedId
347
+ ? { ...item, placement: targetPlacement }
348
+ : { ...item },
349
+ );
350
+ this._items = [...reorderedItems, ...remainingItems];
351
+
352
+ const targetIds = targetPlacement === "header" ? headerIds : moreIds;
353
+ const {
354
+ movedId: persistedId,
355
+ afterId,
356
+ beforeId,
357
+ } = getSortableMove(targetIds, evt.newIndex);
358
+ if (!persistedId) return;
359
+
360
+ try {
361
+ if (crossList) {
362
+ const placementRes = await fetch(`/api/nav-items/${movedId}`, {
363
+ method: "PUT",
364
+ headers: {
365
+ "Content-Type": "application/json",
366
+ Accept: "application/json",
367
+ },
368
+ body: JSON.stringify({ placement: targetPlacement }),
369
+ });
370
+ if (!placementRes.ok) throw new Error(`HTTP ${placementRes.status}`);
371
+ }
372
+
373
+ const moveRes = await fetch(`/api/nav-items/${persistedId}/move`, {
374
+ method: "PUT",
375
+ headers: this.#jsonMutationHeaders(),
376
+ body: JSON.stringify({
377
+ after: afterId ?? null,
378
+ before: beforeId ?? null,
379
+ }),
380
+ });
381
+ if (!moveRes.ok) throw new Error(`HTTP ${moveRes.status}`);
382
+
383
+ const moved =
384
+ (await moveRes.json()) as NavManagerMutationResponse<NavManagerItem>;
385
+ this.#applySiteHeaderHtml(moved.headerHtml);
386
+ showToast(
387
+ crossList ? this.labels.placementSaved : this.labels.orderSaved,
388
+ );
389
+ } catch {
390
+ this.#destroySortables();
391
+ this._items = previousItems;
392
+ showToast(this.labels.saveFailed, "error");
393
+ }
319
394
  }
320
395
 
321
396
  // ===========================================================================
@@ -332,25 +407,36 @@ export class JantNavManager extends LitElement {
332
407
  }
333
408
  }
334
409
 
335
- #handleUpdate(item: NavManagerItem) {
410
+ async #handleUpdate(item: NavManagerItem) {
336
411
  const label = this._editLabel.trim();
337
412
  if (!label && item.type !== "system") {
338
413
  showToast(this.labels.labelRequired, "error");
339
414
  return;
340
415
  }
341
416
 
342
- const detail: NavManagerUpdateDetail = {
343
- id: item.id,
344
- label,
345
- ...(item.type === "link" && { url: this._editUrl.trim() }),
346
- };
417
+ try {
418
+ const res = await fetch(`/api/nav-items/${item.id}`, {
419
+ method: "PUT",
420
+ headers: this.#jsonMutationHeaders(),
421
+ body: JSON.stringify({
422
+ label,
423
+ ...(item.type === "link" && { url: this._editUrl.trim() }),
424
+ }),
425
+ });
426
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
347
427
 
348
- this.dispatchEvent(
349
- new CustomEvent<NavManagerUpdateDetail>("jant:nav-update", {
350
- bubbles: true,
351
- detail,
352
- }),
353
- );
428
+ const response =
429
+ (await res.json()) as NavManagerMutationResponse<NavManagerItem>;
430
+ const updated = this.#normalizeItem(this.#stripHeaderHtml(response));
431
+ this.#destroySortables();
432
+ this._items = this._items.map((current) =>
433
+ current.id === updated.id ? updated : current,
434
+ );
435
+ this._editingId = null;
436
+ this.#applySiteHeaderHtml(response.headerHtml);
437
+ } catch {
438
+ showToast(this.labels.saveFailed, "error");
439
+ }
354
440
  }
355
441
 
356
442
  async #handleDelete(item: NavManagerItem) {
@@ -368,12 +454,23 @@ export class JantNavManager extends LitElement {
368
454
  });
369
455
  if (!confirmed) return;
370
456
 
371
- this.dispatchEvent(
372
- new CustomEvent<NavManagerDeleteDetail>("jant:nav-delete", {
373
- bubbles: true,
374
- detail: { id: item.id },
375
- }),
376
- );
457
+ try {
458
+ const res = await fetch(`/api/nav-items/${item.id}`, {
459
+ method: "DELETE",
460
+ headers: this.#deleteMutationHeaders(),
461
+ });
462
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
463
+
464
+ const response = (await res.json()) as NavManagerMutationResponse<{
465
+ success: true;
466
+ }>;
467
+ this.#destroySortables();
468
+ this._items = this._items.filter((current) => current.id !== item.id);
469
+ this._editingId = null;
470
+ this.#applySiteHeaderHtml(response.headerHtml);
471
+ } catch {
472
+ showToast(this.labels.deleteFailed, "error");
473
+ }
377
474
  }
378
475
 
379
476
  // ===========================================================================
@@ -392,21 +489,21 @@ export class JantNavManager extends LitElement {
392
489
  try {
393
490
  const res = await fetch("/api/nav-items", {
394
491
  method: "POST",
395
- headers: {
396
- "Content-Type": "application/json",
397
- Accept: "application/json",
398
- },
492
+ headers: this.#jsonMutationHeaders(),
399
493
  body: JSON.stringify({ type: "link", label, url, placement: "header" }),
400
494
  });
401
495
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
402
496
 
403
- const created: NavManagerItem = await res.json();
497
+ const response =
498
+ (await res.json()) as NavManagerMutationResponse<NavManagerItem>;
499
+ const created = this.#normalizeItem(this.#stripHeaderHtml(response));
404
500
  this.#destroySortables();
405
501
  this._items = [...this._items, created];
406
502
  this._newLinkLabel = "";
407
503
  this._newLinkUrl = "";
408
504
  this._showLinkForm = false;
409
505
  document.removeEventListener("click", this.#closeLinkForm);
506
+ this.#applySiteHeaderHtml(response.headerHtml);
410
507
  } catch {
411
508
  showToast(this.labels.saveFailed, "error");
412
509
  } finally {
@@ -425,10 +522,7 @@ export class JantNavManager extends LitElement {
425
522
  try {
426
523
  const res = await fetch("/api/nav-items", {
427
524
  method: "POST",
428
- headers: {
429
- "Content-Type": "application/json",
430
- Accept: "application/json",
431
- },
525
+ headers: this.#jsonMutationHeaders(),
432
526
  body: JSON.stringify({
433
527
  type: "collection",
434
528
  collectionId,
@@ -437,10 +531,13 @@ export class JantNavManager extends LitElement {
437
531
  });
438
532
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
439
533
 
440
- const created: NavManagerItem = await res.json();
534
+ const response =
535
+ (await res.json()) as NavManagerMutationResponse<NavManagerItem>;
536
+ const created = this.#normalizeItem(this.#stripHeaderHtml(response));
441
537
  this.#destroySortables();
442
538
  this._items = [...this._items, created];
443
539
  this.#closeCollectionPicker();
540
+ this.#applySiteHeaderHtml(response.headerHtml);
444
541
  } catch {
445
542
  showToast(this.labels.saveFailed, "error");
446
543
  } finally {
@@ -448,6 +545,99 @@ export class JantNavManager extends LitElement {
448
545
  }
449
546
  }
450
547
 
548
+ // ===========================================================================
549
+ // Suggested link handlers
550
+ // ===========================================================================
551
+
552
+ #getComparableInternalPath(url: string): string | null {
553
+ if (
554
+ url.startsWith("http://") ||
555
+ url.startsWith("https://") ||
556
+ url.startsWith("//") ||
557
+ url.startsWith("mailto:") ||
558
+ url.startsWith("tel:") ||
559
+ url.startsWith("#")
560
+ ) {
561
+ return null;
562
+ }
563
+
564
+ try {
565
+ const pathname = new URL(url, "https://jant.invalid").pathname;
566
+ const normalized = pathname
567
+ .trim()
568
+ .toLowerCase()
569
+ .replace(/^\/+|\/+$/g, "")
570
+ .replace(/\/+/g, "/");
571
+ return normalized ? `/${normalized}` : "/";
572
+ } catch {
573
+ return null;
574
+ }
575
+ }
576
+
577
+ #isSuggestedLinkAdded(link: NavManagerSuggestedLink): boolean {
578
+ const path = this.#getComparableInternalPath(link.url);
579
+ const hasMatchingPath =
580
+ path !== null &&
581
+ this._items.some(
582
+ (item) => this.#getComparableInternalPath(item.url) === path,
583
+ );
584
+ if (hasMatchingPath) return true;
585
+
586
+ return Boolean(
587
+ link.collectionId &&
588
+ this._items.some(
589
+ (item) =>
590
+ item.type === "collection" && item.collectionId === link.collectionId,
591
+ ),
592
+ );
593
+ }
594
+
595
+ get #availableSuggestedLinks(): NavManagerSuggestedLink[] {
596
+ return (this.suggestedLinks ?? []).filter(
597
+ (link) => !this.#isSuggestedLinkAdded(link),
598
+ );
599
+ }
600
+
601
+ async #handleAddSuggestedLink(link: NavManagerSuggestedLink) {
602
+ if (this._addingSuggestedKey) return;
603
+
604
+ this._addingSuggestedKey = link.key;
605
+ try {
606
+ const body =
607
+ link.navItemType === "collection" && link.collectionId
608
+ ? {
609
+ type: "collection",
610
+ collectionId: link.collectionId,
611
+ placement: "header",
612
+ }
613
+ : {
614
+ type: "link",
615
+ label: link.label,
616
+ url: link.url,
617
+ placement: "header",
618
+ };
619
+
620
+ const res = await fetch("/api/nav-items", {
621
+ method: "POST",
622
+ headers: this.#jsonMutationHeaders(),
623
+ body: JSON.stringify(body),
624
+ });
625
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
626
+
627
+ const response =
628
+ (await res.json()) as NavManagerMutationResponse<NavManagerItem>;
629
+ const created = this.#normalizeItem(this.#stripHeaderHtml(response));
630
+ this.#destroySortables();
631
+ this._items = [...this._items, created];
632
+ this.#applySiteHeaderHtml(response.headerHtml);
633
+ showToast(this.labels.suggestedLinkAdded);
634
+ } catch {
635
+ showToast(this.labels.saveFailed, "error");
636
+ } finally {
637
+ this._addingSuggestedKey = null;
638
+ }
639
+ }
640
+
451
641
  // ===========================================================================
452
642
  // System toggle handlers
453
643
  // ===========================================================================
@@ -465,10 +655,7 @@ export class JantNavManager extends LitElement {
465
655
  if (enabled) {
466
656
  const res = await fetch("/api/nav-items", {
467
657
  method: "POST",
468
- headers: {
469
- "Content-Type": "application/json",
470
- Accept: "application/json",
471
- },
658
+ headers: this.#jsonMutationHeaders(),
472
659
  body: JSON.stringify({
473
660
  type: "system",
474
661
  systemKey: config.key,
@@ -476,12 +663,12 @@ export class JantNavManager extends LitElement {
476
663
  });
477
664
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
478
665
 
479
- const created: NavManagerItem = await res.json();
666
+ const response =
667
+ (await res.json()) as NavManagerMutationResponse<NavManagerItem>;
668
+ const created = this.#normalizeItem(this.#stripHeaderHtml(response));
480
669
  this.#destroySortables();
481
- this._items = [
482
- ...this._items,
483
- { ...created, displayLabel: config.label },
484
- ];
670
+ this._items = [...this._items, created];
671
+ this.#applySiteHeaderHtml(response.headerHtml);
485
672
  } else {
486
673
  const existing = this._items.find(
487
674
  (item) => item.type === "system" && item.systemKey === config.key,
@@ -489,12 +676,16 @@ export class JantNavManager extends LitElement {
489
676
  if (existing) {
490
677
  const res = await fetch(`/api/nav-items/${existing.id}`, {
491
678
  method: "DELETE",
492
- headers: { Accept: "application/json" },
679
+ headers: this.#deleteMutationHeaders(),
493
680
  });
494
681
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
495
682
 
683
+ const response = (await res.json()) as NavManagerMutationResponse<{
684
+ success: true;
685
+ }>;
496
686
  this.#destroySortables();
497
687
  this._items = this._items.filter((item) => item.id !== existing.id);
688
+ this.#applySiteHeaderHtml(response.headerHtml);
498
689
  }
499
690
  }
500
691
  } catch {
@@ -834,6 +1025,45 @@ export class JantNavManager extends LitElement {
834
1025
  `;
835
1026
  }
836
1027
 
1028
+ #renderSuggestedLinksSection() {
1029
+ const available = this.#availableSuggestedLinks;
1030
+ if (available.length === 0) return nothing;
1031
+
1032
+ return html`
1033
+ <section class="mt-8">
1034
+ <h2 class="text-lg font-semibold mb-1">
1035
+ ${this.labels.suggestedLinks}
1036
+ </h2>
1037
+ <p class="text-sm text-muted-foreground mb-3">
1038
+ ${this.labels.suggestedLinksDescription}
1039
+ </p>
1040
+ <div class="nav-suggestions-list">
1041
+ ${available.map((link) => {
1042
+ const adding = this._addingSuggestedKey === link.key;
1043
+ return html`
1044
+ <div class="nav-suggestion-item">
1045
+ <div class="nav-suggestion-info">
1046
+ <span class="nav-suggestion-title">${link.label}</span>
1047
+ <span class="nav-suggestion-meta">
1048
+ ${link.url} · ${link.targetLabel}
1049
+ </span>
1050
+ </div>
1051
+ <button
1052
+ type="button"
1053
+ class="btn-sm"
1054
+ ?disabled=${adding || this._addingSuggestedKey !== null}
1055
+ @click=${() => this.#handleAddSuggestedLink(link)}
1056
+ >
1057
+ ${this.labels.addSuggestedLink}
1058
+ </button>
1059
+ </div>
1060
+ `;
1061
+ })}
1062
+ </div>
1063
+ </section>
1064
+ `;
1065
+ }
1066
+
837
1067
  #renderAddLinkSection() {
838
1068
  return html`
839
1069
  <section class="mt-8">
@@ -1175,8 +1405,8 @@ export class JantNavManager extends LitElement {
1175
1405
  </div>
1176
1406
  </section>
1177
1407
 
1178
- ${this.#renderAddLinkSection()} ${this.#renderAddCollectionSection()}
1179
- ${this.#renderSystemToggles()}
1408
+ ${this.#renderSuggestedLinksSection()} ${this.#renderAddLinkSection()}
1409
+ ${this.#renderAddCollectionSection()} ${this.#renderSystemToggles()}
1180
1410
  `;
1181
1411
  }
1182
1412
  }