@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
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Searchable, inline editor for explicitly approved runtime settings.
3
+ */
4
+
5
+ import { LitElement, html, nothing } from "lit";
6
+ import { getBestFieldSearchRank, normalizeSearch } from "../search-rank.js";
7
+ import {
8
+ getJsonString,
9
+ isJsonObject,
10
+ readErrorMessage,
11
+ readJsonObject,
12
+ } from "../json.js";
13
+
14
+ type ConfigEditorValueType = "boolean" | "string" | "number" | "enum";
15
+
16
+ interface ConfigEditorItem {
17
+ key: string;
18
+ mode: "edit" | "link";
19
+ type: ConfigEditorValueType;
20
+ value: string;
21
+ fallbackValue: string;
22
+ modified: boolean;
23
+ locked: boolean;
24
+ description: string;
25
+ maxLength?: number;
26
+ min?: number;
27
+ max?: number;
28
+ step?: number;
29
+ options?: string[];
30
+ optionLabels?: Record<string, string>;
31
+ settingsPath?: string;
32
+ display?: "value" | "configured";
33
+ resettable?: boolean;
34
+ fallbackKey?: string;
35
+ }
36
+
37
+ interface ConfigEditorLabels {
38
+ search: string;
39
+ modifiedOnly: string;
40
+ modified: string;
41
+ locked: string;
42
+ reset: string;
43
+ saving: string;
44
+ saved: string;
45
+ saveError: string;
46
+ noMatches: string;
47
+ countTemplate: string;
48
+ openSetting: string;
49
+ configured: string;
50
+ notConfigured: string;
51
+ }
52
+
53
+ type RowStatus =
54
+ | { state: "saving" }
55
+ | { state: "saved" }
56
+ | { state: "error"; message: string };
57
+
58
+ const DEFAULT_LABELS: ConfigEditorLabels = {
59
+ search: "Search settings",
60
+ modifiedOnly: "Show only modified",
61
+ modified: "Modified",
62
+ locked: "Locked",
63
+ reset: "Reset to default",
64
+ saving: "Saving…",
65
+ saved: "Saved",
66
+ saveError: "This setting wasn't saved. Check the value and try again.",
67
+ noMatches:
68
+ "Nothing matches this search. Try a different name or description.",
69
+ countTemplate: "{count} settings shown",
70
+ openSetting: "Open setting",
71
+ configured: "Configured",
72
+ notConfigured: "Not configured",
73
+ };
74
+
75
+ function parseStringRecord(value: unknown): Record<string, string> | undefined {
76
+ if (!isJsonObject(value)) return undefined;
77
+ const result: Record<string, string> = {};
78
+ for (const [key, entry] of Object.entries(value)) {
79
+ if (typeof entry !== "string") return undefined;
80
+ result[key] = entry;
81
+ }
82
+ return result;
83
+ }
84
+
85
+ function parseItem(value: unknown): ConfigEditorItem | null {
86
+ if (!isJsonObject(value)) return null;
87
+ const key = getJsonString(value, "key");
88
+ const mode = getJsonString(value, "mode");
89
+ const type = getJsonString(value, "type");
90
+ const currentValue = getJsonString(value, "value");
91
+ const fallbackValue = getJsonString(value, "fallbackValue");
92
+ const description = getJsonString(value, "description");
93
+ const settingsPath = getJsonString(value, "settingsPath");
94
+ const display = getJsonString(value, "display");
95
+ const fallbackKey = getJsonString(value, "fallbackKey");
96
+
97
+ if (
98
+ !key ||
99
+ (mode !== "edit" && mode !== "link") ||
100
+ (type !== "boolean" &&
101
+ type !== "string" &&
102
+ type !== "number" &&
103
+ type !== "enum") ||
104
+ currentValue === undefined ||
105
+ fallbackValue === undefined ||
106
+ description === undefined ||
107
+ typeof value.modified !== "boolean" ||
108
+ typeof value.locked !== "boolean"
109
+ ) {
110
+ return null;
111
+ }
112
+ if (
113
+ mode === "link" &&
114
+ (!settingsPath || (display !== "value" && display !== "configured"))
115
+ ) {
116
+ return null;
117
+ }
118
+
119
+ const options = Array.isArray(value.options)
120
+ ? value.options.filter(
121
+ (option): option is string => typeof option === "string",
122
+ )
123
+ : undefined;
124
+ if (
125
+ mode === "edit" &&
126
+ type === "enum" &&
127
+ (!options || options.length === 0)
128
+ ) {
129
+ return null;
130
+ }
131
+ const optionLabels = parseStringRecord(value.optionLabels);
132
+
133
+ return {
134
+ key,
135
+ mode,
136
+ type,
137
+ value: currentValue,
138
+ fallbackValue,
139
+ modified: value.modified,
140
+ locked: value.locked,
141
+ description,
142
+ ...(typeof value.maxLength === "number" && {
143
+ maxLength: value.maxLength,
144
+ }),
145
+ ...(typeof value.min === "number" && { min: value.min }),
146
+ ...(typeof value.max === "number" && { max: value.max }),
147
+ ...(typeof value.step === "number" && { step: value.step }),
148
+ ...(options && { options }),
149
+ ...(optionLabels && { optionLabels }),
150
+ ...(settingsPath && { settingsPath }),
151
+ ...(display === "value" || display === "configured" ? { display } : {}),
152
+ ...(value.resettable === true && { resettable: true }),
153
+ ...(fallbackKey && { fallbackKey }),
154
+ };
155
+ }
156
+
157
+ function parseItems(value: string): ConfigEditorItem[] {
158
+ try {
159
+ const parsed: unknown = JSON.parse(value);
160
+ if (!isJsonObject(parsed) || !Array.isArray(parsed.fields)) return [];
161
+ return parsed.fields
162
+ .map(parseItem)
163
+ .filter((item): item is ConfigEditorItem => item !== null);
164
+ } catch {
165
+ return [];
166
+ }
167
+ }
168
+
169
+ function parseLabels(value: string): ConfigEditorLabels {
170
+ try {
171
+ const parsed: unknown = JSON.parse(value);
172
+ if (!isJsonObject(parsed)) return DEFAULT_LABELS;
173
+ const labels = { ...DEFAULT_LABELS };
174
+ for (const key of Object.keys(labels) as Array<keyof ConfigEditorLabels>) {
175
+ const entry = parsed[key];
176
+ if (typeof entry === "string" && entry) labels[key] = entry;
177
+ }
178
+ return labels;
179
+ } catch {
180
+ return DEFAULT_LABELS;
181
+ }
182
+ }
183
+
184
+ export class JantConfigEditor extends LitElement {
185
+ static properties = {
186
+ endpoint: { type: String },
187
+ initialData: { type: String, attribute: "initial-data" },
188
+ labelsJson: { type: String, attribute: "labels" },
189
+ _items: { state: true },
190
+ _labels: { state: true },
191
+ _query: { state: true },
192
+ _modifiedOnly: { state: true },
193
+ _drafts: { state: true },
194
+ _pending: { state: true },
195
+ _statuses: { state: true },
196
+ };
197
+
198
+ declare endpoint: string;
199
+ declare initialData: string;
200
+ declare labelsJson: string;
201
+ declare _items: ConfigEditorItem[];
202
+ declare _labels: ConfigEditorLabels;
203
+ declare _query: string;
204
+ declare _modifiedOnly: boolean;
205
+ declare _drafts: Record<string, string>;
206
+ declare _pending: Record<string, boolean>;
207
+ declare _statuses: Record<string, RowStatus | undefined>;
208
+
209
+ private readonly _statusTimeouts = new Map<string, number>();
210
+
211
+ createRenderRoot() {
212
+ return this;
213
+ }
214
+
215
+ constructor() {
216
+ super();
217
+ this.endpoint = "/api/settings";
218
+ this.initialData = "";
219
+ this.labelsJson = "";
220
+ this._items = [];
221
+ this._labels = DEFAULT_LABELS;
222
+ this._query = "";
223
+ this._modifiedOnly = false;
224
+ this._drafts = {};
225
+ this._pending = {};
226
+ this._statuses = {};
227
+ }
228
+
229
+ connectedCallback() {
230
+ super.connectedCallback();
231
+ // Keep the server-rendered list useful without JavaScript, then remove it
232
+ // once the interactive light-DOM editor is ready to take over.
233
+ this.querySelector(":scope > .config-editor-fallback")?.remove();
234
+ const initialData = this.getAttribute("initial-data") ?? this.initialData;
235
+ const labels = this.getAttribute("labels") ?? this.labelsJson;
236
+ this._items = parseItems(initialData);
237
+ this._labels = parseLabels(labels);
238
+ this._drafts = Object.fromEntries(
239
+ this._items.map((item) => [item.key, item.value]),
240
+ );
241
+ }
242
+
243
+ disconnectedCallback() {
244
+ for (const timeout of this._statusTimeouts.values()) {
245
+ window.clearTimeout(timeout);
246
+ }
247
+ this._statusTimeouts.clear();
248
+ super.disconnectedCallback();
249
+ }
250
+
251
+ private _filteredItems(): ConfigEditorItem[] {
252
+ const query = normalizeSearch(this._query);
253
+ return this._items
254
+ .map((item, index) => ({
255
+ item,
256
+ index,
257
+ rank: query
258
+ ? getBestFieldSearchRank(
259
+ [item.key, item.description, item.type, item.value],
260
+ query,
261
+ )
262
+ : 0,
263
+ }))
264
+ .filter(
265
+ (entry) =>
266
+ entry.rank !== null && (!this._modifiedOnly || entry.item.modified),
267
+ )
268
+ .sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0) || a.index - b.index)
269
+ .map(({ item }) => item);
270
+ }
271
+
272
+ private _setDraft(key: string, value: string) {
273
+ this._drafts = { ...this._drafts, [key]: value };
274
+ if (this._statuses[key]) {
275
+ this._setStatus(key, undefined);
276
+ }
277
+ }
278
+
279
+ private _setPending(key: string, pending: boolean) {
280
+ this._pending = { ...this._pending, [key]: pending };
281
+ }
282
+
283
+ private _setStatus(key: string, status: RowStatus | undefined) {
284
+ const currentTimeout = this._statusTimeouts.get(key);
285
+ if (currentTimeout !== undefined) {
286
+ window.clearTimeout(currentTimeout);
287
+ this._statusTimeouts.delete(key);
288
+ }
289
+ this._statuses = { ...this._statuses, [key]: status };
290
+ if (status?.state === "saved") {
291
+ const timeout = window.setTimeout(() => {
292
+ if (this._statuses[key]?.state === "saved") {
293
+ this._statuses = { ...this._statuses, [key]: undefined };
294
+ }
295
+ this._statusTimeouts.delete(key);
296
+ }, 2000);
297
+ this._statusTimeouts.set(key, timeout);
298
+ }
299
+ }
300
+
301
+ private _replaceItem(
302
+ key: string,
303
+ update: (item: ConfigEditorItem) => ConfigEditorItem,
304
+ ) {
305
+ this._items = this._items.map((item) =>
306
+ item.key === key ? update(item) : item,
307
+ );
308
+ }
309
+
310
+ private _commitValue(key: string, value: string, modified: boolean) {
311
+ const drafts = { ...this._drafts, [key]: value };
312
+ this._items = this._items.map((item) => {
313
+ if (item.key === key) return { ...item, value, modified };
314
+ if (item.fallbackKey !== key) return item;
315
+
316
+ if (item.modified) {
317
+ return { ...item, fallbackValue: value };
318
+ }
319
+
320
+ drafts[item.key] = value;
321
+ return { ...item, value, fallbackValue: value };
322
+ });
323
+ this._drafts = drafts;
324
+ }
325
+
326
+ private async _save(item: ConfigEditorItem, value: string) {
327
+ if (item.locked || this._pending[item.key]) return;
328
+
329
+ const optimistic = item.type === "boolean" || item.type === "enum";
330
+ const previousValue = item.value;
331
+ if (optimistic) {
332
+ this._replaceItem(item.key, (current) => ({ ...current, value }));
333
+ }
334
+ this._setPending(item.key, true);
335
+ this._setStatus(item.key, { state: "saving" });
336
+
337
+ try {
338
+ const response = await fetch(this.endpoint, {
339
+ method: "PUT",
340
+ headers: {
341
+ "Content-Type": "application/json",
342
+ Accept: "application/json",
343
+ },
344
+ body: JSON.stringify({ [item.key]: value }),
345
+ });
346
+ if (!response.ok) {
347
+ throw new Error(
348
+ await readErrorMessage(response, this._labels.saveError),
349
+ );
350
+ }
351
+
352
+ const body = await readJsonObject(response);
353
+ const settings = body.settings;
354
+ const savedValue = getJsonString(settings, item.key) ?? value;
355
+ this._commitValue(item.key, savedValue, true);
356
+ this._setStatus(item.key, { state: "saved" });
357
+ } catch (error) {
358
+ if (optimistic) {
359
+ this._replaceItem(item.key, (current) => ({
360
+ ...current,
361
+ value: previousValue,
362
+ }));
363
+ }
364
+ this._setStatus(item.key, {
365
+ state: "error",
366
+ message:
367
+ error instanceof Error ? error.message : this._labels.saveError,
368
+ });
369
+ } finally {
370
+ this._setPending(item.key, false);
371
+ }
372
+ }
373
+
374
+ private async _reset(item: ConfigEditorItem) {
375
+ if (
376
+ !item.modified ||
377
+ item.locked ||
378
+ (item.mode === "link" && !item.resettable) ||
379
+ this._pending[item.key]
380
+ )
381
+ return;
382
+
383
+ this._setPending(item.key, true);
384
+ this._setStatus(item.key, { state: "saving" });
385
+
386
+ try {
387
+ const response = await fetch(
388
+ `${this.endpoint}/${encodeURIComponent(item.key)}`,
389
+ {
390
+ method: "DELETE",
391
+ headers: { Accept: "application/json" },
392
+ },
393
+ );
394
+ if (!response.ok) {
395
+ throw new Error(
396
+ await readErrorMessage(response, this._labels.saveError),
397
+ );
398
+ }
399
+
400
+ const body = await readJsonObject(response);
401
+ const resetSetting = isJsonObject(body.setting)
402
+ ? body.setting
403
+ : undefined;
404
+ const resetValue =
405
+ getJsonString(resetSetting, "value") ??
406
+ getJsonString(body.settings, item.key) ??
407
+ item.fallbackValue;
408
+ this._commitValue(item.key, resetValue, false);
409
+ this._setStatus(item.key, { state: "saved" });
410
+ } catch (error) {
411
+ this._setStatus(item.key, {
412
+ state: "error",
413
+ message:
414
+ error instanceof Error ? error.message : this._labels.saveError,
415
+ });
416
+ } finally {
417
+ this._setPending(item.key, false);
418
+ }
419
+ }
420
+
421
+ private _renderStatus(item: ConfigEditorItem) {
422
+ const status = this._statuses[item.key];
423
+ if (!status) return nothing;
424
+ if (status.state === "saving") {
425
+ return html`<span class="config-editor-status" role="status"
426
+ >${this._labels.saving}</span
427
+ >`;
428
+ }
429
+ if (status.state === "saved") {
430
+ return html`<span
431
+ class="config-editor-status config-editor-status-saved"
432
+ role="status"
433
+ >${this._labels.saved}</span
434
+ >`;
435
+ }
436
+ return html`<span
437
+ class="config-editor-status config-editor-status-error"
438
+ role="alert"
439
+ >${status.message}</span
440
+ >`;
441
+ }
442
+
443
+ private _renderControl(item: ConfigEditorItem) {
444
+ const id = `config-editor-${item.key.toLowerCase().replaceAll("_", "-")}`;
445
+ const pending = Boolean(this._pending[item.key]);
446
+ const disabled = pending || item.locked;
447
+
448
+ if (item.type === "boolean") {
449
+ return html`<input
450
+ id=${id}
451
+ name=${item.key}
452
+ type="checkbox"
453
+ role="switch"
454
+ class="input"
455
+ aria-describedby=${`${id}-description`}
456
+ .checked=${item.value === "true"}
457
+ ?disabled=${disabled}
458
+ @change=${(event: Event) =>
459
+ void this._save(
460
+ item,
461
+ String((event.target as HTMLInputElement).checked),
462
+ )}
463
+ />`;
464
+ }
465
+
466
+ if (item.type === "enum") {
467
+ return html`<select
468
+ id=${id}
469
+ name=${item.key}
470
+ class="select config-editor-select"
471
+ aria-describedby=${`${id}-description`}
472
+ .value=${item.value}
473
+ ?disabled=${disabled}
474
+ @change=${(event: Event) =>
475
+ void this._save(item, (event.target as HTMLSelectElement).value)}
476
+ >
477
+ ${(item.options ?? []).map(
478
+ (option) =>
479
+ html`<option value=${option} ?selected=${option === item.value}>
480
+ ${item.optionLabels?.[option] ?? option}
481
+ </option>`,
482
+ )}
483
+ </select>`;
484
+ }
485
+
486
+ const draft = this._drafts[item.key] ?? item.value;
487
+ const changed = draft !== item.value;
488
+ return html`<input
489
+ id=${id}
490
+ name=${item.key}
491
+ class="input config-editor-value-input"
492
+ type=${item.type === "number" ? "number" : "text"}
493
+ autocomplete="off"
494
+ spellcheck="false"
495
+ aria-describedby=${`${id}-description`}
496
+ .value=${draft}
497
+ maxlength=${item.maxLength ?? nothing}
498
+ min=${item.min ?? nothing}
499
+ max=${item.max ?? nothing}
500
+ step=${item.step ?? nothing}
501
+ ?disabled=${disabled}
502
+ @input=${(event: Event) =>
503
+ this._setDraft(item.key, (event.target as HTMLInputElement).value)}
504
+ @blur=${(event: Event) => {
505
+ const value = (event.target as HTMLInputElement).value;
506
+ if (value !== item.value) void this._save(item, value);
507
+ }}
508
+ @keydown=${(event: KeyboardEvent) => {
509
+ if (event.key === "Escape") {
510
+ event.preventDefault();
511
+ this._setDraft(item.key, item.value);
512
+ } else if (event.key === "Enter" && !event.isComposing && changed) {
513
+ event.preventDefault();
514
+ void this._save(item, draft);
515
+ }
516
+ }}
517
+ />`;
518
+ }
519
+
520
+ private _renderLinkedSetting(item: ConfigEditorItem) {
521
+ const value =
522
+ item.display === "configured"
523
+ ? item.value === "true"
524
+ ? this._labels.configured
525
+ : this._labels.notConfigured
526
+ : item.value;
527
+
528
+ return html`
529
+ <a
530
+ class="config-editor-open-control"
531
+ href=${item.settingsPath ?? "/settings"}
532
+ aria-describedby=${`config-editor-${item.key.toLowerCase().replaceAll("_", "-")}-description`}
533
+ >
534
+ <span class="config-editor-linked-value" translate="no">${value}</span>
535
+ <span class="config-editor-open-action">
536
+ ${this._labels.openSetting}
537
+ <svg
538
+ xmlns="http://www.w3.org/2000/svg"
539
+ width="15"
540
+ height="15"
541
+ viewBox="0 0 24 24"
542
+ fill="none"
543
+ stroke="currentColor"
544
+ stroke-width="2"
545
+ stroke-linecap="round"
546
+ stroke-linejoin="round"
547
+ aria-hidden="true"
548
+ >
549
+ <path d="M5 12h14" />
550
+ <path d="m13 6 6 6-6 6" />
551
+ </svg>
552
+ </span>
553
+ </a>
554
+ `;
555
+ }
556
+
557
+ private _canReset(item: ConfigEditorItem) {
558
+ return (
559
+ item.modified &&
560
+ !item.locked &&
561
+ (item.mode === "edit" || item.resettable === true)
562
+ );
563
+ }
564
+
565
+ private _renderRow(item: ConfigEditorItem) {
566
+ const id = `config-editor-${item.key.toLowerCase().replaceAll("_", "-")}`;
567
+ const controlRowClass = [
568
+ "config-editor-control-row",
569
+ item.mode === "edit" && item.type === "enum"
570
+ ? "config-editor-control-row-select"
571
+ : "",
572
+ item.mode === "edit" && item.type === "number"
573
+ ? "config-editor-control-row-number"
574
+ : "",
575
+ ]
576
+ .filter(Boolean)
577
+ .join(" ");
578
+ return html`
579
+ <li
580
+ class="config-editor-row"
581
+ data-modified=${String(item.modified)}
582
+ aria-busy=${String(Boolean(this._pending[item.key]))}
583
+ >
584
+ <div class="config-editor-copy">
585
+ <div class="config-editor-heading">
586
+ ${item.mode === "edit"
587
+ ? html`<label for=${id}
588
+ ><code class="config-editor-key" translate="no"
589
+ >${item.key}</code
590
+ ></label
591
+ >`
592
+ : html`<code class="config-editor-key" translate="no"
593
+ >${item.key}</code
594
+ >`}
595
+ ${item.modified
596
+ ? html`<span class="config-editor-state config-editor-modified"
597
+ >${this._labels.modified}</span
598
+ >`
599
+ : nothing}
600
+ ${item.locked
601
+ ? html`<span class="config-editor-state config-editor-locked"
602
+ >${this._labels.locked}</span
603
+ >`
604
+ : nothing}
605
+ </div>
606
+ <p class="config-editor-description" id=${`${id}-description`}>
607
+ ${item.description}
608
+ </p>
609
+ </div>
610
+ <div class="config-editor-value-column">
611
+ <div class=${controlRowClass}>
612
+ ${item.mode === "link"
613
+ ? this._renderLinkedSetting(item)
614
+ : this._renderControl(item)}
615
+ ${this._canReset(item)
616
+ ? html`<button
617
+ type="button"
618
+ class="config-editor-reset"
619
+ aria-label=${this._labels.reset}
620
+ title=${this._labels.reset}
621
+ ?disabled=${Boolean(this._pending[item.key])}
622
+ @click=${() => void this._reset(item)}
623
+ >
624
+ <svg
625
+ xmlns="http://www.w3.org/2000/svg"
626
+ width="15"
627
+ height="15"
628
+ viewBox="0 0 24 24"
629
+ fill="none"
630
+ stroke="currentColor"
631
+ stroke-width="2"
632
+ stroke-linecap="round"
633
+ stroke-linejoin="round"
634
+ aria-hidden="true"
635
+ >
636
+ <path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
637
+ <path d="M3 3v5h5" />
638
+ </svg>
639
+ </button>`
640
+ : nothing}
641
+ </div>
642
+ ${this._renderStatus(item)}
643
+ </div>
644
+ </li>
645
+ `;
646
+ }
647
+
648
+ render() {
649
+ const items = this._filteredItems();
650
+ const count = this._labels.countTemplate.replace(
651
+ "{count}",
652
+ String(items.length),
653
+ );
654
+
655
+ return html`
656
+ <div class="config-editor-toolbar">
657
+ <div class="config-editor-search">
658
+ <svg
659
+ xmlns="http://www.w3.org/2000/svg"
660
+ width="17"
661
+ height="17"
662
+ viewBox="0 0 24 24"
663
+ fill="none"
664
+ stroke="currentColor"
665
+ stroke-width="2"
666
+ stroke-linecap="round"
667
+ stroke-linejoin="round"
668
+ aria-hidden="true"
669
+ >
670
+ <circle cx="11" cy="11" r="8"></circle>
671
+ <path d="m21 21-4.3-4.3"></path>
672
+ </svg>
673
+ <input
674
+ type="search"
675
+ name="config-search"
676
+ class="input config-editor-search-input"
677
+ autocomplete="off"
678
+ spellcheck="false"
679
+ placeholder=${this._labels.search}
680
+ aria-label=${this._labels.search}
681
+ .value=${this._query}
682
+ @input=${(event: Event) => {
683
+ this._query = (event.target as HTMLInputElement).value;
684
+ }}
685
+ @keydown=${(event: KeyboardEvent) => {
686
+ if (event.key === "Escape" && this._query) {
687
+ event.preventDefault();
688
+ this._query = "";
689
+ }
690
+ }}
691
+ />
692
+ </div>
693
+ </div>
694
+ <div class="config-editor-toolbar-meta">
695
+ <p class="config-editor-count" aria-live="polite">${count}</p>
696
+ <label class="config-editor-modified-filter">
697
+ <input
698
+ type="checkbox"
699
+ name="modified-only"
700
+ class="input"
701
+ .checked=${this._modifiedOnly}
702
+ @change=${(event: Event) => {
703
+ this._modifiedOnly = (event.target as HTMLInputElement).checked;
704
+ }}
705
+ />
706
+ <span>${this._labels.modifiedOnly}</span>
707
+ </label>
708
+ </div>
709
+ ${items.length > 0
710
+ ? html`<ul class="config-editor-list">
711
+ ${items.map((item) => this._renderRow(item))}
712
+ </ul>`
713
+ : html`<div class="config-editor-empty" role="status">
714
+ <p>${this._labels.noMatches}</p>
715
+ </div>`}
716
+ `;
717
+ }
718
+ }
719
+
720
+ if (!customElements.get("jant-config-editor")) {
721
+ customElements.define("jant-config-editor", JantConfigEditor);
722
+ }