@jant/core 0.6.14 → 0.6.15

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 (311) hide show
  1. package/bin/commands/import-site.js +81 -13
  2. package/bin/commands/migrate.js +55 -0
  3. package/bin/commands/posts/rebuild-html.js +184 -0
  4. package/bin/commands/site/snapshot/import.js +14 -10
  5. package/bin/lib/d1-query.js +23 -5
  6. package/bin/lib/migration-runner.js +29 -7
  7. package/bin/lib/node-database.js +32 -0
  8. package/bin/lib/site-snapshot.js +347 -46
  9. package/bin/lib/sql-export.js +1 -1
  10. package/bin/lib/thread-collection-migration.js +326 -0
  11. package/dist/app-BxCOR3Uc.js +6 -0
  12. package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
  13. package/dist/client/.vite/manifest.json +3 -3
  14. package/dist/client/_assets/client-B_eGKN5p.css +2 -0
  15. package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
  16. package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
  17. package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
  18. package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
  19. package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
  20. package/dist/index.js +3 -3
  21. package/dist/node.js +5 -5
  22. package/package.json +11 -10
  23. package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
  24. package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
  25. package/src/__tests__/export-hugo-build.test.ts +63 -4
  26. package/src/__tests__/export-import-roundtrip.test.ts +6 -4
  27. package/src/__tests__/export-service.test.ts +142 -9
  28. package/src/__tests__/helpers/app.ts +3 -0
  29. package/src/__tests__/helpers/export-fixtures.ts +2 -0
  30. package/src/__tests__/import-site-command.test.ts +65 -0
  31. package/src/__tests__/vite-config.test.ts +45 -0
  32. package/src/app.tsx +16 -3
  33. package/src/client/__tests__/collection-created-notice.test.ts +36 -0
  34. package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
  35. package/src/client/__tests__/collection-page-actions.test.ts +71 -2
  36. package/src/client/__tests__/compose-bridge.test.ts +64 -0
  37. package/src/client/__tests__/feed-video-player.test.ts +16 -1
  38. package/src/client/__tests__/footnote-rail.test.ts +472 -0
  39. package/src/client/__tests__/hydrate-partial.test.ts +3 -0
  40. package/src/client/__tests__/toast.test.ts +18 -1
  41. package/src/client/collection-created-notice.ts +66 -0
  42. package/src/client/collection-form-bridge.ts +17 -0
  43. package/src/client/collection-navigation.ts +44 -0
  44. package/src/client/collection-page-actions.ts +57 -1
  45. package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
  46. package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
  47. package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
  48. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
  49. package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
  50. package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
  51. package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
  52. package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
  53. package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
  54. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  55. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  56. package/src/client/components/collection-manager-types.ts +9 -3
  57. package/src/client/components/compose-types.ts +8 -0
  58. package/src/client/components/jant-collection-directory.ts +165 -30
  59. package/src/client/components/jant-command-palette.ts +2 -1
  60. package/src/client/components/jant-compose-dialog.ts +85 -21
  61. package/src/client/components/jant-compose-editor.ts +13 -7
  62. package/src/client/components/jant-compose-fullscreen.ts +24 -4
  63. package/src/client/components/jant-config-editor.ts +722 -0
  64. package/src/client/components/jant-media-lightbox.ts +100 -18
  65. package/src/client/components/jant-nav-manager.ts +1283 -54
  66. package/src/client/components/jant-post-form.ts +3 -3
  67. package/src/client/components/jant-post-menu.ts +86 -74
  68. package/src/client/components/nav-manager-types.ts +51 -2
  69. package/src/client/components/post-form-types.ts +1 -1
  70. package/src/client/compose-bridge.ts +4 -1
  71. package/src/client/feed-video-player.ts +62 -2
  72. package/src/client/footnote-rail.ts +478 -0
  73. package/src/client/hydrate-partial.ts +2 -0
  74. package/src/client/media-lightbox-events.ts +63 -0
  75. package/src/client/site-header-fragment.ts +38 -0
  76. package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
  77. package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
  78. package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
  79. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
  80. package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
  81. package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
  82. package/src/client/tiptap/bubble-menu.ts +88 -10
  83. package/src/client/tiptap/continuous-lists.ts +102 -0
  84. package/src/client/tiptap/create-editor.ts +3 -0
  85. package/src/client/tiptap/extensions.ts +14 -5
  86. package/src/client/tiptap/markdown-clipboard.ts +608 -11
  87. package/src/client/tiptap/slash-commands.ts +8 -1
  88. package/src/client/tiptap/structural-keymap.ts +211 -0
  89. package/src/client/tiptap/tab-indent.ts +8 -22
  90. package/src/client/tiptap/table-control-labels.ts +30 -0
  91. package/src/client/tiptap/table-controls.ts +537 -0
  92. package/src/client/tiptap/table-size-picker.ts +254 -0
  93. package/src/client/toast.ts +8 -6
  94. package/src/client-auth.ts +1 -0
  95. package/src/client.ts +1 -0
  96. package/src/db/__tests__/d1-query.test.ts +43 -1
  97. package/src/db/__tests__/migration-runner.test.ts +63 -0
  98. package/src/db/__tests__/migrations.test.ts +469 -0
  99. package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
  100. package/src/db/migrations/0027_old_blue_blade.sql +39 -0
  101. package/src/db/migrations/0028_superb_post.sql +2 -0
  102. package/src/db/migrations/0029_boring_magma.sql +1 -0
  103. package/src/db/migrations/0030_typical_vivisector.sql +50 -0
  104. package/src/db/migrations/meta/0027_snapshot.json +2511 -0
  105. package/src/db/migrations/meta/0028_snapshot.json +2511 -0
  106. package/src/db/migrations/meta/0029_snapshot.json +2519 -0
  107. package/src/db/migrations/meta/0030_snapshot.json +2541 -0
  108. package/src/db/migrations/meta/_journal.json +28 -0
  109. package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
  110. package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
  111. package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
  112. package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
  113. package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
  114. package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
  115. package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
  116. package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
  117. package/src/db/migrations/pg/meta/_journal.json +28 -0
  118. package/src/db/pg/schema.ts +28 -20
  119. package/src/db/rehearsal-fixtures/demo-current.json +39 -4
  120. package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
  121. package/src/db/schema.ts +28 -20
  122. package/src/i18n/__tests__/detect.test.ts +8 -2
  123. package/src/i18n/detect.ts +14 -5
  124. package/src/i18n/locales/public/en.po +133 -30
  125. package/src/i18n/locales/public/en.ts +1 -1
  126. package/src/i18n/locales/public/zh-Hans.po +128 -25
  127. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  128. package/src/i18n/locales/public/zh-Hant.po +128 -25
  129. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  130. package/src/i18n/locales/settings/en.po +513 -10
  131. package/src/i18n/locales/settings/en.ts +1 -1
  132. package/src/i18n/locales/settings/zh-Hans.po +515 -12
  133. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  134. package/src/i18n/locales/settings/zh-Hant.po +515 -12
  135. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  136. package/src/lib/__tests__/api-settings.test.ts +299 -0
  137. package/src/lib/__tests__/constants.test.ts +5 -0
  138. package/src/lib/__tests__/feed-policy.test.ts +25 -0
  139. package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
  140. package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
  141. package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
  142. package/src/lib/__tests__/markdown.test.ts +8 -6
  143. package/src/lib/__tests__/navigation.test.ts +59 -2
  144. package/src/lib/__tests__/post-body-html.test.ts +176 -0
  145. package/src/lib/__tests__/post-display.test.ts +64 -0
  146. package/src/lib/__tests__/resolve-config.test.ts +113 -1
  147. package/src/lib/__tests__/summary.test.ts +82 -0
  148. package/src/lib/__tests__/timeline.test.ts +109 -70
  149. package/src/lib/__tests__/timezones.test.ts +31 -3
  150. package/src/lib/__tests__/tiptap-render.test.ts +268 -9
  151. package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
  152. package/src/lib/__tests__/view.test.ts +53 -0
  153. package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
  154. package/src/lib/api-settings.ts +239 -12
  155. package/src/lib/collection-sort.ts +7 -5
  156. package/src/lib/constants.ts +2 -0
  157. package/src/lib/feed-path.ts +25 -0
  158. package/src/lib/feed-policy.ts +64 -0
  159. package/src/lib/footnotes.ts +264 -0
  160. package/src/lib/github-sync-site-config.ts +2 -0
  161. package/src/lib/hugo-markdown.ts +10 -3
  162. package/src/lib/jant-branding.ts +1 -0
  163. package/src/lib/markdown-manager.ts +188 -5
  164. package/src/lib/markdown.ts +10 -3
  165. package/src/lib/navigation.ts +16 -3
  166. package/src/lib/post-body-html.ts +131 -0
  167. package/src/lib/post-display.ts +7 -1
  168. package/src/lib/render.tsx +9 -2
  169. package/src/lib/resolve-config.ts +100 -16
  170. package/src/lib/schemas.ts +133 -1
  171. package/src/lib/settings-paths.ts +5 -0
  172. package/src/lib/site-skill.ts +78 -0
  173. package/src/lib/summary.ts +16 -5
  174. package/src/lib/timeline.ts +98 -122
  175. package/src/lib/timezones.ts +54 -14
  176. package/src/lib/tiptap-render.ts +355 -81
  177. package/src/lib/view.ts +30 -20
  178. package/src/lib/worker-response-cache.ts +11 -18
  179. package/src/middleware/__tests__/public-content-access.test.ts +91 -0
  180. package/src/middleware/config.ts +2 -2
  181. package/src/middleware/public-content-access.ts +87 -0
  182. package/src/node/__tests__/cli-export.test.ts +14 -0
  183. package/src/node/__tests__/cli-migrate.test.ts +6 -0
  184. package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
  185. package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
  186. package/src/node/index.ts +2 -1
  187. package/src/preset.css +16 -2
  188. package/src/routes/__tests__/compose.test.ts +51 -0
  189. package/src/routes/__tests__/site-skill.test.ts +112 -0
  190. package/src/routes/api/__tests__/collections.test.ts +91 -19
  191. package/src/routes/api/__tests__/mcp.test.ts +65 -1
  192. package/src/routes/api/__tests__/nav-items.test.ts +91 -0
  193. package/src/routes/api/__tests__/public-access.test.ts +81 -0
  194. package/src/routes/api/__tests__/settings.test.ts +217 -0
  195. package/src/routes/api/collections.ts +22 -26
  196. package/src/routes/api/export.ts +2 -0
  197. package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
  198. package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
  199. package/src/routes/api/internal/post-body-html.ts +43 -0
  200. package/src/routes/api/internal/sites.ts +24 -0
  201. package/src/routes/api/nav-items.ts +24 -1
  202. package/src/routes/api/posts.ts +2 -2
  203. package/src/routes/api/public/__tests__/archive.test.ts +35 -0
  204. package/src/routes/api/public/__tests__/posts.test.ts +208 -0
  205. package/src/routes/api/public/archive.ts +4 -0
  206. package/src/routes/api/public/posts.ts +41 -24
  207. package/src/routes/api/search.ts +3 -0
  208. package/src/routes/api/settings.ts +33 -0
  209. package/src/routes/auth/__tests__/setup.test.ts +7 -4
  210. package/src/routes/compose.tsx +2 -1
  211. package/src/routes/dash/settings.tsx +45 -2
  212. package/src/routes/feed/__tests__/feed.test.ts +210 -6
  213. package/src/routes/feed/feed.ts +23 -14
  214. package/src/routes/pages/__tests__/archive-params.test.ts +92 -3
  215. package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
  216. package/src/routes/pages/__tests__/collections.test.ts +9 -9
  217. package/src/routes/pages/__tests__/preview.test.ts +174 -0
  218. package/src/routes/pages/__tests__/search.test.ts +69 -0
  219. package/src/routes/pages/archive.tsx +27 -7
  220. package/src/routes/pages/collection.tsx +32 -19
  221. package/src/routes/pages/collections.tsx +6 -1
  222. package/src/routes/pages/page.tsx +83 -11
  223. package/src/routes/site-skill.ts +18 -0
  224. package/src/services/__tests__/auth.test.ts +7 -5
  225. package/src/services/__tests__/collection.test.ts +159 -44
  226. package/src/services/__tests__/navigation.test.ts +229 -21
  227. package/src/services/__tests__/post-timeline.test.ts +109 -30
  228. package/src/services/__tests__/post.test.ts +567 -5
  229. package/src/services/__tests__/search.test.ts +48 -1
  230. package/src/services/__tests__/settings.test.ts +8 -0
  231. package/src/services/__tests__/site-admin.test.ts +121 -1
  232. package/src/services/auth.ts +2 -2
  233. package/src/services/bootstrap.ts +3 -2
  234. package/src/services/collection.ts +226 -167
  235. package/src/services/export-theme/assets/client-site.css +1 -1
  236. package/src/services/export-theme/assets/client-site.js +46 -25
  237. package/src/services/export-theme/layouts/_default/list.html +18 -4
  238. package/src/services/export-theme/layouts/_default/rss.xml +8 -12
  239. package/src/services/export-theme/layouts/featured/list.html +5 -4
  240. package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
  241. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  242. package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
  243. package/src/services/export-theme/styles/main.css +30 -2
  244. package/src/services/export.ts +91 -80
  245. package/src/services/mcp.ts +37 -27
  246. package/src/services/media.ts +1 -1
  247. package/src/services/navigation.ts +185 -46
  248. package/src/services/post.ts +874 -271
  249. package/src/services/search.ts +8 -2
  250. package/src/services/settings.ts +2 -10
  251. package/src/services/site-admin.ts +63 -43
  252. package/src/style-cjk-jp.css +1 -1
  253. package/src/style-cjk-kr.css +1 -1
  254. package/src/style-cjk-tc.css +1 -1
  255. package/src/style-cjk.css +1 -1
  256. package/src/styles/components.css +540 -0
  257. package/src/styles/site-media.css +111 -19
  258. package/src/styles/tokens.css +14 -9
  259. package/src/styles/ui.css +829 -29
  260. package/src/types/bindings.ts +3 -0
  261. package/src/types/config.ts +243 -12
  262. package/src/types/constants.ts +22 -1
  263. package/src/types/entities.ts +9 -8
  264. package/src/types/operations.ts +18 -10
  265. package/src/types/props.ts +8 -2
  266. package/src/types/raw-assets.d.ts +5 -0
  267. package/src/types/views.ts +16 -4
  268. package/src/ui/__tests__/font-themes.test.ts +42 -30
  269. package/src/ui/compose/ComposeDialog.tsx +92 -2
  270. package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
  271. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
  272. package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
  273. package/src/ui/dash/settings/GeneralContent.tsx +15 -5
  274. package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
  275. package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
  276. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
  277. package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
  278. package/src/ui/feed/LinkCard.tsx +2 -12
  279. package/src/ui/feed/NoteCard.tsx +10 -20
  280. package/src/ui/feed/QuoteCard.tsx +2 -12
  281. package/src/ui/feed/ThreadPreview.tsx +26 -34
  282. package/src/ui/feed/TimelineFeed.tsx +2 -2
  283. package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
  284. package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
  285. package/src/ui/feed/thread-preview-state.ts +5 -7
  286. package/src/ui/font-themes.ts +74 -26
  287. package/src/ui/layouts/BaseLayout.tsx +22 -8
  288. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
  289. package/src/ui/pages/CollectionPage.tsx +101 -22
  290. package/src/ui/pages/CollectionsPage.tsx +3 -1
  291. package/src/ui/pages/PostPage.tsx +39 -12
  292. package/src/ui/pages/SearchPage.tsx +6 -14
  293. package/src/ui/pages/__tests__/ArchivePage.test.tsx +7 -0
  294. package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
  295. package/src/ui/shared/CollectionDirectory.tsx +13 -9
  296. package/src/ui/shared/CollectionsManager.tsx +17 -7
  297. package/src/ui/shared/DraftPreviewBar.tsx +33 -0
  298. package/src/ui/shared/HomePageBranding.tsx +2 -2
  299. package/src/ui/shared/MediaGallery.tsx +1 -0
  300. package/src/ui/shared/PostFooter.tsx +3 -1
  301. package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
  302. package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
  303. package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
  304. package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
  305. package/src/ui/shared/collection-management-labels.ts +33 -2
  306. package/src/ui/shared/navigation-labels.ts +2 -4
  307. package/src/ui/shared/post-article-attributes.ts +46 -0
  308. package/dist/app-DZDlmh7C.js +0 -6
  309. package/dist/client/_assets/client-Cevfpm8H.css +0 -2
  310. package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
  311. package/src/client/tiptap/wrapping-input-rules.ts +0 -102
@@ -27,11 +27,20 @@ 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
+ import { applySiteHeaderHtml } from "../site-header-fragment.js";
31
+ import { NAVIGATION_SETTINGS_PATH } from "../../lib/settings-paths.js";
32
+ import {
33
+ getCollectionEditPath,
34
+ getCollectionPagePath,
35
+ } from "../../lib/collection-paths.js";
36
+ import { getSlugValidationIssue } from "../../lib/slug-format.js";
37
+ import type { CollectionSubmitDetail } from "./collection-types.js";
38
+ import "./jant-collection-form.js";
31
39
  import type {
32
40
  NavManagerCollection,
33
41
  NavManagerItem,
34
42
  NavManagerLabels,
43
+ NavManagerPage,
35
44
  NavManagerSuggestedLink,
36
45
  SystemNavConfig,
37
46
  } from "./nav-manager-types.js";
@@ -45,6 +54,21 @@ type NavManagerMutationResponse<T extends object> = T & {
45
54
 
46
55
  type SortableEndEvent = Parameters<NonNullable<SortableOptions["onEnd"]>>[0];
47
56
 
57
+ type PageDialogView = "picker" | "create" | "created";
58
+ type CollectionDialogView = "create" | "created";
59
+
60
+ interface CreatedPage {
61
+ id: string;
62
+ title: string;
63
+ slug: string;
64
+ }
65
+
66
+ interface CreatedCollection {
67
+ id: string;
68
+ title: string;
69
+ slug: string;
70
+ }
71
+
48
72
  export class JantNavManager extends LitElement {
49
73
  static properties = {
50
74
  items: { type: Array },
@@ -53,6 +77,7 @@ export class JantNavManager extends LitElement {
53
77
  collections: { type: Array },
54
78
  suggestedLinks: { type: Array, attribute: "suggested-links" },
55
79
  siteName: { type: String, attribute: "site-name" },
80
+ rssFeedsEnabled: { type: Boolean, attribute: "rss-feeds-enabled" },
56
81
 
57
82
  _items: { state: true },
58
83
  _editingId: { state: true },
@@ -66,7 +91,28 @@ export class JantNavManager extends LitElement {
66
91
  _showPreviewMore: { state: true },
67
92
  _addingCollectionId: { state: true },
68
93
  _showCollectionPicker: { state: true },
94
+ _collectionDialogOpen: { state: true },
95
+ _collectionDialogView: { state: true },
96
+ _creatingCollection: { state: true },
97
+ _createCollectionError: { state: true },
98
+ _createdCollection: { state: true },
69
99
  _addingSuggestedKey: { state: true },
100
+ _pageDialogOpen: { state: true },
101
+ _pageDialogView: { state: true },
102
+ _pageQuery: { state: true },
103
+ _pages: { state: true },
104
+ _pageSearchLoading: { state: true },
105
+ _pageSearchError: { state: true },
106
+ _selectedPageIndex: { state: true },
107
+ _addingPageId: { state: true },
108
+ _newPageTitle: { state: true },
109
+ _newPageSlug: { state: true },
110
+ _slugEdited: { state: true },
111
+ _slugCheckLoading: { state: true },
112
+ _slugTaken: { state: true },
113
+ _creatingPage: { state: true },
114
+ _createPageError: { state: true },
115
+ _createdPage: { state: true },
70
116
  };
71
117
 
72
118
  declare items: NavManagerItem[];
@@ -75,6 +121,7 @@ export class JantNavManager extends LitElement {
75
121
  declare collections: NavManagerCollection[];
76
122
  declare suggestedLinks: NavManagerSuggestedLink[];
77
123
  declare siteName: string;
124
+ declare rssFeedsEnabled: boolean;
78
125
 
79
126
  declare _items: NavManagerItem[];
80
127
  declare _editingId: string | null;
@@ -90,13 +137,40 @@ export class JantNavManager extends LitElement {
90
137
  /** ID of the collection currently being added (for loading state) */
91
138
  declare _addingCollectionId: string | null;
92
139
  declare _showCollectionPicker: boolean;
140
+ declare _collectionDialogOpen: boolean;
141
+ declare _collectionDialogView: CollectionDialogView;
142
+ declare _creatingCollection: boolean;
143
+ declare _createCollectionError: string;
144
+ declare _createdCollection: CreatedCollection | null;
93
145
  /** Key of the suggested link currently being added */
94
146
  declare _addingSuggestedKey: string | null;
147
+ declare _pageDialogOpen: boolean;
148
+ declare _pageDialogView: PageDialogView;
149
+ declare _pageQuery: string;
150
+ declare _pages: NavManagerPage[];
151
+ declare _pageSearchLoading: boolean;
152
+ declare _pageSearchError: boolean;
153
+ declare _selectedPageIndex: number;
154
+ declare _addingPageId: string | null;
155
+ declare _newPageTitle: string;
156
+ declare _newPageSlug: string;
157
+ declare _slugEdited: boolean;
158
+ declare _slugCheckLoading: boolean;
159
+ declare _slugTaken: boolean;
160
+ declare _creatingPage: boolean;
161
+ declare _createPageError: string;
162
+ declare _createdPage: CreatedPage | null;
95
163
 
96
164
  #sortableHeader: { destroy(): void } | null = null;
97
165
  #sortableMore: { destroy(): void } | null = null;
98
166
  #initialized = false;
99
167
  #revertNextSibling: Node | null = null;
168
+ #pageSearchTimer: ReturnType<typeof setTimeout> | null = null;
169
+ #pageSearchRequestId = 0;
170
+ #slugSuggestTimer: ReturnType<typeof setTimeout> | null = null;
171
+ #slugSuggestRequestId = 0;
172
+ #slugCheckTimer: ReturnType<typeof setTimeout> | null = null;
173
+ #slugCheckRequestId = 0;
100
174
  #closeLinkForm = () => {
101
175
  this._showLinkForm = false;
102
176
  document.removeEventListener("click", this.#closeLinkForm);
@@ -126,6 +200,21 @@ export class JantNavManager extends LitElement {
126
200
  this.#closePreviewMore();
127
201
  this.querySelector<HTMLElement>("[data-preview-more-trigger]")?.focus();
128
202
  };
203
+ #handleCollectionPickerKeydown = (event: globalThis.KeyboardEvent) => {
204
+ if (
205
+ event.key !== "Escape" ||
206
+ event.isComposing ||
207
+ event.keyCode === 229 ||
208
+ !this._showCollectionPicker
209
+ ) {
210
+ return;
211
+ }
212
+
213
+ event.preventDefault();
214
+ event.stopPropagation();
215
+ this.#closeCollectionPicker();
216
+ this.querySelector<HTMLElement>("[data-add-collection-trigger]")?.focus();
217
+ };
129
218
 
130
219
  createRenderRoot() {
131
220
  this.innerHTML = "";
@@ -140,6 +229,7 @@ export class JantNavManager extends LitElement {
140
229
  this.collections = [];
141
230
  this.suggestedLinks = [];
142
231
  this.siteName = "";
232
+ this.rssFeedsEnabled = false;
143
233
 
144
234
  this._items = [];
145
235
  this._editingId = null;
@@ -153,7 +243,28 @@ export class JantNavManager extends LitElement {
153
243
  this._showPreviewMore = false;
154
244
  this._addingCollectionId = null;
155
245
  this._showCollectionPicker = false;
246
+ this._collectionDialogOpen = false;
247
+ this._collectionDialogView = "create";
248
+ this._creatingCollection = false;
249
+ this._createCollectionError = "";
250
+ this._createdCollection = null;
156
251
  this._addingSuggestedKey = null;
252
+ this._pageDialogOpen = false;
253
+ this._pageDialogView = "picker";
254
+ this._pageQuery = "";
255
+ this._pages = [];
256
+ this._pageSearchLoading = false;
257
+ this._pageSearchError = false;
258
+ this._selectedPageIndex = 0;
259
+ this._addingPageId = null;
260
+ this._newPageTitle = "";
261
+ this._newPageSlug = "";
262
+ this._slugEdited = false;
263
+ this._slugCheckLoading = false;
264
+ this._slugTaken = false;
265
+ this._creatingPage = false;
266
+ this._createPageError = "";
267
+ this._createdPage = null;
157
268
  }
158
269
 
159
270
  protected update(changedProperties: PropertyValueMap<JantNavManager>): void {
@@ -165,7 +276,7 @@ export class JantNavManager extends LitElement {
165
276
  }
166
277
 
167
278
  protected updated(): void {
168
- if (this._showPreviewMore && this.#moreItems.length === 0) {
279
+ if (this._showPreviewMore && this.#previewMoreItems.length === 0) {
169
280
  this.#closePreviewMore();
170
281
  }
171
282
  this.#initSortable();
@@ -180,6 +291,7 @@ export class JantNavManager extends LitElement {
180
291
  document.removeEventListener("click", this.#closeLinkForm);
181
292
  document.removeEventListener("click", this.#closeCollectionPicker);
182
293
  this.#closePreviewMore();
294
+ this.#cancelPageDialogRequests();
183
295
  }
184
296
 
185
297
  #jsonMutationHeaders(): Record<string, string> {
@@ -217,31 +329,454 @@ export class JantNavManager extends LitElement {
217
329
  return { ...item, displayLabel };
218
330
  }
219
331
 
220
- #applySiteHeaderHtml(headerHtml: string | undefined) {
221
- if (!headerHtml) return;
332
+ // ===========================================================================
333
+ // Page picker and quick-create dialog
334
+ // ===========================================================================
335
+
336
+ #cancelPageDialogRequests() {
337
+ if (this.#pageSearchTimer !== null) {
338
+ clearTimeout(this.#pageSearchTimer);
339
+ this.#pageSearchTimer = null;
340
+ }
341
+ if (this.#slugSuggestTimer !== null) {
342
+ clearTimeout(this.#slugSuggestTimer);
343
+ this.#slugSuggestTimer = null;
344
+ }
345
+ if (this.#slugCheckTimer !== null) {
346
+ clearTimeout(this.#slugCheckTimer);
347
+ this.#slugCheckTimer = null;
348
+ }
349
+ this.#pageSearchRequestId += 1;
350
+ this.#slugSuggestRequestId += 1;
351
+ this.#slugCheckRequestId += 1;
352
+ }
353
+
354
+ async #openPageDialog() {
355
+ if (this._pageDialogOpen) return;
356
+
357
+ this.#cancelPageDialogRequests();
358
+ this._pageDialogOpen = true;
359
+ this._pageDialogView = "picker";
360
+ this._pageQuery = "";
361
+ this._pages = [];
362
+ this._pageSearchError = false;
363
+ this._selectedPageIndex = 0;
364
+ this._addingPageId = null;
365
+ this._newPageTitle = "";
366
+ this._newPageSlug = "";
367
+ this._slugEdited = false;
368
+ this._slugCheckLoading = false;
369
+ this._slugTaken = false;
370
+ this._creatingPage = false;
371
+ this._createPageError = "";
372
+ this._createdPage = null;
373
+
374
+ await this.updateComplete;
375
+ const dialog = this.querySelector<HTMLDialogElement>("#nav-page-dialog");
376
+ if (dialog && !dialog.open) dialog.showModal();
377
+ this.querySelector<HTMLInputElement>("#nav-page-search")?.focus();
378
+ void this.#loadPageCandidates();
379
+ }
380
+
381
+ #closePageDialog() {
382
+ const trigger = this.querySelector<HTMLElement>("[data-add-page-trigger]");
383
+ const dialog = this.querySelector<HTMLDialogElement>("#nav-page-dialog");
384
+ if (dialog?.open) dialog.close();
385
+ this.#cancelPageDialogRequests();
386
+ this._pageDialogOpen = false;
387
+ void this.updateComplete.then(() => trigger?.focus());
388
+ }
389
+
390
+ async #loadPageCandidates() {
391
+ const requestId = ++this.#pageSearchRequestId;
392
+ const params = new URLSearchParams({ limit: "20" });
393
+ const query = this._pageQuery.trim();
394
+ if (query) params.set("q", query);
395
+
396
+ this._pageSearchLoading = true;
397
+ this._pageSearchError = false;
398
+ try {
399
+ const res = await fetch(`/api/nav-items/pages?${params.toString()}`, {
400
+ headers: { Accept: "application/json" },
401
+ });
402
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
403
+ const json = (await res.json()) as { pages?: NavManagerPage[] };
404
+ if (requestId !== this.#pageSearchRequestId) return;
405
+
406
+ const addedPostIds = new Set(
407
+ this._items.flatMap((item) =>
408
+ item.type === "page" && item.postId ? [item.postId] : [],
409
+ ),
410
+ );
411
+ this._pages = (json.pages ?? []).filter(
412
+ (page) => !addedPostIds.has(page.id),
413
+ );
414
+ this._selectedPageIndex = 0;
415
+ } catch {
416
+ if (requestId === this.#pageSearchRequestId) {
417
+ this._pages = [];
418
+ this._pageSearchError = true;
419
+ }
420
+ } finally {
421
+ if (requestId === this.#pageSearchRequestId) {
422
+ this._pageSearchLoading = false;
423
+ }
424
+ }
425
+ }
426
+
427
+ #schedulePageSearch() {
428
+ if (this.#pageSearchTimer !== null) {
429
+ clearTimeout(this.#pageSearchTimer);
430
+ }
431
+ this.#pageSearchTimer = setTimeout(() => {
432
+ this.#pageSearchTimer = null;
433
+ void this.#loadPageCandidates();
434
+ }, 200);
435
+ }
436
+
437
+ async #addPageToNavigation(page: CreatedPage) {
438
+ if (this._addingPageId) return;
222
439
 
223
- const template = document.createElement("template");
224
- template.innerHTML = headerHtml.trim();
440
+ this._addingPageId = page.id;
441
+ try {
442
+ const res = await fetch("/api/nav-items", {
443
+ method: "POST",
444
+ headers: this.#jsonMutationHeaders(),
445
+ body: JSON.stringify({
446
+ type: "page",
447
+ postId: page.id,
448
+ placement: "header",
449
+ }),
450
+ });
451
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
452
+
453
+ const response =
454
+ (await res.json()) as NavManagerMutationResponse<NavManagerItem>;
455
+ const created = this.#normalizeItem(this.#stripHeaderHtml(response));
456
+ this.#destroySortables();
457
+ this._items = [...this._items, created];
458
+ this._pages = this._pages.filter((candidate) => candidate.id !== page.id);
459
+ applySiteHeaderHtml(response.headerHtml);
460
+ showToast(this.labels.pageAdded);
225
461
 
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;
462
+ this.#closePageDialog();
463
+ } catch {
464
+ showToast(this.labels.saveFailed, "error");
465
+ } finally {
466
+ this._addingPageId = null;
467
+ }
468
+ }
232
469
 
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;
470
+ #showCreatePage() {
471
+ this.#pageSearchRequestId += 1;
472
+ this._pageDialogView = "create";
473
+ this._newPageTitle = "";
474
+ this._newPageSlug = "";
475
+ this._slugEdited = false;
476
+ this._slugCheckLoading = false;
477
+ this._slugTaken = false;
478
+ this._createPageError = "";
479
+ void this.updateComplete.then(() => {
480
+ this.querySelector<HTMLInputElement>("#nav-new-page-title")?.focus();
481
+ });
482
+ }
238
483
 
239
- current.replaceWith(next);
240
- replaced = true;
484
+ #showPagePicker() {
485
+ this.#slugSuggestRequestId += 1;
486
+ this.#slugCheckRequestId += 1;
487
+ this._pageDialogView = "picker";
488
+ this._createPageError = "";
489
+ void this.updateComplete.then(() => {
490
+ this.querySelector<HTMLInputElement>("#nav-page-search")?.focus();
491
+ });
492
+ }
493
+
494
+ #scheduleSlugSuggestion() {
495
+ if (this._slugEdited) return;
496
+ if (this.#slugSuggestTimer !== null) {
497
+ clearTimeout(this.#slugSuggestTimer);
241
498
  }
499
+ const title = this._newPageTitle.trim();
500
+ if (!title) {
501
+ this._newPageSlug = "";
502
+ return;
503
+ }
504
+ this.#slugSuggestTimer = setTimeout(() => {
505
+ this.#slugSuggestTimer = null;
506
+ void this.#suggestPageSlug(title);
507
+ }, 250);
508
+ }
242
509
 
243
- if (replaced) {
244
- initSiteHeaderNav(document);
510
+ async #suggestPageSlug(title: string) {
511
+ const requestId = ++this.#slugSuggestRequestId;
512
+ const params = new URLSearchParams({ mode: "suggest", title });
513
+ try {
514
+ const res = await fetch(`/api/posts/slug?${params.toString()}`, {
515
+ headers: { Accept: "application/json" },
516
+ });
517
+ if (!res.ok) return;
518
+ const json = (await res.json()) as { slug?: string };
519
+ if (
520
+ requestId !== this.#slugSuggestRequestId ||
521
+ this._slugEdited ||
522
+ this._pageDialogView !== "create"
523
+ ) {
524
+ return;
525
+ }
526
+ this._newPageSlug = json.slug?.trim() ?? "";
527
+ this._slugTaken = false;
528
+ } catch {
529
+ // Slug suggestion is optional; the create endpoint can generate one.
530
+ }
531
+ }
532
+
533
+ #scheduleSlugCheck() {
534
+ if (this.#slugCheckTimer !== null) {
535
+ clearTimeout(this.#slugCheckTimer);
536
+ }
537
+ this._slugTaken = false;
538
+ const slug = this._newPageSlug.trim();
539
+ if (!slug || getSlugValidationIssue(slug, { maxLength: 200 })) {
540
+ this._slugCheckLoading = false;
541
+ return;
542
+ }
543
+
544
+ this._slugCheckLoading = true;
545
+ this.#slugCheckTimer = setTimeout(() => {
546
+ this.#slugCheckTimer = null;
547
+ void this.#checkPageSlug(slug);
548
+ }, 250);
549
+ }
550
+
551
+ async #checkPageSlug(slug: string) {
552
+ const requestId = ++this.#slugCheckRequestId;
553
+ const params = new URLSearchParams({ mode: "check", slug });
554
+ try {
555
+ const res = await fetch(`/api/posts/slug?${params.toString()}`, {
556
+ headers: { Accept: "application/json" },
557
+ });
558
+ if (!res.ok) return;
559
+ const json = (await res.json()) as { available?: boolean };
560
+ if (
561
+ requestId !== this.#slugCheckRequestId ||
562
+ this._newPageSlug.trim() !== slug
563
+ ) {
564
+ return;
565
+ }
566
+ this._slugTaken = json.available === false;
567
+ } catch {
568
+ // The create endpoint remains the final authority for slug conflicts.
569
+ } finally {
570
+ if (requestId === this.#slugCheckRequestId) {
571
+ this._slugCheckLoading = false;
572
+ }
573
+ }
574
+ }
575
+
576
+ #pageSlugError(): string {
577
+ const issue = getSlugValidationIssue(this._newPageSlug, { maxLength: 200 });
578
+ if (issue === "invalid") return this.labels.slugInvalid;
579
+ if (issue === "reserved") return this.labels.slugReserved;
580
+ if (issue === "too_long") return this.labels.slugTooLong;
581
+ if (this._slugTaken) return this.labels.slugUnavailable;
582
+ return "";
583
+ }
584
+
585
+ async #createPage() {
586
+ const title = this._newPageTitle.trim();
587
+ const slug = this._newPageSlug.trim();
588
+ if (!title) {
589
+ this._createPageError = this.labels.titleRequired;
590
+ return;
591
+ }
592
+ const slugError = this.#pageSlugError();
593
+ if (slugError) {
594
+ this._createPageError = slugError;
595
+ return;
596
+ }
597
+ if (this._slugCheckLoading) return;
598
+
599
+ this._creatingPage = true;
600
+ this._createPageError = "";
601
+ try {
602
+ const res = await fetch("/api/posts", {
603
+ method: "POST",
604
+ headers: {
605
+ "Content-Type": "application/json",
606
+ Accept: "application/json",
607
+ },
608
+ body: JSON.stringify({
609
+ format: "note",
610
+ title,
611
+ ...(slug && { slug }),
612
+ status: "published",
613
+ visibility: "latest_hidden",
614
+ }),
615
+ });
616
+ if (!res.ok) {
617
+ if (res.status === 409) {
618
+ this._slugTaken = true;
619
+ this._createPageError = this.labels.slugUnavailable;
620
+ return;
621
+ }
622
+ throw new Error(`HTTP ${res.status}`);
623
+ }
624
+
625
+ const created = (await res.json()) as CreatedPage;
626
+ this._createdPage = {
627
+ id: created.id,
628
+ title: created.title || title,
629
+ slug: created.slug,
630
+ };
631
+ this._pageDialogView = "created";
632
+ } catch {
633
+ this._createPageError = this.labels.createPageFailed;
634
+ } finally {
635
+ this._creatingPage = false;
636
+ }
637
+ }
638
+
639
+ #handlePageDialogKeydown(event: globalThis.KeyboardEvent) {
640
+ if (event.isComposing || event.keyCode === 229) return;
641
+
642
+ if (event.key === "Escape") {
643
+ event.preventDefault();
644
+ this.#closePageDialog();
645
+ return;
646
+ }
647
+
648
+ if (this._pageDialogView !== "picker") return;
649
+ if (event.target !== this.querySelector("#nav-page-search")) return;
650
+ if (event.key === "ArrowDown") {
651
+ event.preventDefault();
652
+ this._selectedPageIndex =
653
+ this._pages.length > 0
654
+ ? (this._selectedPageIndex + 1) % this._pages.length
655
+ : 0;
656
+ this.#scrollSelectedPageIntoView();
657
+ } else if (event.key === "ArrowUp") {
658
+ event.preventDefault();
659
+ this._selectedPageIndex =
660
+ this._pages.length > 0
661
+ ? (this._selectedPageIndex - 1 + this._pages.length) %
662
+ this._pages.length
663
+ : 0;
664
+ this.#scrollSelectedPageIntoView();
665
+ } else if (event.key === "Enter" && this._pages.length > 0) {
666
+ event.preventDefault();
667
+ const page = this._pages[this._selectedPageIndex];
668
+ if (page) void this.#addPageToNavigation(page);
669
+ }
670
+ }
671
+
672
+ #scrollSelectedPageIntoView() {
673
+ requestAnimationFrame(() => {
674
+ this.querySelector(".nav-page-result-selected")?.scrollIntoView({
675
+ block: "nearest",
676
+ });
677
+ });
678
+ }
679
+
680
+ // ===========================================================================
681
+ // Collection quick-create dialog
682
+ // ===========================================================================
683
+
684
+ async #openCollectionDialog() {
685
+ if (this._collectionDialogOpen) return;
686
+
687
+ this.#closeCollectionPicker();
688
+ this._collectionDialogOpen = true;
689
+ this._collectionDialogView = "create";
690
+ this._creatingCollection = false;
691
+ this._createCollectionError = "";
692
+ this._createdCollection = null;
693
+
694
+ await this.updateComplete;
695
+ const dialog = this.querySelector<HTMLDialogElement>(
696
+ "#nav-collection-dialog",
697
+ );
698
+ if (dialog && !dialog.open) dialog.showModal();
699
+ const titleInput = this.querySelector<HTMLInputElement>(
700
+ "#nav-collection-dialog [data-collection-title-input]",
701
+ );
702
+ titleInput?.focus();
703
+ titleInput?.select();
704
+ }
705
+
706
+ #closeCollectionDialog() {
707
+ const trigger = this.querySelector<HTMLElement>(
708
+ "[data-add-collection-trigger]",
709
+ );
710
+ const dialog = this.querySelector<HTMLDialogElement>(
711
+ "#nav-collection-dialog",
712
+ );
713
+ if (dialog?.open) dialog.close();
714
+ this._collectionDialogOpen = false;
715
+ this._creatingCollection = false;
716
+ this._createCollectionError = "";
717
+ void this.updateComplete.then(() => trigger?.focus());
718
+ }
719
+
720
+ #handleCollectionDialogKeydown(event: globalThis.KeyboardEvent) {
721
+ if (event.isComposing || event.keyCode === 229) return;
722
+ if (event.key !== "Escape") return;
723
+
724
+ event.preventDefault();
725
+ this.#closeCollectionDialog();
726
+ }
727
+
728
+ #submitCollectionForm() {
729
+ this.querySelector<HTMLFormElement>(
730
+ "#nav-collection-dialog jant-collection-form form",
731
+ )?.requestSubmit();
732
+ }
733
+
734
+ async #handleCreateCollectionSubmit(event: Event) {
735
+ const submitEvent = event as CustomEvent<CollectionSubmitDetail>;
736
+ submitEvent.stopPropagation();
737
+ if (!submitEvent.detail || this._creatingCollection) return;
738
+
739
+ const form = this.querySelector(
740
+ "#nav-collection-dialog jant-collection-form",
741
+ ) as (HTMLElement & { loading: boolean }) | null;
742
+ this._creatingCollection = true;
743
+ this._createCollectionError = "";
744
+ if (form) form.loading = true;
745
+
746
+ try {
747
+ const res = await fetch("/api/collections", {
748
+ method: "POST",
749
+ headers: {
750
+ "Content-Type": "application/json",
751
+ Accept: "application/json",
752
+ },
753
+ body: JSON.stringify(submitEvent.detail.data),
754
+ });
755
+ const created = (await res.json().catch(() => null)) as
756
+ | (CreatedCollection & { error?: string })
757
+ | null;
758
+ if (!res.ok || !created?.id || !created.title || !created.slug) {
759
+ throw new Error("Invalid collection response");
760
+ }
761
+
762
+ const newCollection: NavManagerCollection = {
763
+ id: created.id,
764
+ title: created.title,
765
+ slug: created.slug,
766
+ group: null,
767
+ };
768
+ if (
769
+ !this.collections.some((collection) => collection.id === created.id)
770
+ ) {
771
+ this.collections = [...this.collections, newCollection];
772
+ }
773
+ this._createdCollection = created;
774
+ this._collectionDialogView = "created";
775
+ } catch {
776
+ this._createCollectionError = this.labels.createCollectionFailed;
777
+ } finally {
778
+ this._creatingCollection = false;
779
+ if (form) form.loading = false;
245
780
  }
246
781
  }
247
782
 
@@ -382,7 +917,7 @@ export class JantNavManager extends LitElement {
382
917
 
383
918
  const moved =
384
919
  (await moveRes.json()) as NavManagerMutationResponse<NavManagerItem>;
385
- this.#applySiteHeaderHtml(moved.headerHtml);
920
+ applySiteHeaderHtml(moved.headerHtml);
386
921
  showToast(
387
922
  crossList ? this.labels.placementSaved : this.labels.orderSaved,
388
923
  );
@@ -433,7 +968,7 @@ export class JantNavManager extends LitElement {
433
968
  current.id === updated.id ? updated : current,
434
969
  );
435
970
  this._editingId = null;
436
- this.#applySiteHeaderHtml(response.headerHtml);
971
+ applySiteHeaderHtml(response.headerHtml);
437
972
  } catch {
438
973
  showToast(this.labels.saveFailed, "error");
439
974
  }
@@ -443,9 +978,13 @@ export class JantNavManager extends LitElement {
443
978
  const message =
444
979
  item.type === "collection"
445
980
  ? this.labels.confirmDeleteCollection
446
- : this.labels.confirmDeleteLink;
981
+ : item.type === "page"
982
+ ? this.labels.confirmDeletePage
983
+ : this.labels.confirmDeleteLink;
447
984
  const confirmLabel =
448
- item.type === "collection" ? this.labels.remove : this.labels.delete;
985
+ item.type === "collection" || item.type === "page"
986
+ ? this.labels.remove
987
+ : this.labels.delete;
449
988
  const confirmed = await showConfirmDialog({
450
989
  message,
451
990
  confirmLabel,
@@ -467,7 +1006,7 @@ export class JantNavManager extends LitElement {
467
1006
  this.#destroySortables();
468
1007
  this._items = this._items.filter((current) => current.id !== item.id);
469
1008
  this._editingId = null;
470
- this.#applySiteHeaderHtml(response.headerHtml);
1009
+ applySiteHeaderHtml(response.headerHtml);
471
1010
  } catch {
472
1011
  showToast(this.labels.deleteFailed, "error");
473
1012
  }
@@ -503,7 +1042,7 @@ export class JantNavManager extends LitElement {
503
1042
  this._newLinkUrl = "";
504
1043
  this._showLinkForm = false;
505
1044
  document.removeEventListener("click", this.#closeLinkForm);
506
- this.#applySiteHeaderHtml(response.headerHtml);
1045
+ applySiteHeaderHtml(response.headerHtml);
507
1046
  } catch {
508
1047
  showToast(this.labels.saveFailed, "error");
509
1048
  } finally {
@@ -537,7 +1076,11 @@ export class JantNavManager extends LitElement {
537
1076
  this.#destroySortables();
538
1077
  this._items = [...this._items, created];
539
1078
  this.#closeCollectionPicker();
540
- this.#applySiteHeaderHtml(response.headerHtml);
1079
+ applySiteHeaderHtml(response.headerHtml);
1080
+ showToast(this.labels.collectionAdded);
1081
+ if (this._collectionDialogOpen) {
1082
+ this.#closeCollectionDialog();
1083
+ }
541
1084
  } catch {
542
1085
  showToast(this.labels.saveFailed, "error");
543
1086
  } finally {
@@ -584,11 +1127,16 @@ export class JantNavManager extends LitElement {
584
1127
  if (hasMatchingPath) return true;
585
1128
 
586
1129
  return Boolean(
587
- link.collectionId &&
588
- this._items.some(
589
- (item) =>
590
- item.type === "collection" && item.collectionId === link.collectionId,
591
- ),
1130
+ (link.collectionId &&
1131
+ this._items.some(
1132
+ (item) =>
1133
+ item.type === "collection" &&
1134
+ item.collectionId === link.collectionId,
1135
+ )) ||
1136
+ (link.postId &&
1137
+ this._items.some(
1138
+ (item) => item.type === "page" && item.postId === link.postId,
1139
+ )),
592
1140
  );
593
1141
  }
594
1142
 
@@ -604,18 +1152,24 @@ export class JantNavManager extends LitElement {
604
1152
  this._addingSuggestedKey = link.key;
605
1153
  try {
606
1154
  const body =
607
- link.navItemType === "collection" && link.collectionId
1155
+ link.navItemType === "page" && link.postId
608
1156
  ? {
609
- type: "collection",
610
- collectionId: link.collectionId,
1157
+ type: "page",
1158
+ postId: link.postId,
611
1159
  placement: "header",
612
1160
  }
613
- : {
614
- type: "link",
615
- label: link.label,
616
- url: link.url,
617
- placement: "header",
618
- };
1161
+ : link.navItemType === "collection" && link.collectionId
1162
+ ? {
1163
+ type: "collection",
1164
+ collectionId: link.collectionId,
1165
+ placement: "header",
1166
+ }
1167
+ : {
1168
+ type: "link",
1169
+ label: link.label,
1170
+ url: link.url,
1171
+ placement: "header",
1172
+ };
619
1173
 
620
1174
  const res = await fetch("/api/nav-items", {
621
1175
  method: "POST",
@@ -629,7 +1183,7 @@ export class JantNavManager extends LitElement {
629
1183
  const created = this.#normalizeItem(this.#stripHeaderHtml(response));
630
1184
  this.#destroySortables();
631
1185
  this._items = [...this._items, created];
632
- this.#applySiteHeaderHtml(response.headerHtml);
1186
+ applySiteHeaderHtml(response.headerHtml);
633
1187
  showToast(this.labels.suggestedLinkAdded);
634
1188
  } catch {
635
1189
  showToast(this.labels.saveFailed, "error");
@@ -668,7 +1222,7 @@ export class JantNavManager extends LitElement {
668
1222
  const created = this.#normalizeItem(this.#stripHeaderHtml(response));
669
1223
  this.#destroySortables();
670
1224
  this._items = [...this._items, created];
671
- this.#applySiteHeaderHtml(response.headerHtml);
1225
+ applySiteHeaderHtml(response.headerHtml);
672
1226
  } else {
673
1227
  const existing = this._items.find(
674
1228
  (item) => item.type === "system" && item.systemKey === config.key,
@@ -685,7 +1239,7 @@ export class JantNavManager extends LitElement {
685
1239
  }>;
686
1240
  this.#destroySortables();
687
1241
  this._items = this._items.filter((item) => item.id !== existing.id);
688
- this.#applySiteHeaderHtml(response.headerHtml);
1242
+ applySiteHeaderHtml(response.headerHtml);
689
1243
  }
690
1244
  }
691
1245
  } catch {
@@ -710,6 +1264,20 @@ export class JantNavManager extends LitElement {
710
1264
  return this._items.filter((i) => i.placement === "more");
711
1265
  }
712
1266
 
1267
+ #isVisibleInPreview(item: NavManagerItem): boolean {
1268
+ return (
1269
+ this.rssFeedsEnabled || item.type !== "system" || item.systemKey !== "rss"
1270
+ );
1271
+ }
1272
+
1273
+ get #previewHeaderItems(): NavManagerItem[] {
1274
+ return this.#headerItems.filter((item) => this.#isVisibleInPreview(item));
1275
+ }
1276
+
1277
+ get #previewMoreItems(): NavManagerItem[] {
1278
+ return this.#moreItems.filter((item) => this.#isVisibleInPreview(item));
1279
+ }
1280
+
713
1281
  #closePreviewMore() {
714
1282
  this._showPreviewMore = false;
715
1283
  document.removeEventListener("click", this.#handlePreviewMoreDocumentClick);
@@ -733,8 +1301,8 @@ export class JantNavManager extends LitElement {
733
1301
  }
734
1302
 
735
1303
  #renderPreview() {
736
- const headerItems = this.#headerItems;
737
- const moreItems = this.#moreItems;
1304
+ const headerItems = this.#previewHeaderItems;
1305
+ const moreItems = this.#previewMoreItems;
738
1306
 
739
1307
  return html`
740
1308
  <div class="nav-preview">
@@ -820,7 +1388,9 @@ export class JantNavManager extends LitElement {
820
1388
  ? this.labels.system
821
1389
  : type === "collection"
822
1390
  ? this.labels.collection
823
- : this.labels.link;
1391
+ : type === "page"
1392
+ ? this.labels.page
1393
+ : this.labels.link;
824
1394
  return html`<span class="badge-secondary">${label}</span>`;
825
1395
  }
826
1396
 
@@ -905,6 +1475,54 @@ export class JantNavManager extends LitElement {
905
1475
  `;
906
1476
  }
907
1477
 
1478
+ if (item.type === "page") {
1479
+ const editHref = `${publicPath(item.url)}?edit=1`;
1480
+ return html`
1481
+ <div class="nav-item-edit">
1482
+ <div class="field">
1483
+ <label class="label">${this.labels.label}</label>
1484
+ <input
1485
+ type="text"
1486
+ class="input"
1487
+ required
1488
+ maxlength="100"
1489
+ .value=${this._editLabel}
1490
+ @input=${(e: Event) => {
1491
+ this._editLabel = (e.target as HTMLInputElement).value;
1492
+ }}
1493
+ />
1494
+ </div>
1495
+ <div class="flex items-center justify-between gap-3">
1496
+ <button
1497
+ type="button"
1498
+ class="btn-sm-ghost text-destructive"
1499
+ @click=${() => void this.#handleDelete(item)}
1500
+ >
1501
+ ${this.labels.remove}
1502
+ </button>
1503
+ <div class="flex items-center gap-2">
1504
+ <a
1505
+ class="btn-sm-outline"
1506
+ href=${editHref}
1507
+ target="_blank"
1508
+ rel="noopener noreferrer"
1509
+ >
1510
+ ${this.labels.editPage}
1511
+ <span aria-hidden="true">↗</span>
1512
+ </a>
1513
+ <button
1514
+ type="button"
1515
+ class="btn-sm"
1516
+ @click=${() => this.#handleUpdate(item)}
1517
+ >
1518
+ ${this.labels.save}
1519
+ </button>
1520
+ </div>
1521
+ </div>
1522
+ </div>
1523
+ `;
1524
+ }
1525
+
908
1526
  if (item.type === "link") {
909
1527
  return html`
910
1528
  <div class="nav-item-edit">
@@ -1064,6 +1682,414 @@ export class JantNavManager extends LitElement {
1064
1682
  `;
1065
1683
  }
1066
1684
 
1685
+ #renderAddPageSection() {
1686
+ return html`
1687
+ <section class="mt-8">
1688
+ <h2 class="text-lg font-semibold mb-1">
1689
+ ${this.labels.addPageToNavigation}
1690
+ </h2>
1691
+ <p class="text-sm text-muted-foreground mb-3">
1692
+ ${this.labels.addPageDescription}
1693
+ </p>
1694
+ <button
1695
+ type="button"
1696
+ class="btn-outline"
1697
+ data-add-page-trigger
1698
+ @click=${() => void this.#openPageDialog()}
1699
+ >
1700
+ ${this.labels.addPage}
1701
+ </button>
1702
+ </section>
1703
+ `;
1704
+ }
1705
+
1706
+ #renderPagePicker() {
1707
+ const status = this._pageSearchLoading
1708
+ ? this.labels.searchingPages
1709
+ : this._pageSearchError
1710
+ ? this.labels.pageSearchFailed
1711
+ : this._pages.length === 0
1712
+ ? this._pageQuery.trim()
1713
+ ? this.labels.noMatchingPages
1714
+ : this.labels.noPages
1715
+ : "";
1716
+
1717
+ return html`
1718
+ <header>
1719
+ <h2 id="nav-page-dialog-title">${this.labels.addPage}</h2>
1720
+ <p>${this.labels.addPageDescription}</p>
1721
+ </header>
1722
+ <section class="nav-page-picker-section">
1723
+ <div class="nav-page-search-wrap">
1724
+ <svg
1725
+ xmlns="http://www.w3.org/2000/svg"
1726
+ width="16"
1727
+ height="16"
1728
+ viewBox="0 0 24 24"
1729
+ fill="none"
1730
+ stroke="currentColor"
1731
+ stroke-width="2"
1732
+ stroke-linecap="round"
1733
+ stroke-linejoin="round"
1734
+ aria-hidden="true"
1735
+ >
1736
+ <circle cx="11" cy="11" r="8" />
1737
+ <path d="m21 21-4.3-4.3" />
1738
+ </svg>
1739
+ <input
1740
+ id="nav-page-search"
1741
+ type="search"
1742
+ class="input nav-page-search-input"
1743
+ placeholder=${this.labels.searchPages}
1744
+ autocomplete="off"
1745
+ role="combobox"
1746
+ aria-expanded="true"
1747
+ aria-controls="nav-page-results"
1748
+ aria-activedescendant=${this._pages.length > 0
1749
+ ? `nav-page-result-${this._selectedPageIndex}`
1750
+ : ""}
1751
+ .value=${this._pageQuery}
1752
+ @input=${(event: Event) => {
1753
+ this._pageQuery = (event.target as HTMLInputElement).value;
1754
+ this._selectedPageIndex = 0;
1755
+ this.#schedulePageSearch();
1756
+ }}
1757
+ />
1758
+ </div>
1759
+
1760
+ ${this._pages.length > 0
1761
+ ? html`
1762
+ <p class="nav-page-results-label">
1763
+ ${this._pageQuery.trim()
1764
+ ? this.labels.searchPages
1765
+ : this.labels.recentPages}
1766
+ </p>
1767
+ <div
1768
+ id="nav-page-results"
1769
+ class="nav-page-results"
1770
+ role="listbox"
1771
+ >
1772
+ ${this._pages.map((page, index) => {
1773
+ const selected = index === this._selectedPageIndex;
1774
+ const adding = this._addingPageId === page.id;
1775
+ return html`
1776
+ <button
1777
+ id=${`nav-page-result-${index}`}
1778
+ type="button"
1779
+ role="option"
1780
+ aria-selected=${selected ? "true" : "false"}
1781
+ class=${`nav-page-result${
1782
+ selected ? " nav-page-result-selected" : ""
1783
+ }`}
1784
+ ?disabled=${this._addingPageId !== null}
1785
+ @mouseenter=${() => {
1786
+ this._selectedPageIndex = index;
1787
+ }}
1788
+ @click=${() => void this.#addPageToNavigation(page)}
1789
+ >
1790
+ <span class="nav-page-result-copy">
1791
+ <span class="nav-page-result-title">${page.title}</span>
1792
+ <span class="nav-page-result-path"
1793
+ >${publicPath(`/${page.slug}`)}</span
1794
+ >
1795
+ </span>
1796
+ ${adding
1797
+ ? html`<span
1798
+ class="nav-page-spinner"
1799
+ aria-hidden="true"
1800
+ ></span>`
1801
+ : html`
1802
+ <svg
1803
+ xmlns="http://www.w3.org/2000/svg"
1804
+ width="16"
1805
+ height="16"
1806
+ viewBox="0 0 24 24"
1807
+ fill="none"
1808
+ stroke="currentColor"
1809
+ stroke-width="2"
1810
+ stroke-linecap="round"
1811
+ stroke-linejoin="round"
1812
+ aria-hidden="true"
1813
+ >
1814
+ <path d="M12 5v14" />
1815
+ <path d="M5 12h14" />
1816
+ </svg>
1817
+ `}
1818
+ </button>
1819
+ `;
1820
+ })}
1821
+ </div>
1822
+ `
1823
+ : html`<p class="nav-page-status" role="status">${status}</p>`}
1824
+ </section>
1825
+ <footer class="nav-page-dialog-footer">
1826
+ <button
1827
+ type="button"
1828
+ class="btn-outline nav-page-create-link"
1829
+ @click=${this.#showCreatePage}
1830
+ >
1831
+ <svg
1832
+ xmlns="http://www.w3.org/2000/svg"
1833
+ width="16"
1834
+ height="16"
1835
+ viewBox="0 0 24 24"
1836
+ fill="none"
1837
+ stroke="currentColor"
1838
+ stroke-width="2"
1839
+ stroke-linecap="round"
1840
+ stroke-linejoin="round"
1841
+ aria-hidden="true"
1842
+ >
1843
+ <path d="M12 5v14" />
1844
+ <path d="M5 12h14" />
1845
+ </svg>
1846
+ ${this.labels.createNewPage}
1847
+ </button>
1848
+ <button type="button" class="btn-ghost" @click=${this.#closePageDialog}>
1849
+ ${this.labels.cancel}
1850
+ </button>
1851
+ </footer>
1852
+ `;
1853
+ }
1854
+
1855
+ #renderCreatePage() {
1856
+ const slugError = this.#pageSlugError();
1857
+ const pagePath = this._newPageSlug.trim()
1858
+ ? publicPath(`/${this._newPageSlug.trim()}`)
1859
+ : publicPath("/");
1860
+
1861
+ return html`
1862
+ <header>
1863
+ <h2 id="nav-page-dialog-title">${this.labels.createPage}</h2>
1864
+ <p>${this.labels.createPageDescription}</p>
1865
+ </header>
1866
+ <section>
1867
+ <form
1868
+ id="nav-create-page-form"
1869
+ class="form grid gap-4"
1870
+ @submit=${(event: Event) => {
1871
+ event.preventDefault();
1872
+ void this.#createPage();
1873
+ }}
1874
+ >
1875
+ <div class="field">
1876
+ <label class="label" for="nav-new-page-title"
1877
+ >${this.labels.pageTitle}</label
1878
+ >
1879
+ <input
1880
+ id="nav-new-page-title"
1881
+ type="text"
1882
+ class="input"
1883
+ required
1884
+ maxlength="300"
1885
+ autocomplete="off"
1886
+ .value=${this._newPageTitle}
1887
+ @input=${(event: Event) => {
1888
+ this._newPageTitle = (event.target as HTMLInputElement).value;
1889
+ this._createPageError = "";
1890
+ this.#scheduleSlugSuggestion();
1891
+ }}
1892
+ />
1893
+ </div>
1894
+ <div class="field">
1895
+ <label class="label" for="nav-new-page-slug"
1896
+ >${this.labels.pageAddress}</label
1897
+ >
1898
+ <div class="nav-page-slug-field">
1899
+ <span class="nav-page-slug-prefix" aria-hidden="true"
1900
+ >${publicPath("/")}</span
1901
+ >
1902
+ <input
1903
+ id="nav-new-page-slug"
1904
+ type="text"
1905
+ class="input nav-page-slug-input"
1906
+ maxlength="200"
1907
+ autocomplete="off"
1908
+ spellcheck="false"
1909
+ .value=${this._newPageSlug}
1910
+ @input=${(event: Event) => {
1911
+ this._newPageSlug = (
1912
+ event.target as HTMLInputElement
1913
+ ).value.toLowerCase();
1914
+ this._slugEdited = true;
1915
+ this._createPageError = "";
1916
+ this.#scheduleSlugCheck();
1917
+ }}
1918
+ aria-invalid=${slugError ? "true" : "false"}
1919
+ aria-describedby="nav-new-page-slug-help"
1920
+ />
1921
+ </div>
1922
+ <p
1923
+ id="nav-new-page-slug-help"
1924
+ class=${slugError
1925
+ ? "text-xs text-destructive"
1926
+ : "text-xs text-muted-foreground"}
1927
+ >
1928
+ ${slugError
1929
+ ? slugError
1930
+ : this._slugCheckLoading
1931
+ ? this.labels.checkingAddress
1932
+ : pagePath}
1933
+ </p>
1934
+ </div>
1935
+ <p class="nav-page-visibility-note">
1936
+ <svg
1937
+ xmlns="http://www.w3.org/2000/svg"
1938
+ width="15"
1939
+ height="15"
1940
+ viewBox="0 0 24 24"
1941
+ fill="none"
1942
+ stroke="currentColor"
1943
+ stroke-width="2"
1944
+ stroke-linecap="round"
1945
+ stroke-linejoin="round"
1946
+ aria-hidden="true"
1947
+ >
1948
+ <circle cx="12" cy="12" r="10" />
1949
+ <path d="M12 16v-4" />
1950
+ <path d="M12 8h.01" />
1951
+ </svg>
1952
+ ${this.labels.pageVisibilityHint}
1953
+ </p>
1954
+ ${this._createPageError
1955
+ ? html`<p class="text-sm text-destructive" role="alert">
1956
+ ${this._createPageError}
1957
+ </p>`
1958
+ : nothing}
1959
+ </form>
1960
+ </section>
1961
+ <footer>
1962
+ <button
1963
+ type="button"
1964
+ class="btn-ghost"
1965
+ ?disabled=${this._creatingPage}
1966
+ @click=${this.#showPagePicker}
1967
+ >
1968
+ ${this.labels.back}
1969
+ </button>
1970
+ <button
1971
+ type="submit"
1972
+ class="btn"
1973
+ form="nav-create-page-form"
1974
+ ?disabled=${this._creatingPage ||
1975
+ this._slugCheckLoading ||
1976
+ Boolean(slugError)}
1977
+ >
1978
+ ${this._creatingPage
1979
+ ? this.labels.creatingPage
1980
+ : this.labels.createPage}
1981
+ </button>
1982
+ </footer>
1983
+ `;
1984
+ }
1985
+
1986
+ #renderCreatedPage() {
1987
+ const page = this._createdPage;
1988
+ if (!page) return nothing;
1989
+ const editHref = `${publicPath(`/${page.slug}`)}?edit=1`;
1990
+
1991
+ return html`
1992
+ <header class="nav-create-success-header">
1993
+ <span class="nav-create-success-icon" aria-hidden="true">
1994
+ <svg
1995
+ xmlns="http://www.w3.org/2000/svg"
1996
+ width="22"
1997
+ height="22"
1998
+ viewBox="0 0 24 24"
1999
+ fill="none"
2000
+ stroke="currentColor"
2001
+ stroke-width="2"
2002
+ stroke-linecap="round"
2003
+ stroke-linejoin="round"
2004
+ >
2005
+ <path d="M20 6 9 17l-5-5" />
2006
+ </svg>
2007
+ </span>
2008
+ <h2 id="nav-page-dialog-title">${this.labels.pageCreated}</h2>
2009
+ <p>${this.labels.pageCreatedDescription}</p>
2010
+ </header>
2011
+ <section>
2012
+ <div class="nav-create-success-card">
2013
+ <span class="nav-page-result-title">${page.title}</span>
2014
+ <span class="nav-page-result-path"
2015
+ >${publicPath(`/${page.slug}`)}</span
2016
+ >
2017
+ </div>
2018
+ </section>
2019
+ <footer>
2020
+ <a
2021
+ class="btn-outline"
2022
+ href=${editHref}
2023
+ target="_blank"
2024
+ rel="noopener noreferrer"
2025
+ >
2026
+ ${this.labels.editPage}
2027
+ <span aria-hidden="true">↗</span>
2028
+ </a>
2029
+ <button
2030
+ type="button"
2031
+ class="btn"
2032
+ ?disabled=${this._addingPageId !== null}
2033
+ @click=${() => void this.#addPageToNavigation(page)}
2034
+ >
2035
+ ${this.labels.addToNavigation}
2036
+ </button>
2037
+ </footer>
2038
+ `;
2039
+ }
2040
+
2041
+ #renderPageDialog() {
2042
+ if (!this._pageDialogOpen) return nothing;
2043
+
2044
+ return html`
2045
+ <dialog
2046
+ id="nav-page-dialog"
2047
+ class="dialog nav-page-dialog"
2048
+ aria-labelledby="nav-page-dialog-title"
2049
+ @cancel=${(event: Event) => {
2050
+ event.preventDefault();
2051
+ this.#closePageDialog();
2052
+ }}
2053
+ @click=${(event: Event) => {
2054
+ if (event.target === event.currentTarget) this.#closePageDialog();
2055
+ }}
2056
+ @keydown=${this.#handlePageDialogKeydown}
2057
+ >
2058
+ <div
2059
+ class="nav-page-dialog-panel"
2060
+ @click=${(event: Event) => event.stopPropagation()}
2061
+ >
2062
+ <button
2063
+ type="button"
2064
+ aria-label=${this.labels.cancel}
2065
+ @click=${this.#closePageDialog}
2066
+ >
2067
+ <svg
2068
+ xmlns="http://www.w3.org/2000/svg"
2069
+ width="18"
2070
+ height="18"
2071
+ viewBox="0 0 24 24"
2072
+ fill="none"
2073
+ stroke="currentColor"
2074
+ stroke-width="2"
2075
+ stroke-linecap="round"
2076
+ stroke-linejoin="round"
2077
+ aria-hidden="true"
2078
+ >
2079
+ <path d="M18 6 6 18" />
2080
+ <path d="m6 6 12 12" />
2081
+ </svg>
2082
+ </button>
2083
+ ${this._pageDialogView === "picker"
2084
+ ? this.#renderPagePicker()
2085
+ : this._pageDialogView === "create"
2086
+ ? this.#renderCreatePage()
2087
+ : this.#renderCreatedPage()}
2088
+ </div>
2089
+ </dialog>
2090
+ `;
2091
+ }
2092
+
1067
2093
  #renderAddLinkSection() {
1068
2094
  return html`
1069
2095
  <section class="mt-8">
@@ -1175,9 +2201,17 @@ export class JantNavManager extends LitElement {
1175
2201
  <h2 class="text-lg font-semibold mb-1">
1176
2202
  ${this.labels.addCollectionToNavigation}
1177
2203
  </h2>
1178
- <p class="text-sm text-muted-foreground">
2204
+ <p class="text-sm text-muted-foreground mb-3">
1179
2205
  ${this.labels.noCollections}
1180
2206
  </p>
2207
+ <button
2208
+ type="button"
2209
+ class="btn-outline"
2210
+ data-add-collection-trigger
2211
+ @click=${() => void this.#openCollectionDialog()}
2212
+ >
2213
+ ${this.labels.createCollection}
2214
+ </button>
1181
2215
  </section>
1182
2216
  `;
1183
2217
  }
@@ -1197,9 +2231,17 @@ export class JantNavManager extends LitElement {
1197
2231
  <h2 class="text-lg font-semibold mb-1">
1198
2232
  ${this.labels.addCollectionToNavigation}
1199
2233
  </h2>
1200
- <p class="text-sm text-muted-foreground">
2234
+ <p class="text-sm text-muted-foreground mb-3">
1201
2235
  ${this.labels.allCollectionsAdded}
1202
2236
  </p>
2237
+ <button
2238
+ type="button"
2239
+ class="btn-outline"
2240
+ data-add-collection-trigger
2241
+ @click=${() => void this.#openCollectionDialog()}
2242
+ >
2243
+ ${this.labels.createCollection}
2244
+ </button>
1203
2245
  </section>
1204
2246
  `;
1205
2247
  }
@@ -1226,15 +2268,21 @@ export class JantNavManager extends LitElement {
1226
2268
  <p class="text-sm text-muted-foreground mb-3">
1227
2269
  ${this.labels.addCollectionDescription}
1228
2270
  </p>
1229
- <div class="relative inline-block">
2271
+ <div
2272
+ class="relative inline-block"
2273
+ @keydown=${this.#handleCollectionPickerKeydown}
2274
+ >
1230
2275
  <button
1231
2276
  type="button"
1232
2277
  aria-expanded=${this._showCollectionPicker}
1233
2278
  aria-haspopup="menu"
1234
2279
  class="btn-outline"
2280
+ data-add-collection-trigger
1235
2281
  @click=${(e: Event) => {
1236
2282
  e.stopPropagation();
1237
- this._showCollectionPicker = !this._showCollectionPicker;
2283
+ const opening = !this._showCollectionPicker;
2284
+ if (opening) this.#closeLinkForm();
2285
+ this._showCollectionPicker = opening;
1238
2286
  if (this._showCollectionPicker) {
1239
2287
  setTimeout(() => {
1240
2288
  document.addEventListener(
@@ -1319,6 +2367,18 @@ export class JantNavManager extends LitElement {
1319
2367
  })}
1320
2368
  `,
1321
2369
  )}
2370
+ <div class="collection-picker-footer">
2371
+ <button
2372
+ type="button"
2373
+ role="menuitem"
2374
+ class="collection-picker-create"
2375
+ data-create-collection-trigger
2376
+ @click=${() => void this.#openCollectionDialog()}
2377
+ >
2378
+ ${this.labels.createNewCollection}
2379
+ <span aria-hidden="true">+</span>
2380
+ </button>
2381
+ </div>
1322
2382
  </div>
1323
2383
  `
1324
2384
  : nothing}
@@ -1327,6 +2387,173 @@ export class JantNavManager extends LitElement {
1327
2387
  `;
1328
2388
  }
1329
2389
 
2390
+ #renderCreateCollection() {
2391
+ const initial = {
2392
+ title: "",
2393
+ slug: "",
2394
+ description: "",
2395
+ sortOrder: "newest" as const,
2396
+ };
2397
+
2398
+ return html`
2399
+ <header>
2400
+ <h2 id="nav-collection-dialog-title">
2401
+ ${this.labels.createCollection}
2402
+ </h2>
2403
+ <p>${this.labels.collectionFormLabels.quickHint}</p>
2404
+ </header>
2405
+ <section>
2406
+ <jant-collection-form
2407
+ variant="quick"
2408
+ .labels=${this.labels.collectionFormLabels}
2409
+ .initial=${initial}
2410
+ action=${publicPath("/api/collections")}
2411
+ cancel-href=${publicPath(NAVIGATION_SETTINGS_PATH)}
2412
+ @jant:collection-submit=${(event: Event) =>
2413
+ void this.#handleCreateCollectionSubmit(event)}
2414
+ ></jant-collection-form>
2415
+ ${this._createCollectionError
2416
+ ? html`<p class="mt-3 text-sm text-destructive" role="alert">
2417
+ ${this._createCollectionError}
2418
+ </p>`
2419
+ : nothing}
2420
+ </section>
2421
+ <footer>
2422
+ <button
2423
+ type="button"
2424
+ class="btn-ghost"
2425
+ ?disabled=${this._creatingCollection}
2426
+ @click=${this.#closeCollectionDialog}
2427
+ >
2428
+ ${this.labels.cancel}
2429
+ </button>
2430
+ <button
2431
+ type="button"
2432
+ class="btn"
2433
+ ?disabled=${this._creatingCollection}
2434
+ @click=${this.#submitCollectionForm}
2435
+ >
2436
+ ${this._creatingCollection
2437
+ ? this.labels.creatingCollection
2438
+ : this.labels.createCollection}
2439
+ </button>
2440
+ </footer>
2441
+ `;
2442
+ }
2443
+
2444
+ #renderCreatedCollection() {
2445
+ const collection = this._createdCollection;
2446
+ if (!collection) return nothing;
2447
+
2448
+ const returnTo = publicPath(NAVIGATION_SETTINGS_PATH);
2449
+ const editHref = `${publicPath(
2450
+ getCollectionEditPath(collection.slug),
2451
+ )}?returnTo=${encodeURIComponent(returnTo)}`;
2452
+
2453
+ return html`
2454
+ <header class="nav-create-success-header">
2455
+ <span class="nav-create-success-icon" aria-hidden="true">
2456
+ <svg
2457
+ xmlns="http://www.w3.org/2000/svg"
2458
+ width="22"
2459
+ height="22"
2460
+ viewBox="0 0 24 24"
2461
+ fill="none"
2462
+ stroke="currentColor"
2463
+ stroke-width="2"
2464
+ stroke-linecap="round"
2465
+ stroke-linejoin="round"
2466
+ >
2467
+ <path d="M20 6 9 17l-5-5" />
2468
+ </svg>
2469
+ </span>
2470
+ <h2 id="nav-collection-dialog-title">
2471
+ ${this.labels.collectionFormLabels.createdLabel}
2472
+ </h2>
2473
+ <p>${this.labels.collectionCreatedDescription}</p>
2474
+ </header>
2475
+ <section>
2476
+ <div class="nav-create-success-card">
2477
+ <span class="nav-page-result-title">${collection.title}</span>
2478
+ <span class="nav-page-result-path">
2479
+ ${publicPath(getCollectionPagePath(collection.slug))}
2480
+ </span>
2481
+ </div>
2482
+ </section>
2483
+ <footer>
2484
+ <a
2485
+ class="btn-outline"
2486
+ href=${editHref}
2487
+ target="_blank"
2488
+ rel="noopener noreferrer"
2489
+ >
2490
+ ${this.labels.editCollection}
2491
+ <span aria-hidden="true">↗</span>
2492
+ </a>
2493
+ <button
2494
+ type="button"
2495
+ class="btn"
2496
+ ?disabled=${this._addingCollectionId !== null}
2497
+ @click=${() => void this.#handleAddCollection(collection.id)}
2498
+ >
2499
+ ${this.labels.addToNavigation}
2500
+ </button>
2501
+ </footer>
2502
+ `;
2503
+ }
2504
+
2505
+ #renderCollectionDialog() {
2506
+ if (!this._collectionDialogOpen) return nothing;
2507
+
2508
+ return html`
2509
+ <dialog
2510
+ id="nav-collection-dialog"
2511
+ class="dialog nav-collection-dialog"
2512
+ aria-labelledby="nav-collection-dialog-title"
2513
+ @cancel=${(event: Event) => {
2514
+ event.preventDefault();
2515
+ this.#closeCollectionDialog();
2516
+ }}
2517
+ @click=${(event: Event) => {
2518
+ if (event.target === event.currentTarget) {
2519
+ this.#closeCollectionDialog();
2520
+ }
2521
+ }}
2522
+ @keydown=${this.#handleCollectionDialogKeydown}
2523
+ >
2524
+ <div
2525
+ class="nav-collection-dialog-panel"
2526
+ @click=${(event: Event) => event.stopPropagation()}
2527
+ >
2528
+ <button
2529
+ type="button"
2530
+ aria-label=${this.labels.cancel}
2531
+ @click=${this.#closeCollectionDialog}
2532
+ >
2533
+ <svg
2534
+ xmlns="http://www.w3.org/2000/svg"
2535
+ width="18"
2536
+ height="18"
2537
+ viewBox="0 0 24 24"
2538
+ fill="none"
2539
+ stroke="currentColor"
2540
+ stroke-width="2"
2541
+ stroke-linecap="round"
2542
+ stroke-linejoin="round"
2543
+ aria-hidden="true"
2544
+ >
2545
+ <path d="M18 6 6 18" />
2546
+ <path d="m6 6 12 12" />
2547
+ </svg>
2548
+ </button>
2549
+ ${this._collectionDialogView === "create"
2550
+ ? this.#renderCreateCollection()
2551
+ : this.#renderCreatedCollection()}
2552
+ </div>
2553
+ </dialog>
2554
+ `;
2555
+ }
2556
+
1330
2557
  #renderSystemToggles() {
1331
2558
  if (!this.systemNavItems?.length) return nothing;
1332
2559
 
@@ -1405,8 +2632,10 @@ export class JantNavManager extends LitElement {
1405
2632
  </div>
1406
2633
  </section>
1407
2634
 
1408
- ${this.#renderSuggestedLinksSection()} ${this.#renderAddLinkSection()}
1409
- ${this.#renderAddCollectionSection()} ${this.#renderSystemToggles()}
2635
+ ${this.#renderSuggestedLinksSection()} ${this.#renderAddPageSection()}
2636
+ ${this.#renderAddCollectionSection()} ${this.#renderAddLinkSection()}
2637
+ ${this.#renderSystemToggles()} ${this.#renderPageDialog()}
2638
+ ${this.#renderCollectionDialog()}
1410
2639
  `;
1411
2640
  }
1412
2641
  }