@jant/core 0.3.32 → 0.3.34

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 (97) hide show
  1. package/dist/client/client.css +1 -1
  2. package/dist/client/client.js +1442 -989
  3. package/dist/index.js +1431 -1057
  4. package/package.json +1 -1
  5. package/src/__tests__/helpers/app.ts +6 -3
  6. package/src/__tests__/helpers/db.ts +3 -0
  7. package/src/app.tsx +1 -1
  8. package/src/client.ts +2 -1
  9. package/src/db/migrations/0011_add_path_registry.sql +23 -0
  10. package/src/db/schema.ts +12 -1
  11. package/src/i18n/locales/en.po +225 -91
  12. package/src/i18n/locales/en.ts +1 -1
  13. package/src/i18n/locales/zh-Hans.po +201 -152
  14. package/src/i18n/locales/zh-Hans.ts +1 -1
  15. package/src/i18n/locales/zh-Hant.po +201 -152
  16. package/src/i18n/locales/zh-Hant.ts +1 -1
  17. package/src/lib/__tests__/excerpt.test.ts +25 -0
  18. package/src/lib/__tests__/resolve-config.test.ts +26 -2
  19. package/src/lib/__tests__/timeline.test.ts +2 -1
  20. package/src/lib/compose-bridge.ts +30 -1
  21. package/src/lib/excerpt.ts +16 -7
  22. package/src/lib/nav-manager-bridge.ts +54 -0
  23. package/src/lib/navigation.ts +7 -4
  24. package/src/lib/render.tsx +5 -2
  25. package/src/lib/resolve-config.ts +7 -0
  26. package/src/lib/view.ts +42 -10
  27. package/src/middleware/error-handler.ts +16 -0
  28. package/src/routes/api/__tests__/posts.test.ts +80 -0
  29. package/src/routes/api/__tests__/settings.test.ts +1 -1
  30. package/src/routes/api/posts.ts +6 -29
  31. package/src/routes/api/upload.ts +2 -14
  32. package/src/routes/auth/__tests__/setup.test.ts +3 -2
  33. package/src/routes/auth/setup.tsx +1 -1
  34. package/src/routes/compose.tsx +13 -5
  35. package/src/routes/dash/__tests__/pages.test.ts +2 -1
  36. package/src/routes/dash/__tests__/settings-avatar.test.ts +151 -33
  37. package/src/routes/dash/appearance.tsx +71 -4
  38. package/src/routes/dash/collections.tsx +15 -21
  39. package/src/routes/dash/media.tsx +1 -13
  40. package/src/routes/dash/pages.tsx +5 -150
  41. package/src/routes/dash/posts.tsx +25 -32
  42. package/src/routes/dash/redirects.tsx +9 -11
  43. package/src/routes/dash/settings.tsx +29 -111
  44. package/src/routes/feed/__tests__/rss.test.ts +5 -1
  45. package/src/routes/pages/__tests__/collections.test.ts +2 -1
  46. package/src/routes/pages/__tests__/featured.test.ts +2 -1
  47. package/src/routes/pages/page.tsx +20 -25
  48. package/src/services/__tests__/collection.test.ts +2 -1
  49. package/src/services/__tests__/media.test.ts +78 -1
  50. package/src/services/__tests__/navigation.test.ts +2 -1
  51. package/src/services/__tests__/page.test.ts +78 -1
  52. package/src/services/__tests__/path-registry.test.ts +165 -0
  53. package/src/services/__tests__/post-timeline.test.ts +2 -1
  54. package/src/services/__tests__/post.test.ts +103 -1
  55. package/src/services/__tests__/redirect.test.ts +53 -4
  56. package/src/services/__tests__/search.test.ts +2 -1
  57. package/src/services/__tests__/settings.test.ts +153 -0
  58. package/src/services/index.ts +12 -4
  59. package/src/services/media.ts +72 -4
  60. package/src/services/page.ts +64 -17
  61. package/src/services/path-registry.ts +160 -0
  62. package/src/services/post.ts +119 -24
  63. package/src/services/redirect.ts +23 -3
  64. package/src/services/settings.ts +181 -0
  65. package/src/styles/components.css +135 -0
  66. package/src/styles/tokens.css +6 -1
  67. package/src/styles/ui.css +70 -26
  68. package/src/types/bindings.ts +1 -0
  69. package/src/types/config.ts +7 -2
  70. package/src/types/constants.ts +9 -1
  71. package/src/types/sortablejs.d.ts +8 -2
  72. package/src/types/views.ts +1 -1
  73. package/src/ui/color-themes.ts +31 -31
  74. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +0 -3
  75. package/src/ui/components/__tests__/jant-settings-general.test.ts +2 -6
  76. package/src/ui/components/jant-compose-dialog.ts +3 -2
  77. package/src/ui/components/jant-compose-editor.ts +17 -2
  78. package/src/ui/components/jant-nav-manager.ts +1067 -0
  79. package/src/ui/components/jant-settings-general.ts +2 -35
  80. package/src/ui/components/nav-manager-types.ts +72 -0
  81. package/src/ui/components/settings-types.ts +0 -3
  82. package/src/ui/compose/ComposePrompt.tsx +3 -11
  83. package/src/ui/dash/appearance/AdvancedContent.tsx +0 -3
  84. package/src/ui/dash/appearance/AppearanceNav.tsx +12 -8
  85. package/src/ui/dash/appearance/ColorThemeContent.tsx +1 -4
  86. package/src/ui/dash/appearance/FontThemeContent.tsx +0 -3
  87. package/src/ui/dash/appearance/NavigationContent.tsx +302 -0
  88. package/src/ui/dash/pages/PagesContent.tsx +74 -0
  89. package/src/ui/dash/settings/AccountContent.tsx +0 -3
  90. package/src/ui/dash/settings/GeneralContent.tsx +1 -19
  91. package/src/ui/dash/settings/SettingsNav.tsx +2 -6
  92. package/src/ui/feed/NoteCard.tsx +2 -2
  93. package/src/ui/layouts/DashLayout.tsx +83 -86
  94. package/src/ui/layouts/SiteLayout.tsx +82 -21
  95. package/src/lib/nav-reorder.ts +0 -26
  96. package/src/ui/dash/pages/LinkFormContent.tsx +0 -119
  97. package/src/ui/dash/pages/UnifiedPagesContent.tsx +0 -203
@@ -0,0 +1,1067 @@
1
+ /**
2
+ * Navigation Manager Component
3
+ *
4
+ * Manages nav item reordering with a live preview:
5
+ * - Renders a preview bar that reflects current item order
6
+ * - Sortable list with inline edit/delete panels
7
+ * - SortableJS drag-and-drop reorder with immediate preview update
8
+ * - Add page/link forms
9
+ * - System nav item toggles with immediate list/preview update
10
+ * - Dispatches events for update/delete (handled by bridge)
11
+ *
12
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
13
+ */
14
+
15
+ import { LitElement, html, nothing } from "lit";
16
+ import type { PropertyValueMap } from "lit";
17
+ import Sortable from "sortablejs";
18
+ import { showToast } from "../../lib/toast.js";
19
+ import type {
20
+ AvailablePage,
21
+ NavManagerItem,
22
+ NavManagerLabels,
23
+ NavManagerUpdateDetail,
24
+ NavManagerDeleteDetail,
25
+ SystemNavConfig,
26
+ } from "./nav-manager-types.js";
27
+
28
+ export class JantNavManager extends LitElement {
29
+ static properties = {
30
+ items: { type: Array },
31
+ labels: { type: Object },
32
+ systemNavItems: { type: Array, attribute: "system-nav-items" },
33
+ availablePages: { type: Array, attribute: "available-pages" },
34
+ siteName: { type: String, attribute: "site-name" },
35
+ maxVisible: { type: Number, attribute: "max-visible" },
36
+ homeDefaultView: { type: String, attribute: "home-default-view" },
37
+
38
+ _items: { state: true },
39
+ _editingId: { state: true },
40
+ _editLabel: { state: true },
41
+ _editUrl: { state: true },
42
+ _togglingKeys: { state: true },
43
+ _showOverflow: { state: true },
44
+ _showPagePicker: { state: true },
45
+ _showLinkForm: { state: true },
46
+ _newLinkLabel: { state: true },
47
+ _newLinkUrl: { state: true },
48
+ _availablePages: { state: true },
49
+ _addingPageId: { state: true },
50
+ _addingLink: { state: true },
51
+ _pageSearchQuery: { state: true },
52
+ };
53
+
54
+ declare items: NavManagerItem[];
55
+ declare labels: NavManagerLabels;
56
+ declare systemNavItems: SystemNavConfig[];
57
+ declare availablePages: AvailablePage[];
58
+ declare siteName: string;
59
+ declare maxVisible: number;
60
+ declare homeDefaultView: string;
61
+
62
+ declare _items: NavManagerItem[];
63
+ declare _editingId: number | null;
64
+ declare _editLabel: string;
65
+ declare _editUrl: string;
66
+ /** Keys currently mid-request (to disable switch during toggle) */
67
+ declare _togglingKeys: Set<string>;
68
+ declare _showOverflow: boolean;
69
+ declare _showPagePicker: boolean;
70
+ declare _showLinkForm: boolean;
71
+ declare _newLinkLabel: string;
72
+ declare _newLinkUrl: string;
73
+ declare _availablePages: AvailablePage[];
74
+ /** Page ID currently being added (to disable its button) */
75
+ declare _addingPageId: number | null;
76
+ declare _addingLink: boolean;
77
+ declare _pageSearchQuery: string;
78
+
79
+ #sortable: { destroy(): void } | null = null;
80
+ #initialized = false;
81
+ #closeOverflow = () => {
82
+ this._showOverflow = false;
83
+ document.removeEventListener("click", this.#closeOverflow);
84
+ };
85
+ #closePagePicker = () => {
86
+ this._showPagePicker = false;
87
+ this._pageSearchQuery = "";
88
+ document.removeEventListener("click", this.#closePagePicker);
89
+ };
90
+ #closeLinkForm = () => {
91
+ this._showLinkForm = false;
92
+ document.removeEventListener("click", this.#closeLinkForm);
93
+ };
94
+
95
+ createRenderRoot() {
96
+ this.innerHTML = "";
97
+ return this;
98
+ }
99
+
100
+ constructor() {
101
+ super();
102
+ this.items = [];
103
+ this.labels = {} as NavManagerLabels;
104
+ this.systemNavItems = [];
105
+ this.availablePages = [];
106
+ this.siteName = "";
107
+ this.maxVisible = 3;
108
+ this.homeDefaultView = "latest";
109
+
110
+ this._items = [];
111
+ this._editingId = null;
112
+ this._editLabel = "";
113
+ this._editUrl = "";
114
+ this._togglingKeys = new Set();
115
+ this._showOverflow = false;
116
+ this._showPagePicker = false;
117
+ this._showLinkForm = false;
118
+ this._newLinkLabel = "";
119
+ this._newLinkUrl = "";
120
+ this._availablePages = [];
121
+ this._addingPageId = null;
122
+ this._addingLink = false;
123
+ this._pageSearchQuery = "";
124
+ }
125
+
126
+ protected update(changedProperties: PropertyValueMap<JantNavManager>): void {
127
+ if (!this.#initialized || changedProperties.has("items")) {
128
+ this._items = [...(this.items ?? [])];
129
+ this.#initialized = true;
130
+ }
131
+ if (changedProperties.has("availablePages" as keyof JantNavManager)) {
132
+ this._availablePages = [...(this.availablePages ?? [])];
133
+ }
134
+ super.update(changedProperties);
135
+ }
136
+
137
+ protected updated(): void {
138
+ this.#initSortable();
139
+ }
140
+
141
+ disconnectedCallback() {
142
+ super.disconnectedCallback();
143
+ this.#sortable?.destroy();
144
+ this.#sortable = null;
145
+ document.removeEventListener("click", this.#closeOverflow);
146
+ document.removeEventListener("click", this.#closePagePicker);
147
+ document.removeEventListener("click", this.#closeLinkForm);
148
+ }
149
+
150
+ // ===========================================================================
151
+ // SortableJS
152
+ // ===========================================================================
153
+
154
+ #initSortable() {
155
+ const list = this.querySelector<HTMLElement>("#nav-items-list");
156
+ if (!list || this.#sortable) return;
157
+
158
+ this.#sortable = Sortable.create(list, {
159
+ animation: 150,
160
+ handle: "[data-drag-handle]",
161
+ onEnd: (evt) => {
162
+ // Read new order from DOM BEFORE reverting
163
+ const els = [...list.querySelectorAll<HTMLElement>("[data-nav-id]")];
164
+ const ids = els.map((el) => Number(el.dataset.navId));
165
+
166
+ // Revert SortableJS DOM manipulation so Lit can re-render cleanly.
167
+ // SortableJS physically moved the element — put it back where it was.
168
+ const { item, oldIndex, newIndex } = evt;
169
+ if (oldIndex != null && newIndex != null && oldIndex !== newIndex) {
170
+ // Remove the item from its new position
171
+ item.parentNode?.removeChild(item);
172
+ // Re-insert at the original position
173
+ const children = list.children;
174
+ if (oldIndex >= children.length) {
175
+ list.appendChild(item);
176
+ } else {
177
+ list.insertBefore(item, children[oldIndex]);
178
+ }
179
+ }
180
+
181
+ // Destroy sortable so it doesn't fight Lit's re-render
182
+ this.#sortable?.destroy();
183
+ this.#sortable = null;
184
+
185
+ // Update internal state so Lit re-renders in the new order
186
+ const itemMap = new Map(this._items.map((i) => [i.id, i]));
187
+ this._items = ids
188
+ .map((id) => itemMap.get(id))
189
+ .filter((i): i is NavManagerItem => i !== undefined);
190
+
191
+ // Persist to server
192
+ fetch("/api/nav-items/reorder", {
193
+ method: "PUT",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify({ ids }),
196
+ }).then((res) => {
197
+ if (res.ok) showToast(this.labels.orderSaved);
198
+ else showToast(this.labels.saveFailed, "error");
199
+ });
200
+ },
201
+ });
202
+ }
203
+
204
+ // ===========================================================================
205
+ // Inline edit handlers
206
+ // ===========================================================================
207
+
208
+ #toggleEdit(item: NavManagerItem) {
209
+ if (this._editingId === item.id) {
210
+ this._editingId = null;
211
+ } else {
212
+ this._editingId = item.id;
213
+ this._editLabel = item.label;
214
+ this._editUrl = item.url;
215
+ }
216
+ }
217
+
218
+ #handleUpdate(item: NavManagerItem) {
219
+ const label = this._editLabel.trim();
220
+ if (!label) {
221
+ showToast(this.labels.labelRequired, "error");
222
+ return;
223
+ }
224
+
225
+ const detail: NavManagerUpdateDetail = {
226
+ id: item.id,
227
+ label,
228
+ ...(item.type === "link" && { url: this._editUrl.trim() }),
229
+ };
230
+
231
+ this.dispatchEvent(
232
+ new CustomEvent<NavManagerUpdateDetail>("jant:nav-update", {
233
+ bubbles: true,
234
+ detail,
235
+ }),
236
+ );
237
+ }
238
+
239
+ #handleDelete(item: NavManagerItem) {
240
+ this.dispatchEvent(
241
+ new CustomEvent<NavManagerDeleteDetail>("jant:nav-delete", {
242
+ bubbles: true,
243
+ detail: { id: item.id },
244
+ }),
245
+ );
246
+ }
247
+
248
+ // ===========================================================================
249
+ // Max visible handler
250
+ // ===========================================================================
251
+
252
+ async #handleMaxVisibleChange(value: number) {
253
+ const clamped = Math.max(0, Math.min(5, value));
254
+ this.maxVisible = clamped;
255
+ try {
256
+ const res = await fetch("/dash/appearance/nav-max-visible", {
257
+ method: "POST",
258
+ headers: { "Content-Type": "application/json" },
259
+ body: JSON.stringify({ value: clamped }),
260
+ });
261
+ if (res.ok) showToast(this.labels.maxVisibleSaved);
262
+ else showToast(this.labels.saveFailed, "error");
263
+ } catch {
264
+ showToast(this.labels.saveFailed, "error");
265
+ }
266
+ }
267
+
268
+ async #handleHomeViewToggle(useFeatured: boolean) {
269
+ this.homeDefaultView = useFeatured ? "featured" : "latest";
270
+ try {
271
+ const res = await fetch("/dash/appearance/home-default-view", {
272
+ method: "POST",
273
+ headers: { "Content-Type": "application/json" },
274
+ body: JSON.stringify({ value: this.homeDefaultView }),
275
+ });
276
+ if (res.ok) showToast(this.labels.homeViewSaved);
277
+ else showToast(this.labels.saveFailed, "error");
278
+ } catch {
279
+ showToast(this.labels.saveFailed, "error");
280
+ }
281
+ }
282
+
283
+ // ===========================================================================
284
+ // Add page / link handlers
285
+ // ===========================================================================
286
+
287
+ async #handleAddPage(page: AvailablePage) {
288
+ this._addingPageId = page.id;
289
+ try {
290
+ const res = await fetch("/api/nav-items", {
291
+ method: "POST",
292
+ headers: {
293
+ "Content-Type": "application/json",
294
+ Accept: "application/json",
295
+ },
296
+ body: JSON.stringify({
297
+ type: "page",
298
+ label: page.title || page.slug,
299
+ url: `/${page.slug}`,
300
+ pageId: page.id,
301
+ }),
302
+ });
303
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
304
+
305
+ const created: NavManagerItem = await res.json();
306
+ this.#sortable?.destroy();
307
+ this.#sortable = null;
308
+ this._items = [...this._items, created];
309
+ this._availablePages = this._availablePages.filter(
310
+ (p) => p.id !== page.id,
311
+ );
312
+ } catch {
313
+ showToast(this.labels.saveFailed, "error");
314
+ } finally {
315
+ this._addingPageId = null;
316
+ }
317
+ }
318
+
319
+ async #handleAddLink() {
320
+ const label = this._newLinkLabel.trim();
321
+ const url = this._newLinkUrl.trim();
322
+ if (!label || !url) {
323
+ showToast(this.labels.labelAndUrlRequired, "error");
324
+ return;
325
+ }
326
+
327
+ this._addingLink = true;
328
+ try {
329
+ const res = await fetch("/api/nav-items", {
330
+ method: "POST",
331
+ headers: {
332
+ "Content-Type": "application/json",
333
+ Accept: "application/json",
334
+ },
335
+ body: JSON.stringify({ type: "link", label, url }),
336
+ });
337
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
338
+
339
+ const created: NavManagerItem = await res.json();
340
+ this.#sortable?.destroy();
341
+ this.#sortable = null;
342
+ this._items = [...this._items, created];
343
+ this._newLinkLabel = "";
344
+ this._newLinkUrl = "";
345
+ this._showLinkForm = false;
346
+ document.removeEventListener("click", this.#closeLinkForm);
347
+ } catch {
348
+ showToast(this.labels.saveFailed, "error");
349
+ } finally {
350
+ this._addingLink = false;
351
+ }
352
+ }
353
+
354
+ // ===========================================================================
355
+ // System toggle handlers
356
+ // ===========================================================================
357
+
358
+ #isSystemEnabled(config: SystemNavConfig): boolean {
359
+ return this._items.some(
360
+ (item) => item.type === "system" && item.url === config.url,
361
+ );
362
+ }
363
+
364
+ async #handleSystemToggle(config: SystemNavConfig, enabled: boolean) {
365
+ this._togglingKeys = new Set([...this._togglingKeys, config.key]);
366
+
367
+ try {
368
+ if (enabled) {
369
+ const res = await fetch("/api/nav-items", {
370
+ method: "POST",
371
+ headers: {
372
+ "Content-Type": "application/json",
373
+ Accept: "application/json",
374
+ },
375
+ body: JSON.stringify({
376
+ type: "system",
377
+ label: config.defaultLabel,
378
+ url: config.url,
379
+ }),
380
+ });
381
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
382
+
383
+ const created: NavManagerItem = await res.json();
384
+ this.#sortable?.destroy();
385
+ this.#sortable = null;
386
+ this._items = [...this._items, created];
387
+ } else {
388
+ const existing = this._items.find(
389
+ (item) => item.type === "system" && item.url === config.url,
390
+ );
391
+ if (existing) {
392
+ const res = await fetch(`/api/nav-items/${existing.id}`, {
393
+ method: "DELETE",
394
+ headers: { Accept: "application/json" },
395
+ });
396
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
397
+
398
+ this.#sortable?.destroy();
399
+ this.#sortable = null;
400
+ this._items = this._items.filter((item) => item.id !== existing.id);
401
+ }
402
+ }
403
+ } catch {
404
+ showToast(this.labels.saveFailed, "error");
405
+ this.requestUpdate();
406
+ } finally {
407
+ const next = new Set(this._togglingKeys);
408
+ next.delete(config.key);
409
+ this._togglingKeys = next;
410
+ }
411
+ }
412
+
413
+ // ===========================================================================
414
+ // Render helpers
415
+ // ===========================================================================
416
+
417
+ #renderPreview() {
418
+ const visible = this._items.slice(0, this.maxVisible);
419
+ const overflow = this._items.slice(this.maxVisible);
420
+ const hasMore = overflow.length > 0;
421
+
422
+ return html`
423
+ <div class="border rounded-lg">
424
+ <p class="text-xs text-muted-foreground px-4 pt-3">
425
+ ${this.labels.preview}
426
+ </p>
427
+ <div class="px-5 py-3">
428
+ <div class="site-header-top">
429
+ <a href="/" class="site-logo">${this.siteName}</a>
430
+ <div class="site-header-right">
431
+ ${visible.length > 0 || hasMore
432
+ ? html`<nav class="site-header-nav">
433
+ ${visible.map(
434
+ (item) =>
435
+ html`<a href=${item.url} class="site-header-link">
436
+ ${item.label}
437
+ </a>`,
438
+ )}
439
+ ${hasMore
440
+ ? html`<div class="dropdown-menu site-header-more">
441
+ <button
442
+ type="button"
443
+ class="site-header-more-btn"
444
+ aria-haspopup="menu"
445
+ aria-expanded=${this._showOverflow}
446
+ @click=${(e: Event) => {
447
+ e.stopPropagation();
448
+ this._showOverflow = !this._showOverflow;
449
+ if (this._showOverflow) {
450
+ setTimeout(() => {
451
+ document.addEventListener(
452
+ "click",
453
+ this.#closeOverflow,
454
+ );
455
+ });
456
+ } else {
457
+ document.removeEventListener(
458
+ "click",
459
+ this.#closeOverflow,
460
+ );
461
+ }
462
+ }}
463
+ >
464
+ <svg
465
+ xmlns="http://www.w3.org/2000/svg"
466
+ width="16"
467
+ height="16"
468
+ viewBox="0 0 24 24"
469
+ fill="currentColor"
470
+ >
471
+ <circle cx="5" cy="12" r="2" />
472
+ <circle cx="12" cy="12" r="2" />
473
+ <circle cx="19" cy="12" r="2" />
474
+ </svg>
475
+ </button>
476
+ ${this._showOverflow
477
+ ? html`<div
478
+ data-popover
479
+ data-align="end"
480
+ aria-hidden="false"
481
+ >
482
+ <div role="menu">
483
+ ${overflow.map(
484
+ (item) =>
485
+ html`<a href=${item.url} role="menuitem">
486
+ ${item.label}
487
+ </a>`,
488
+ )}
489
+ </div>
490
+ </div>`
491
+ : nothing}
492
+ </div>`
493
+ : nothing}
494
+ </nav>`
495
+ : nothing}
496
+ <span class="site-header-search" aria-hidden="true">
497
+ <svg
498
+ xmlns="http://www.w3.org/2000/svg"
499
+ width="16"
500
+ height="16"
501
+ viewBox="0 0 24 24"
502
+ fill="none"
503
+ stroke="currentColor"
504
+ stroke-width="2"
505
+ stroke-linecap="round"
506
+ stroke-linejoin="round"
507
+ >
508
+ <circle cx="11" cy="11" r="8" />
509
+ <path d="m21 21-4.35-4.35" />
510
+ </svg>
511
+ </span>
512
+ </div>
513
+ </div>
514
+ <nav class="site-browse-nav">
515
+ <span class="site-browse-link site-browse-link-active">
516
+ ${this.homeDefaultView === "featured"
517
+ ? this.labels.featured
518
+ : this.labels.latest}
519
+ </span>
520
+ <span class="site-browse-sep" aria-hidden="true">/</span>
521
+ <span class="site-browse-link">
522
+ ${this.homeDefaultView === "featured"
523
+ ? this.labels.latest
524
+ : this.labels.featured}
525
+ </span>
526
+ </nav>
527
+ </div>
528
+ </div>
529
+ `;
530
+ }
531
+
532
+ #renderTypeBadge(type: string) {
533
+ const label =
534
+ type === "page"
535
+ ? this.labels.page
536
+ : type === "system"
537
+ ? this.labels.system
538
+ : this.labels.link;
539
+ return html`<span class="badge-secondary">${label}</span>`;
540
+ }
541
+
542
+ #renderEditPanel(item: NavManagerItem) {
543
+ if (this._editingId !== item.id) return nothing;
544
+
545
+ if (item.type === "link") {
546
+ return html`
547
+ <div class="pb-4 pl-8 flex flex-col gap-3">
548
+ <div class="field">
549
+ <label class="label">${this.labels.label}</label>
550
+ <input
551
+ type="text"
552
+ class="input"
553
+ required
554
+ .value=${this._editLabel}
555
+ @input=${(e: Event) => {
556
+ this._editLabel = (e.target as HTMLInputElement).value;
557
+ }}
558
+ />
559
+ </div>
560
+ <div class="field">
561
+ <label class="label">${this.labels.url}</label>
562
+ <input
563
+ type="text"
564
+ class="input"
565
+ required
566
+ .value=${this._editUrl}
567
+ @input=${(e: Event) => {
568
+ this._editUrl = (e.target as HTMLInputElement).value;
569
+ }}
570
+ />
571
+ </div>
572
+ <div class="flex gap-2">
573
+ <button
574
+ type="button"
575
+ class="btn-sm"
576
+ @click=${() => this.#handleUpdate(item)}
577
+ >
578
+ ${this.labels.save}
579
+ </button>
580
+ <button
581
+ type="button"
582
+ class="btn-sm-ghost text-destructive"
583
+ @click=${() => this.#handleDelete(item)}
584
+ >
585
+ ${this.labels.delete}
586
+ </button>
587
+ </div>
588
+ </div>
589
+ `;
590
+ }
591
+
592
+ if (item.type === "page") {
593
+ return html`
594
+ <div class="pb-4 pl-8 flex flex-col gap-3">
595
+ <code class="text-sm text-muted-foreground">${item.url}</code>
596
+ <div class="flex gap-2">
597
+ ${item.pageId
598
+ ? html`<a
599
+ href=${`/dash/pages/${item.pageId}/edit`}
600
+ class="btn-sm-outline"
601
+ >${this.labels.editPage}</a
602
+ >`
603
+ : nothing}
604
+ <button
605
+ type="button"
606
+ class="btn-sm-ghost text-destructive"
607
+ @click=${() => this.#handleDelete(item)}
608
+ >
609
+ ${this.labels.remove}
610
+ </button>
611
+ </div>
612
+ </div>
613
+ `;
614
+ }
615
+
616
+ if (item.type === "system") {
617
+ return html`
618
+ <div class="pb-4 pl-8 flex flex-col gap-3">
619
+ <code class="text-sm text-muted-foreground">${item.url}</code>
620
+ <div class="field">
621
+ <label class="label">${this.labels.label}</label>
622
+ <input
623
+ type="text"
624
+ class="input"
625
+ required
626
+ .value=${this._editLabel}
627
+ @input=${(e: Event) => {
628
+ this._editLabel = (e.target as HTMLInputElement).value;
629
+ }}
630
+ />
631
+ </div>
632
+ <div class="flex gap-2">
633
+ <button
634
+ type="button"
635
+ class="btn-sm"
636
+ @click=${() => this.#handleUpdate(item)}
637
+ >
638
+ ${this.labels.save}
639
+ </button>
640
+ <button
641
+ type="button"
642
+ class="btn-sm-ghost text-destructive"
643
+ @click=${() => this.#handleDelete(item)}
644
+ >
645
+ ${this.labels.remove}
646
+ </button>
647
+ </div>
648
+ </div>
649
+ `;
650
+ }
651
+
652
+ return nothing;
653
+ }
654
+
655
+ #renderItem(item: NavManagerItem) {
656
+ return html`
657
+ <div data-nav-id=${item.id}>
658
+ <div class="flex items-center py-3 gap-2">
659
+ <div
660
+ class="flex items-center gap-3 cursor-grab flex-1 min-w-0"
661
+ data-drag-handle
662
+ >
663
+ <svg
664
+ xmlns="http://www.w3.org/2000/svg"
665
+ width="16"
666
+ height="16"
667
+ viewBox="0 0 24 24"
668
+ fill="none"
669
+ stroke="currentColor"
670
+ stroke-width="2"
671
+ stroke-linecap="round"
672
+ stroke-linejoin="round"
673
+ class="text-muted-foreground shrink-0"
674
+ >
675
+ <circle cx="9" cy="12" r="1" />
676
+ <circle cx="9" cy="5" r="1" />
677
+ <circle cx="9" cy="19" r="1" />
678
+ <circle cx="15" cy="12" r="1" />
679
+ <circle cx="15" cy="5" r="1" />
680
+ <circle cx="15" cy="19" r="1" />
681
+ </svg>
682
+ <span class="font-medium truncate">${item.label}</span>
683
+ </div>
684
+ <div class="flex items-center gap-2 shrink-0">
685
+ ${this.#renderTypeBadge(item.type)}
686
+ <button
687
+ type="button"
688
+ class="btn-sm-ghost"
689
+ @click=${() => this.#toggleEdit(item)}
690
+ aria-label=${this.labels.toggleEdit}
691
+ >
692
+ <svg
693
+ xmlns="http://www.w3.org/2000/svg"
694
+ width="16"
695
+ height="16"
696
+ viewBox="0 0 24 24"
697
+ fill="none"
698
+ stroke="currentColor"
699
+ stroke-width="2"
700
+ stroke-linecap="round"
701
+ stroke-linejoin="round"
702
+ >
703
+ <path d="m6 9 6 6 6-6" />
704
+ </svg>
705
+ </button>
706
+ </div>
707
+ </div>
708
+ ${this.#renderEditPanel(item)}
709
+ </div>
710
+ `;
711
+ }
712
+
713
+ #renderAddArea() {
714
+ return html`
715
+ ${this.#renderAddPageSection()} ${this.#renderAddLinkSection()}
716
+ `;
717
+ }
718
+
719
+ #renderAddPageSection() {
720
+ const query = this._pageSearchQuery.toLowerCase();
721
+ const filteredPages = query
722
+ ? this._availablePages.filter((p) =>
723
+ (p.title || p.slug).toLowerCase().includes(query),
724
+ )
725
+ : this._availablePages;
726
+
727
+ return html`
728
+ <section class="mt-8">
729
+ <h2 class="text-lg font-semibold mb-3">
730
+ ${this.labels.addPageToNavigation}
731
+ </h2>
732
+ <div id="nav-page-select" class="select">
733
+ <button
734
+ type="button"
735
+ class="btn-outline w-full sm:w-[280px]"
736
+ id="nav-page-select-trigger"
737
+ aria-haspopup="listbox"
738
+ aria-expanded=${this._showPagePicker}
739
+ aria-controls="nav-page-select-listbox"
740
+ @click=${(e: Event) => {
741
+ e.stopPropagation();
742
+ this._showPagePicker = !this._showPagePicker;
743
+ this._pageSearchQuery = "";
744
+ if (this._showPagePicker) {
745
+ setTimeout(() => {
746
+ document.addEventListener("click", this.#closePagePicker);
747
+ this.querySelector<HTMLInputElement>(
748
+ "#nav-page-search",
749
+ )?.focus();
750
+ });
751
+ } else {
752
+ document.removeEventListener("click", this.#closePagePicker);
753
+ }
754
+ }}
755
+ >
756
+ <span class="truncate">${this.labels.choosePage}</span>
757
+ <svg
758
+ xmlns="http://www.w3.org/2000/svg"
759
+ width="24"
760
+ height="24"
761
+ viewBox="0 0 24 24"
762
+ fill="none"
763
+ stroke="currentColor"
764
+ stroke-width="2"
765
+ stroke-linecap="round"
766
+ stroke-linejoin="round"
767
+ class="text-muted-foreground opacity-50 shrink-0"
768
+ >
769
+ <path d="m7 15 5 5 5-5" />
770
+ <path d="m7 9 5-5 5 5" />
771
+ </svg>
772
+ </button>
773
+ ${this._showPagePicker
774
+ ? html`
775
+ <div
776
+ id="nav-page-select-popover"
777
+ data-popover
778
+ aria-hidden="false"
779
+ class="w-full sm:w-[280px]"
780
+ @click=${(e: Event) => e.stopPropagation()}
781
+ >
782
+ <header>
783
+ <svg
784
+ xmlns="http://www.w3.org/2000/svg"
785
+ width="24"
786
+ height="24"
787
+ viewBox="0 0 24 24"
788
+ fill="none"
789
+ stroke="currentColor"
790
+ stroke-width="2"
791
+ stroke-linecap="round"
792
+ stroke-linejoin="round"
793
+ >
794
+ <circle cx="11" cy="11" r="8" />
795
+ <path d="m21 21-4.3-4.3" />
796
+ </svg>
797
+ <input
798
+ type="text"
799
+ id="nav-page-search"
800
+ .value=${this._pageSearchQuery}
801
+ placeholder=${this.labels.searchPages}
802
+ autocomplete="off"
803
+ autocorrect="off"
804
+ spellcheck="false"
805
+ aria-autocomplete="list"
806
+ role="combobox"
807
+ aria-expanded="true"
808
+ aria-controls="nav-page-select-listbox"
809
+ aria-labelledby="nav-page-select-trigger"
810
+ @input=${(e: Event) => {
811
+ this._pageSearchQuery = (
812
+ e.target as HTMLInputElement
813
+ ).value;
814
+ }}
815
+ />
816
+ </header>
817
+ <div
818
+ role="listbox"
819
+ id="nav-page-select-listbox"
820
+ aria-orientation="vertical"
821
+ aria-labelledby="nav-page-select-trigger"
822
+ data-empty=${this.labels.noPagesFound}
823
+ >
824
+ ${filteredPages.length > 0
825
+ ? html`<div class="max-h-64 overflow-y-auto scrollbar">
826
+ ${filteredPages.map(
827
+ (page) => html`
828
+ <div
829
+ role="option"
830
+ data-value=${page.id}
831
+ @click=${() => {
832
+ this._showPagePicker = false;
833
+ this._pageSearchQuery = "";
834
+ document.removeEventListener(
835
+ "click",
836
+ this.#closePagePicker,
837
+ );
838
+ this.#handleAddPage(page);
839
+ }}
840
+ >
841
+ ${page.title || page.slug}
842
+ </div>
843
+ `,
844
+ )}
845
+ </div>`
846
+ : html`<div
847
+ class="py-6 text-center text-sm text-muted-foreground"
848
+ >
849
+ ${this._availablePages.length === 0
850
+ ? this.labels.allPagesInNav
851
+ : this.labels.noPagesFound}
852
+ </div>`}
853
+ </div>
854
+ </div>
855
+ `
856
+ : nothing}
857
+ </div>
858
+ </section>
859
+ `;
860
+ }
861
+
862
+ #renderAddLinkSection() {
863
+ return html`
864
+ <section class="mt-8">
865
+ <h2 class="text-lg font-semibold mb-3">
866
+ ${this.labels.addCustomLinkToNavigation}
867
+ </h2>
868
+ <div id="nav-link-popover" class="popover">
869
+ <button
870
+ id="nav-link-popover-trigger"
871
+ type="button"
872
+ aria-expanded=${this._showLinkForm}
873
+ aria-controls="nav-link-popover-content"
874
+ class="btn-outline"
875
+ @click=${(e: Event) => {
876
+ e.stopPropagation();
877
+ this._showLinkForm = !this._showLinkForm;
878
+ if (this._showLinkForm) {
879
+ setTimeout(() => {
880
+ document.addEventListener("click", this.#closeLinkForm);
881
+ });
882
+ } else {
883
+ document.removeEventListener("click", this.#closeLinkForm);
884
+ }
885
+ }}
886
+ >
887
+ ${this.labels.addLink}
888
+ </button>
889
+ ${this._showLinkForm
890
+ ? html`
891
+ <div
892
+ id="nav-link-popover-content"
893
+ data-popover
894
+ data-side="top"
895
+ aria-hidden="false"
896
+ class="w-80"
897
+ style="bottom: 100%; margin-bottom: 0.5rem;"
898
+ @click=${(e: Event) => e.stopPropagation()}
899
+ >
900
+ <div class="grid gap-4">
901
+ <header class="grid gap-1.5">
902
+ <h4 class="leading-none font-medium">
903
+ ${this.labels.addLink}
904
+ </h4>
905
+ <p class="text-muted-foreground text-sm">
906
+ ${this.labels.addLinkDescription}
907
+ </p>
908
+ </header>
909
+ <form
910
+ class="form grid gap-2"
911
+ @submit=${(e: Event) => {
912
+ e.preventDefault();
913
+ this.#handleAddLink();
914
+ }}
915
+ >
916
+ <div class="grid grid-cols-3 items-center gap-4">
917
+ <label for="nav-link-label">${this.labels.label}</label>
918
+ <input
919
+ type="text"
920
+ id="nav-link-label"
921
+ class="col-span-2 h-8"
922
+ placeholder="Home"
923
+ required
924
+ .value=${this._newLinkLabel}
925
+ @input=${(e: Event) => {
926
+ this._newLinkLabel = (
927
+ e.target as HTMLInputElement
928
+ ).value;
929
+ }}
930
+ autofocus
931
+ />
932
+ </div>
933
+ <div class="grid grid-cols-3 items-center gap-4">
934
+ <label for="nav-link-url">${this.labels.url}</label>
935
+ <input
936
+ type="text"
937
+ id="nav-link-url"
938
+ class="col-span-2 h-8"
939
+ placeholder=${this.labels.urlPlaceholder}
940
+ required
941
+ .value=${this._newLinkUrl}
942
+ @input=${(e: Event) => {
943
+ this._newLinkUrl = (
944
+ e.target as HTMLInputElement
945
+ ).value;
946
+ }}
947
+ />
948
+ </div>
949
+ <button
950
+ type="submit"
951
+ class="btn-sm mt-2"
952
+ ?disabled=${this._addingLink}
953
+ >
954
+ ${this.labels.addLink}
955
+ </button>
956
+ </form>
957
+ </div>
958
+ </div>
959
+ `
960
+ : nothing}
961
+ </div>
962
+ </section>
963
+ `;
964
+ }
965
+
966
+ #renderSystemToggles() {
967
+ if (!this.systemNavItems?.length) return nothing;
968
+
969
+ return html`
970
+ <section class="mt-8">
971
+ <h2 class="text-lg font-semibold mb-1">${this.labels.systemLinks}</h2>
972
+ <p class="text-sm text-muted-foreground mb-3">
973
+ ${this.labels.systemLinksDescription}
974
+ </p>
975
+ <div class="flex flex-col divide-y">
976
+ ${this.systemNavItems.map((config) => {
977
+ const enabled = this.#isSystemEnabled(config);
978
+ const toggling = this._togglingKeys.has(config.key);
979
+ return html`
980
+ <div class="flex items-center justify-between py-3">
981
+ <div>
982
+ <p class="font-medium">${config.defaultLabel}</p>
983
+ <p class="text-sm text-muted-foreground">
984
+ ${config.description}
985
+ </p>
986
+ </div>
987
+ <input
988
+ type="checkbox"
989
+ role="switch"
990
+ class="input"
991
+ .checked=${enabled}
992
+ ?disabled=${toggling}
993
+ @change=${(e: Event) => {
994
+ const checked = (e.target as HTMLInputElement).checked;
995
+ this.#handleSystemToggle(config, checked);
996
+ }}
997
+ />
998
+ </div>
999
+ `;
1000
+ })}
1001
+ </div>
1002
+ </section>
1003
+ `;
1004
+ }
1005
+
1006
+ render() {
1007
+ return html`
1008
+ ${this.#renderPreview()}
1009
+
1010
+ <div class="flex flex-col gap-3 mt-3">
1011
+ <div class="flex items-center gap-3">
1012
+ <label class="text-sm" for="nav-max-visible">
1013
+ ${this.labels.maxVisibleLinks}
1014
+ </label>
1015
+ <input
1016
+ type="number"
1017
+ id="nav-max-visible"
1018
+ class="input w-16 h-8"
1019
+ min="0"
1020
+ max="5"
1021
+ .value=${String(this.maxVisible)}
1022
+ @change=${(e: Event) => {
1023
+ const val = parseInt((e.target as HTMLInputElement).value, 10);
1024
+ if (!isNaN(val)) this.#handleMaxVisibleChange(val);
1025
+ }}
1026
+ />
1027
+ </div>
1028
+ <div class="flex items-center gap-3">
1029
+ <label class="text-sm" for="nav-home-view">
1030
+ ${this.labels.useFeaturedAsDefault}
1031
+ </label>
1032
+ <input
1033
+ type="checkbox"
1034
+ role="switch"
1035
+ id="nav-home-view"
1036
+ class="input"
1037
+ .checked=${this.homeDefaultView === "featured"}
1038
+ @change=${(e: Event) => {
1039
+ this.#handleHomeViewToggle(
1040
+ (e.target as HTMLInputElement).checked,
1041
+ );
1042
+ }}
1043
+ />
1044
+ </div>
1045
+ </div>
1046
+
1047
+ <section class="mt-8">
1048
+ <h2 class="text-lg font-semibold mb-3">
1049
+ ${this.labels.navigationItems}
1050
+ </h2>
1051
+ ${this._items.length === 0
1052
+ ? html`<p class="text-sm text-muted-foreground py-4">
1053
+ ${this.labels.emptyState}
1054
+ </p>`
1055
+ : html`
1056
+ <div id="nav-items-list" class="flex flex-col divide-y">
1057
+ ${this._items.map((item) => this.#renderItem(item))}
1058
+ </div>
1059
+ `}
1060
+ </section>
1061
+
1062
+ ${this.#renderAddArea()} ${this.#renderSystemToggles()}
1063
+ `;
1064
+ }
1065
+ }
1066
+
1067
+ customElements.define("jant-nav-manager", JantNavManager);