@jant/core 0.3.35 → 0.3.37

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 (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -0,0 +1,805 @@
1
+ /**
2
+ * Collection Sidebar Component
3
+ *
4
+ * Manages collections in the public /c page sidebar for authenticated users:
5
+ * - Renders sidebar items (collections + dividers) from a single pre-ordered list
6
+ * - Dropdown menus for "More" (reorder, add divider) and per-collection edit
7
+ * - SortableJS drag-and-drop reorder mode
8
+ * - Create/edit collection dialogs embedding <jant-collection-form>
9
+ * - Divider CRUD
10
+ *
11
+ * Anonymous users see a static list rendered server-side; this component
12
+ * is only instantiated for authenticated users.
13
+ *
14
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
15
+ */
16
+
17
+ import { LitElement, html, nothing } from "lit";
18
+ import type { PropertyValueMap } from "lit";
19
+ import { classMap } from "lit/directives/class-map.js";
20
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
21
+ import Sortable from "sortablejs";
22
+ import { showToast } from "../toast.js";
23
+ import { renderCollectionIcon } from "../../lib/icons.js";
24
+ import type { CollectionSubmitDetail } from "./collection-types.js";
25
+ import type {
26
+ CollectionSidebarLabels,
27
+ SidebarCollection,
28
+ ClientSidebarItem,
29
+ } from "./collection-sidebar-types.js";
30
+
31
+ export class JantCollectionSidebar extends LitElement {
32
+ static properties = {
33
+ "sidebar-items": { type: Array },
34
+ labels: { type: Object },
35
+ activeSlug: { type: String, attribute: "active-slug" },
36
+
37
+ _items: { state: true },
38
+ _reorderMode: { state: true },
39
+ _dialogMode: { state: true },
40
+ _editingCollection: { state: true },
41
+ _showMoreMenu: { state: true },
42
+ _hoveringId: { state: true },
43
+ _showItemMenuId: { state: true },
44
+ };
45
+
46
+ declare "sidebar-items": ClientSidebarItem[];
47
+ declare labels: CollectionSidebarLabels;
48
+ declare activeSlug: string;
49
+
50
+ declare _items: ClientSidebarItem[];
51
+ declare _reorderMode: boolean;
52
+ declare _dialogMode: "create" | "edit" | null;
53
+ declare _editingCollection: SidebarCollection | null;
54
+ declare _showMoreMenu: boolean;
55
+ declare _hoveringId: string | null;
56
+ declare _showItemMenuId: string | null;
57
+
58
+ #sortable: { destroy(): void } | null = null;
59
+ #initialized = false;
60
+ #revertNextSibling: Node | null = null;
61
+
62
+ #closeMoreMenu = () => {
63
+ this._showMoreMenu = false;
64
+ document.removeEventListener("click", this.#closeMoreMenu);
65
+ };
66
+
67
+ #closeItemMenu = () => {
68
+ this._showItemMenuId = null;
69
+ document.removeEventListener("click", this.#closeItemMenu);
70
+ };
71
+
72
+ createRenderRoot() {
73
+ this.innerHTML = "";
74
+ return this;
75
+ }
76
+
77
+ constructor() {
78
+ super();
79
+ this["sidebar-items"] = [];
80
+ this.labels = {} as CollectionSidebarLabels;
81
+ this.activeSlug = "";
82
+
83
+ this._items = [];
84
+ this._reorderMode = false;
85
+ this._dialogMode = null;
86
+ this._editingCollection = null;
87
+ this._showMoreMenu = false;
88
+ this._hoveringId = null;
89
+ this._showItemMenuId = null;
90
+ }
91
+
92
+ protected update(
93
+ changedProperties: PropertyValueMap<JantCollectionSidebar>,
94
+ ): void {
95
+ if (
96
+ !this.#initialized ||
97
+ changedProperties.has("sidebar-items" as keyof JantCollectionSidebar)
98
+ ) {
99
+ this._items = [...(this["sidebar-items"] ?? [])];
100
+ this.#initialized = true;
101
+ }
102
+ super.update(changedProperties);
103
+ }
104
+
105
+ disconnectedCallback() {
106
+ super.disconnectedCallback();
107
+ this.#sortable?.destroy();
108
+ this.#sortable = null;
109
+ document.removeEventListener("click", this.#closeMoreMenu);
110
+ document.removeEventListener("click", this.#closeItemMenu);
111
+ }
112
+
113
+ // ===========================================================================
114
+ // Data fetching
115
+ // ===========================================================================
116
+
117
+ async #refreshList() {
118
+ try {
119
+ const res = await fetch("/api/collections");
120
+ if (!res.ok) return;
121
+ const json = await res.json();
122
+
123
+ // Build collection lookup from response
124
+ const collectionMap = new Map<string, SidebarCollection>();
125
+ for (const col of json.collections) {
126
+ collectionMap.set(col.id, col);
127
+ }
128
+
129
+ // Map sidebar items, enriching with collection data
130
+ this._items = (json.sidebarItems as ClientSidebarItem[]).map((item) => ({
131
+ ...item,
132
+ collection: item.collectionId
133
+ ? collectionMap.get(item.collectionId)
134
+ : undefined,
135
+ }));
136
+ } catch {
137
+ // silent — stale list is acceptable
138
+ }
139
+ }
140
+
141
+ // ===========================================================================
142
+ // SortableJS
143
+ // ===========================================================================
144
+
145
+ #initSortable() {
146
+ const list = this.querySelector<HTMLElement>("#sidebar-collections-list");
147
+ if (!list || this.#sortable) return;
148
+
149
+ this.#sortable = Sortable.create(list, {
150
+ animation: 150,
151
+ handle: "[data-drag-handle]",
152
+ onStart: (evt) => {
153
+ // Capture the exact next sibling (including comment/marker nodes)
154
+ // so the DOM revert restores the element between the correct Lit markers.
155
+ this.#revertNextSibling = evt.item.nextSibling;
156
+ },
157
+ onEnd: (evt) => {
158
+ // Read new order from DOM BEFORE reverting
159
+ const els = [
160
+ ...list.querySelectorAll<HTMLElement>("[data-sidebar-item]"),
161
+ ];
162
+ const orderedIds = els
163
+ .map((el) => el.dataset.sidebarItem)
164
+ .filter((id): id is string => id !== undefined);
165
+
166
+ // Revert SortableJS DOM manipulation so Lit can re-render cleanly.
167
+ // Use the captured nextSibling (which includes Lit's comment markers)
168
+ // to restore the element to its exact original position. Using
169
+ // list.children (element-only) would skip comment nodes and misalign
170
+ // Lit's internal template markers, leaving orphaned DOM nodes.
171
+ const { item, oldIndex, newIndex } = evt;
172
+ if (oldIndex != null && newIndex != null && oldIndex !== newIndex) {
173
+ item.parentNode?.removeChild(item);
174
+ if (this.#revertNextSibling) {
175
+ list.insertBefore(item, this.#revertNextSibling);
176
+ } else {
177
+ list.appendChild(item);
178
+ }
179
+ }
180
+ this.#revertNextSibling = null;
181
+
182
+ // Destroy sortable so it doesn't fight Lit's re-render
183
+ this.#sortable?.destroy();
184
+ this.#sortable = null;
185
+
186
+ // Find the moved item
187
+ const movedId = newIndex != null ? orderedIds[newIndex] : undefined;
188
+ if (!movedId) return;
189
+
190
+ // Compute after/before neighbors
191
+ const movedIdx = orderedIds.indexOf(movedId);
192
+ const afterId = movedIdx > 0 ? orderedIds[movedIdx - 1] : null;
193
+ const beforeId =
194
+ movedIdx < orderedIds.length - 1 ? orderedIds[movedIdx + 1] : null;
195
+
196
+ // Update internal state — rebuild items in new order
197
+ const itemMap = new Map(this._items.map((i) => [i.id, i]));
198
+ this._items = orderedIds
199
+ .map((id) => itemMap.get(id))
200
+ .filter((i): i is ClientSidebarItem => i !== undefined);
201
+
202
+ // Persist to server — single item move
203
+ fetch(`/api/collections/sidebar-items/${movedId}/move`, {
204
+ method: "PUT",
205
+ headers: { "Content-Type": "application/json" },
206
+ body: JSON.stringify({
207
+ after: afterId ?? null,
208
+ before: beforeId ?? null,
209
+ }),
210
+ }).then((res) => {
211
+ if (res.ok) showToast(this.labels.orderSaved);
212
+ else showToast(this.labels.saveFailed, "error");
213
+ });
214
+ },
215
+ });
216
+ }
217
+
218
+ #enterReorderMode() {
219
+ this._reorderMode = true;
220
+ this._showMoreMenu = false;
221
+ document.removeEventListener("click", this.#closeMoreMenu);
222
+ // SortableJS will be initialized after Lit re-renders (in updated())
223
+ }
224
+
225
+ #exitReorderMode() {
226
+ this._reorderMode = false;
227
+ this.#sortable?.destroy();
228
+ this.#sortable = null;
229
+ }
230
+
231
+ protected updated(): void {
232
+ if (this._reorderMode) {
233
+ this.#initSortable();
234
+ }
235
+ }
236
+
237
+ // ===========================================================================
238
+ // Divider handlers
239
+ // ===========================================================================
240
+
241
+ async #addDivider() {
242
+ this._showMoreMenu = false;
243
+ document.removeEventListener("click", this.#closeMoreMenu);
244
+ try {
245
+ const res = await fetch("/api/collections/sidebar-items", {
246
+ method: "POST",
247
+ headers: { "Content-Type": "application/json" },
248
+ });
249
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
250
+ await this.#refreshList();
251
+ } catch {
252
+ showToast(this.labels.saveFailed, "error");
253
+ }
254
+ }
255
+
256
+ async #deleteDivider(id: string) {
257
+ try {
258
+ const res = await fetch(`/api/collections/sidebar-items/${id}`, {
259
+ method: "DELETE",
260
+ });
261
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
262
+ // Remove locally for instant feedback
263
+ this._items = this._items.filter((item) => item.id !== id);
264
+ } catch {
265
+ showToast(this.labels.saveFailed, "error");
266
+ }
267
+ }
268
+
269
+ // ===========================================================================
270
+ // Collection CRUD handlers
271
+ // ===========================================================================
272
+
273
+ #openCreateDialog() {
274
+ this._dialogMode = "create";
275
+ this._editingCollection = null;
276
+ this._showMoreMenu = false;
277
+ document.removeEventListener("click", this.#closeMoreMenu);
278
+ // Wait for render, then show the dialog
279
+ this.updateComplete.then(() => {
280
+ this.querySelector<HTMLDialogElement>(
281
+ "#sidebar-collection-dialog",
282
+ )?.showModal();
283
+ });
284
+ }
285
+
286
+ #openEditDialog(col: SidebarCollection) {
287
+ this._dialogMode = "edit";
288
+ this._editingCollection = col;
289
+ this._showItemMenuId = null;
290
+ document.removeEventListener("click", this.#closeItemMenu);
291
+ this.updateComplete.then(() => {
292
+ this.querySelector<HTMLDialogElement>(
293
+ "#sidebar-collection-dialog",
294
+ )?.showModal();
295
+ });
296
+ }
297
+
298
+ #closeDialog() {
299
+ this.querySelector<HTMLDialogElement>(
300
+ "#sidebar-collection-dialog",
301
+ )?.close();
302
+ this._dialogMode = null;
303
+ this._editingCollection = null;
304
+ }
305
+
306
+ async #handleCollectionSubmit(e: Event) {
307
+ const event = e as CustomEvent<CollectionSubmitDetail>;
308
+ event.stopPropagation(); // prevent global bridge from handling
309
+
310
+ const detail = event.detail;
311
+ if (!detail) return;
312
+
313
+ const formEl = this.querySelector("jant-collection-form") as
314
+ | (HTMLElement & {
315
+ loading: boolean;
316
+ })
317
+ | null;
318
+ if (formEl) formEl.loading = true;
319
+
320
+ try {
321
+ const isEdit = detail.isEdit;
322
+ const url = isEdit
323
+ ? `/api/collections/${this._editingCollection?.id}`
324
+ : "/api/collections";
325
+ const method = isEdit ? "PUT" : "POST";
326
+
327
+ const res = await fetch(url, {
328
+ method,
329
+ headers: { "Content-Type": "application/json" },
330
+ body: JSON.stringify(detail.data),
331
+ });
332
+
333
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
334
+
335
+ showToast(this.labels.saved);
336
+ this.#closeDialog();
337
+ await this.#refreshList();
338
+ } catch {
339
+ showToast(this.labels.saveFailed, "error");
340
+ } finally {
341
+ if (formEl) formEl.loading = false;
342
+ }
343
+ }
344
+
345
+ async #deleteCollection(col: SidebarCollection) {
346
+ if (!window.confirm(this.labels.confirmDelete)) return;
347
+
348
+ this._showItemMenuId = null;
349
+ document.removeEventListener("click", this.#closeItemMenu);
350
+
351
+ try {
352
+ const res = await fetch(`/api/collections/${col.id}`, {
353
+ method: "DELETE",
354
+ });
355
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
356
+
357
+ showToast(this.labels.deleted);
358
+ await this.#refreshList();
359
+ } catch {
360
+ showToast(this.labels.saveFailed, "error");
361
+ }
362
+ }
363
+
364
+ // ===========================================================================
365
+ // Render
366
+ // ===========================================================================
367
+
368
+ #renderHeading() {
369
+ return html`
370
+ <div class="flex items-center justify-between px-3 pb-2">
371
+ <h2
372
+ class="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
373
+ >
374
+ ${this.labels.collections}
375
+ </h2>
376
+ <div class="flex items-center gap-1">
377
+ ${this._reorderMode
378
+ ? html`
379
+ <button
380
+ type="button"
381
+ class="text-xs font-medium text-primary hover:underline"
382
+ @click=${() => this.#exitReorderMode()}
383
+ >
384
+ ${this.labels.done}
385
+ </button>
386
+ `
387
+ : html` ${this.#renderMoreButton()} ${this.#renderAddButton()} `}
388
+ </div>
389
+ </div>
390
+ `;
391
+ }
392
+
393
+ #renderMoreButton() {
394
+ return html`
395
+ <div class="relative">
396
+ <button
397
+ type="button"
398
+ class="flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
399
+ aria-label=${this.labels.moreActions}
400
+ @click=${(e: Event) => {
401
+ e.stopPropagation();
402
+ this._showMoreMenu = !this._showMoreMenu;
403
+ if (this._showMoreMenu) {
404
+ setTimeout(() => {
405
+ document.addEventListener("click", this.#closeMoreMenu);
406
+ });
407
+ } else {
408
+ document.removeEventListener("click", this.#closeMoreMenu);
409
+ }
410
+ }}
411
+ >
412
+ <svg
413
+ xmlns="http://www.w3.org/2000/svg"
414
+ width="14"
415
+ height="14"
416
+ viewBox="0 0 24 24"
417
+ fill="none"
418
+ stroke="currentColor"
419
+ stroke-width="2"
420
+ stroke-linecap="round"
421
+ stroke-linejoin="round"
422
+ >
423
+ <circle cx="12" cy="5" r="1" />
424
+ <circle cx="12" cy="12" r="1" />
425
+ <circle cx="12" cy="19" r="1" />
426
+ </svg>
427
+ </button>
428
+ ${this._showMoreMenu
429
+ ? html`
430
+ <div
431
+ class="absolute right-0 top-full mt-1 z-50 min-w-[160px] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
432
+ @click=${(e: Event) => e.stopPropagation()}
433
+ >
434
+ <button
435
+ type="button"
436
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
437
+ @click=${() => this.#enterReorderMode()}
438
+ >
439
+ <svg
440
+ xmlns="http://www.w3.org/2000/svg"
441
+ width="14"
442
+ height="14"
443
+ viewBox="0 0 24 24"
444
+ fill="none"
445
+ stroke="currentColor"
446
+ stroke-width="2"
447
+ stroke-linecap="round"
448
+ stroke-linejoin="round"
449
+ >
450
+ <path d="m3 16 4 4 4-4" />
451
+ <path d="M7 20V4" />
452
+ <path d="m21 8-4-4-4 4" />
453
+ <path d="M17 4v16" />
454
+ </svg>
455
+ ${this.labels.reorder}
456
+ </button>
457
+ <button
458
+ type="button"
459
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
460
+ @click=${() => this.#addDivider()}
461
+ >
462
+ <svg
463
+ xmlns="http://www.w3.org/2000/svg"
464
+ width="14"
465
+ height="14"
466
+ viewBox="0 0 24 24"
467
+ fill="none"
468
+ stroke="currentColor"
469
+ stroke-width="2"
470
+ stroke-linecap="round"
471
+ stroke-linejoin="round"
472
+ >
473
+ <path d="M3 12h18" />
474
+ </svg>
475
+ ${this.labels.addDivider}
476
+ </button>
477
+ </div>
478
+ `
479
+ : nothing}
480
+ </div>
481
+ `;
482
+ }
483
+
484
+ #renderAddButton() {
485
+ return html`
486
+ <button
487
+ type="button"
488
+ class="flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
489
+ title=${this.labels.newCollection}
490
+ aria-label=${this.labels.newCollection}
491
+ @click=${() => this.#openCreateDialog()}
492
+ >
493
+ <svg
494
+ xmlns="http://www.w3.org/2000/svg"
495
+ width="14"
496
+ height="14"
497
+ viewBox="0 0 24 24"
498
+ fill="none"
499
+ stroke="currentColor"
500
+ stroke-width="2"
501
+ stroke-linecap="round"
502
+ stroke-linejoin="round"
503
+ >
504
+ <path d="M5 12h14" />
505
+ <path d="M12 5v14" />
506
+ </svg>
507
+ </button>
508
+ `;
509
+ }
510
+
511
+ #renderCollectionItem(item: ClientSidebarItem) {
512
+ const col = item.collection;
513
+ if (!col) return nothing;
514
+
515
+ const isActive = col.slug === this.activeSlug;
516
+
517
+ if (this._reorderMode) {
518
+ return html`
519
+ <div
520
+ data-sidebar-item="${item.id}"
521
+ class="flex items-center gap-2 px-3 py-2 text-sm rounded-md"
522
+ >
523
+ <div class="cursor-grab text-muted-foreground" data-drag-handle>
524
+ <svg
525
+ xmlns="http://www.w3.org/2000/svg"
526
+ width="14"
527
+ height="14"
528
+ viewBox="0 0 24 24"
529
+ fill="none"
530
+ stroke="currentColor"
531
+ stroke-width="2"
532
+ stroke-linecap="round"
533
+ stroke-linejoin="round"
534
+ >
535
+ <circle cx="9" cy="12" r="1" />
536
+ <circle cx="9" cy="5" r="1" />
537
+ <circle cx="9" cy="19" r="1" />
538
+ <circle cx="15" cy="12" r="1" />
539
+ <circle cx="15" cy="5" r="1" />
540
+ <circle cx="15" cy="19" r="1" />
541
+ </svg>
542
+ </div>
543
+ <span class="flex items-center justify-center w-4 h-4 shrink-0">
544
+ ${unsafeHTML(
545
+ renderCollectionIcon(col.icon, { size: 16, fallback: true }),
546
+ )}
547
+ </span>
548
+ <span class="truncate">${col.title}</span>
549
+ </div>
550
+ `;
551
+ }
552
+
553
+ return html`
554
+ <div
555
+ data-sidebar-item="${item.id}"
556
+ class=${classMap({
557
+ "group relative": true,
558
+ "z-50": this._showItemMenuId === item.id,
559
+ })}
560
+ @mouseenter=${() => {
561
+ this._hoveringId = item.id;
562
+ }}
563
+ @mouseleave=${() => {
564
+ if (this._hoveringId === item.id) this._hoveringId = null;
565
+ }}
566
+ >
567
+ <a
568
+ href=${`/c/${col.slug}`}
569
+ class=${classMap({
570
+ "flex items-center gap-2.5 px-3 py-2 text-sm rounded-md truncate": true,
571
+ "bg-accent text-accent-foreground font-medium": isActive,
572
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground":
573
+ !isActive,
574
+ })}
575
+ >
576
+ <span class="flex items-center justify-center w-4 h-4 shrink-0">
577
+ ${unsafeHTML(
578
+ renderCollectionIcon(col.icon, { size: 16, fallback: true }),
579
+ )}
580
+ </span>
581
+ <span class="truncate">${col.title}</span>
582
+ </a>
583
+ ${this._hoveringId === item.id || this._showItemMenuId === item.id
584
+ ? this.#renderItemMenu(item)
585
+ : nothing}
586
+ </div>
587
+ `;
588
+ }
589
+
590
+ #renderItemMenu(item: ClientSidebarItem) {
591
+ const col = item.collection;
592
+ if (!col) return nothing;
593
+
594
+ const isOpen = this._showItemMenuId === item.id;
595
+
596
+ return html`
597
+ <div class="absolute right-1 top-1/2 -translate-y-1/2">
598
+ <button
599
+ type="button"
600
+ class="flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
601
+ @click=${(e: Event) => {
602
+ e.preventDefault();
603
+ e.stopPropagation();
604
+ if (isOpen) {
605
+ this._showItemMenuId = null;
606
+ document.removeEventListener("click", this.#closeItemMenu);
607
+ } else {
608
+ this._showItemMenuId = item.id;
609
+ setTimeout(() => {
610
+ document.addEventListener("click", this.#closeItemMenu);
611
+ });
612
+ }
613
+ }}
614
+ >
615
+ <svg
616
+ xmlns="http://www.w3.org/2000/svg"
617
+ width="14"
618
+ height="14"
619
+ viewBox="0 0 24 24"
620
+ fill="none"
621
+ stroke="currentColor"
622
+ stroke-width="2"
623
+ stroke-linecap="round"
624
+ stroke-linejoin="round"
625
+ >
626
+ <circle cx="12" cy="5" r="1" />
627
+ <circle cx="12" cy="12" r="1" />
628
+ <circle cx="12" cy="19" r="1" />
629
+ </svg>
630
+ </button>
631
+ ${isOpen
632
+ ? html`
633
+ <div
634
+ class="absolute right-0 top-full mt-1 z-50 min-w-[120px] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
635
+ @click=${(e: Event) => e.stopPropagation()}
636
+ >
637
+ <button
638
+ type="button"
639
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
640
+ @click=${() => this.#openEditDialog(col)}
641
+ >
642
+ ${this.labels.edit}
643
+ </button>
644
+ <button
645
+ type="button"
646
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-accent"
647
+ @click=${() => this.#deleteCollection(col)}
648
+ >
649
+ ${this.labels.deleteCollection}
650
+ </button>
651
+ </div>
652
+ `
653
+ : nothing}
654
+ </div>
655
+ `;
656
+ }
657
+
658
+ #renderDividerItem(item: ClientSidebarItem) {
659
+ if (this._reorderMode) {
660
+ return html`
661
+ <div
662
+ data-sidebar-item="${item.id}"
663
+ class="flex items-center gap-2 px-3 py-1"
664
+ >
665
+ <div class="cursor-grab text-muted-foreground" data-drag-handle>
666
+ <svg
667
+ xmlns="http://www.w3.org/2000/svg"
668
+ width="14"
669
+ height="14"
670
+ viewBox="0 0 24 24"
671
+ fill="none"
672
+ stroke="currentColor"
673
+ stroke-width="2"
674
+ stroke-linecap="round"
675
+ stroke-linejoin="round"
676
+ >
677
+ <circle cx="9" cy="12" r="1" />
678
+ <circle cx="9" cy="5" r="1" />
679
+ <circle cx="9" cy="19" r="1" />
680
+ <circle cx="15" cy="12" r="1" />
681
+ <circle cx="15" cy="5" r="1" />
682
+ <circle cx="15" cy="19" r="1" />
683
+ </svg>
684
+ </div>
685
+ <hr class="flex-1 border-border" />
686
+ <button
687
+ type="button"
688
+ class="flex items-center justify-center w-5 h-5 rounded-md text-muted-foreground hover:text-destructive"
689
+ title=${this.labels.deleteDivider}
690
+ @click=${() => this.#deleteDivider(item.id)}
691
+ >
692
+ <svg
693
+ xmlns="http://www.w3.org/2000/svg"
694
+ width="12"
695
+ height="12"
696
+ viewBox="0 0 24 24"
697
+ fill="none"
698
+ stroke="currentColor"
699
+ stroke-width="2"
700
+ stroke-linecap="round"
701
+ stroke-linejoin="round"
702
+ >
703
+ <path d="M18 6 6 18" />
704
+ <path d="m6 6 12 12" />
705
+ </svg>
706
+ </button>
707
+ </div>
708
+ `;
709
+ }
710
+
711
+ return html`
712
+ <div data-sidebar-item="${item.id}" class="px-3 py-1">
713
+ <hr class="border-border" />
714
+ </div>
715
+ `;
716
+ }
717
+
718
+ #renderDialog() {
719
+ if (!this._dialogMode) return nothing;
720
+
721
+ const isEdit = this._dialogMode === "edit";
722
+ const col = this._editingCollection;
723
+
724
+ const formLabels = this.labels.formLabels;
725
+ const initial =
726
+ isEdit && col
727
+ ? {
728
+ title: col.title,
729
+ slug: col.slug,
730
+ description: col.description ?? "",
731
+ sortOrder: col.sortOrder ?? "newest",
732
+ icon: col.icon ?? "",
733
+ }
734
+ : {
735
+ title: "",
736
+ slug: "",
737
+ description: "",
738
+ sortOrder: "newest",
739
+ icon: "",
740
+ };
741
+
742
+ const dialogLabels = {
743
+ ...formLabels,
744
+ submitLabel: isEdit ? formLabels.submitLabel : formLabels.submitLabel,
745
+ };
746
+
747
+ return html`
748
+ <dialog
749
+ id="sidebar-collection-dialog"
750
+ class="m-auto rounded-lg border border-border bg-background text-foreground p-6 w-full max-w-md shadow-lg backdrop:bg-black/50"
751
+ @cancel=${() => this.#closeDialog()}
752
+ @close=${() => {
753
+ this._dialogMode = null;
754
+ this._editingCollection = null;
755
+ }}
756
+ @click=${(e: Event) => {
757
+ // Backdrop click — target is the <dialog> itself when clicking outside the box
758
+ if (e.target === e.currentTarget) {
759
+ this.#closeDialog();
760
+ }
761
+ }}
762
+ >
763
+ <jant-collection-form
764
+ .labels=${dialogLabels}
765
+ .initial=${initial}
766
+ action=${isEdit && col
767
+ ? `/api/collections/${col.id}`
768
+ : "/api/collections"}
769
+ cancel-href="javascript:void(0)"
770
+ ?is-edit=${isEdit}
771
+ @jant:collection-submit=${(e: Event) =>
772
+ this.#handleCollectionSubmit(e)}
773
+ @click=${(e: Event) => {
774
+ // Intercept cancel link click
775
+ const target = (e.target as HTMLElement).closest?.("a.btn-outline");
776
+ if (target) {
777
+ e.preventDefault();
778
+ this.#closeDialog();
779
+ }
780
+ }}
781
+ ></jant-collection-form>
782
+ </dialog>
783
+ `;
784
+ }
785
+
786
+ render() {
787
+ return html`
788
+ <nav class="flex flex-col gap-1 pt-6">
789
+ ${this.#renderHeading()}
790
+
791
+ <div id="sidebar-collections-list" class="flex flex-col">
792
+ ${this._items.map((item) =>
793
+ item.type === "collection"
794
+ ? this.#renderCollectionItem(item)
795
+ : this.#renderDividerItem(item),
796
+ )}
797
+ </div>
798
+
799
+ ${this.#renderDialog()}
800
+ </nav>
801
+ `;
802
+ }
803
+ }
804
+
805
+ customElements.define("jant-collection-sidebar", JantCollectionSidebar);