@jant/core 0.3.36 → 0.3.38

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 (271) 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/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -23,7 +23,7 @@ import {
23
23
  renderCollectionIcon,
24
24
  getIconSvg,
25
25
  } from "../../lib/icons.js";
26
- import { ICON_CATALOG } from "../../lib/icon-catalog.js";
26
+ import { ALL_ICON_NAMES, ALL_ICON_CATEGORIES } from "../../lib/icon-catalog.js";
27
27
  import { EMOJI_CATALOG } from "../../lib/emoji-catalog.js";
28
28
  import { slugify } from "../lazy-slugify.js";
29
29
  import type {
@@ -84,6 +84,16 @@ export class JantCollectionForm extends LitElement {
84
84
  declare _loading: boolean;
85
85
 
86
86
  #initialized = false;
87
+ #svgCache = new Map<string, string>();
88
+
89
+ #getCachedSvg(name: string): string | null {
90
+ const cached = this.#svgCache.get(name);
91
+ if (cached !== undefined) return cached;
92
+ const svg = getIconSvg(name);
93
+ if (svg) this.#svgCache.set(name, svg);
94
+ return svg;
95
+ }
96
+
87
97
  #closePickerHandler = (e: Event) => {
88
98
  const target = e.target as HTMLElement | null;
89
99
  if (!target) return;
@@ -215,14 +225,15 @@ export class JantCollectionForm extends LitElement {
215
225
  return "";
216
226
  }
217
227
 
218
- #filteredCatalog(): CatalogCategory[] {
219
- const q = this._iconSearch.trim().toLowerCase();
228
+ #allIconsByCategory: CatalogCategory[] | null = null;
229
+
230
+ #getAllIconsByCategory(): CatalogCategory[] {
231
+ if (this.#allIconsByCategory) return this.#allIconsByCategory;
220
232
  const result: CatalogCategory[] = [];
221
- for (const [category, names] of Object.entries(ICON_CATALOG)) {
233
+ for (const [category, names] of Object.entries(ALL_ICON_CATEGORIES)) {
222
234
  const icons = names
223
- .filter((name) => (q ? name.includes(q) : true))
224
235
  .map((name) => {
225
- const svg = getIconSvg(name);
236
+ const svg = this.#getCachedSvg(name);
226
237
  return svg ? { name, svg } : null;
227
238
  })
228
239
  .filter((icon): icon is { name: string; svg: string } => Boolean(icon));
@@ -230,9 +241,33 @@ export class JantCollectionForm extends LitElement {
230
241
  result.push({ name: category, icons });
231
242
  }
232
243
  }
244
+ this.#allIconsByCategory = result;
233
245
  return result;
234
246
  }
235
247
 
248
+ #filteredCatalog(): CatalogCategory[] {
249
+ const q = this._iconSearch.trim().toLowerCase();
250
+
251
+ if (!q) {
252
+ // No search → show all icons grouped by official category
253
+ return this.#getAllIconsByCategory();
254
+ }
255
+
256
+ // Search → filter ALL icon names + category names
257
+ const matching = ALL_ICON_NAMES.filter((name) => name.includes(q));
258
+ if (matching.length === 0) return [];
259
+
260
+ const icons = matching
261
+ .map((name) => {
262
+ const svg = this.#getCachedSvg(name);
263
+ return svg ? { name, svg } : null;
264
+ })
265
+ .filter((icon): icon is { name: string; svg: string } => Boolean(icon));
266
+
267
+ if (icons.length === 0) return [];
268
+ return [{ name: "results", icons }];
269
+ }
270
+
236
271
  #filteredEmojiCatalog(): EmojiCategory[] {
237
272
  const q = this._iconSearch.trim().toLowerCase();
238
273
  const result: EmojiCategory[] = [];
@@ -379,6 +414,7 @@ export class JantCollectionForm extends LitElement {
379
414
  type="button"
380
415
  class=${`flex items-center justify-center w-8 h-8 rounded-md hover:bg-accent transition-colors${this._iconName === icon.name && this._iconSvg === icon.svg && !this._iconEmoji ? " ring-2 ring-primary" : ""}`}
381
416
  data-icon-name=${icon.name}
417
+ title=${icon.name}
382
418
  style=${`color:${this._iconColor}`}
383
419
  @click=${() => this.#selectIcon(icon.name, icon.svg)}
384
420
  >
@@ -489,7 +525,7 @@ export class JantCollectionForm extends LitElement {
489
525
  </div>
490
526
 
491
527
  <!-- Grid -->
492
- <div class="overflow-y-auto max-h-56">
528
+ <div class="overflow-y-auto max-h-80">
493
529
  ${isIconsTab ? this.#renderIconsGrid() : this.#renderEmojisGrid()}
494
530
  </div>
495
531
 
@@ -2,7 +2,7 @@
2
2
  * Collection Sidebar Component
3
3
  *
4
4
  * Manages collections in the public /c page sidebar for authenticated users:
5
- * - Renders collections + dividers as an interleaved sorted list
5
+ * - Renders sidebar items (collections + dividers) from a single pre-ordered list
6
6
  * - Dropdown menus for "More" (reorder, add divider) and per-collection edit
7
7
  * - SortableJS drag-and-drop reorder mode
8
8
  * - Create/edit collection dialogs embedding <jant-collection-form>
@@ -25,26 +25,12 @@ import type { CollectionSubmitDetail } from "./collection-types.js";
25
25
  import type {
26
26
  CollectionSidebarLabels,
27
27
  SidebarCollection,
28
- SidebarDivider,
29
- SidebarItem,
28
+ ClientSidebarItem,
30
29
  } from "./collection-sidebar-types.js";
31
30
 
32
- function interleaveItems(
33
- collections: SidebarCollection[],
34
- dividers: SidebarDivider[],
35
- ): SidebarItem[] {
36
- const items: SidebarItem[] = [
37
- ...collections.map((c) => ({ kind: "collection", data: c }) as SidebarItem),
38
- ...dividers.map((d) => ({ kind: "divider", data: d }) as SidebarItem),
39
- ];
40
- items.sort((a, b) => a.data.position - b.data.position);
41
- return items;
42
- }
43
-
44
31
  export class JantCollectionSidebar extends LitElement {
45
32
  static properties = {
46
- collections: { type: Array },
47
- dividers: { type: Array },
33
+ "sidebar-items": { type: Array },
48
34
  labels: { type: Object },
49
35
  activeSlug: { type: String, attribute: "active-slug" },
50
36
 
@@ -57,21 +43,21 @@ export class JantCollectionSidebar extends LitElement {
57
43
  _showItemMenuId: { state: true },
58
44
  };
59
45
 
60
- declare collections: SidebarCollection[];
61
- declare dividers: SidebarDivider[];
46
+ declare "sidebar-items": ClientSidebarItem[];
62
47
  declare labels: CollectionSidebarLabels;
63
48
  declare activeSlug: string;
64
49
 
65
- declare _items: SidebarItem[];
50
+ declare _items: ClientSidebarItem[];
66
51
  declare _reorderMode: boolean;
67
52
  declare _dialogMode: "create" | "edit" | null;
68
53
  declare _editingCollection: SidebarCollection | null;
69
54
  declare _showMoreMenu: boolean;
70
- declare _hoveringId: number | null;
71
- declare _showItemMenuId: number | null;
55
+ declare _hoveringId: string | null;
56
+ declare _showItemMenuId: string | null;
72
57
 
73
58
  #sortable: { destroy(): void } | null = null;
74
59
  #initialized = false;
60
+ #revertNextSibling: Node | null = null;
75
61
 
76
62
  #closeMoreMenu = () => {
77
63
  this._showMoreMenu = false;
@@ -90,8 +76,7 @@ export class JantCollectionSidebar extends LitElement {
90
76
 
91
77
  constructor() {
92
78
  super();
93
- this.collections = [];
94
- this.dividers = [];
79
+ this["sidebar-items"] = [];
95
80
  this.labels = {} as CollectionSidebarLabels;
96
81
  this.activeSlug = "";
97
82
 
@@ -109,13 +94,9 @@ export class JantCollectionSidebar extends LitElement {
109
94
  ): void {
110
95
  if (
111
96
  !this.#initialized ||
112
- changedProperties.has("collections") ||
113
- changedProperties.has("dividers")
97
+ changedProperties.has("sidebar-items" as keyof JantCollectionSidebar)
114
98
  ) {
115
- this._items = interleaveItems(
116
- this.collections ?? [],
117
- this.dividers ?? [],
118
- );
99
+ this._items = [...(this["sidebar-items"] ?? [])];
119
100
  this.#initialized = true;
120
101
  }
121
102
  super.update(changedProperties);
@@ -138,9 +119,20 @@ export class JantCollectionSidebar extends LitElement {
138
119
  const res = await fetch("/api/collections");
139
120
  if (!res.ok) return;
140
121
  const json = await res.json();
141
- this.collections = json.collections;
142
- this.dividers = json.dividers;
143
- // update triggers via the `update` lifecycle
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
+ }));
144
136
  } catch {
145
137
  // silent — stale list is acceptable
146
138
  }
@@ -157,56 +149,64 @@ export class JantCollectionSidebar extends LitElement {
157
149
  this.#sortable = Sortable.create(list, {
158
150
  animation: 150,
159
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
+ },
160
157
  onEnd: (evt) => {
161
158
  // Read new order from DOM BEFORE reverting
162
159
  const els = [
163
160
  ...list.querySelectorAll<HTMLElement>("[data-sidebar-item]"),
164
161
  ];
165
- const items = els
162
+ const orderedIds = els
166
163
  .map((el) => el.dataset.sidebarItem)
167
164
  .filter((id): id is string => id !== undefined);
168
165
 
169
- // Revert SortableJS DOM manipulation so Lit can re-render cleanly
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.
170
171
  const { item, oldIndex, newIndex } = evt;
171
172
  if (oldIndex != null && newIndex != null && oldIndex !== newIndex) {
172
173
  item.parentNode?.removeChild(item);
173
- const children = list.children;
174
- if (oldIndex >= children.length) {
175
- list.appendChild(item);
174
+ if (this.#revertNextSibling) {
175
+ list.insertBefore(item, this.#revertNextSibling);
176
176
  } else {
177
- list.insertBefore(item, children[oldIndex]);
177
+ list.appendChild(item);
178
178
  }
179
179
  }
180
+ this.#revertNextSibling = null;
180
181
 
181
182
  // Destroy sortable so it doesn't fight Lit's re-render
182
183
  this.#sortable?.destroy();
183
184
  this.#sortable = null;
184
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
+
185
196
  // Update internal state — rebuild items in new order
186
- const collectionMap = new Map(
187
- (this.collections ?? []).map((c) => [`c-${c.id}`, c]),
188
- );
189
- const dividerMap = new Map(
190
- (this.dividers ?? []).map((d) => [`d-${d.id}`, d]),
191
- );
192
-
193
- const newItems: SidebarItem[] = [];
194
- for (const prefixed of items) {
195
- if (prefixed.startsWith("c-")) {
196
- const col = collectionMap.get(prefixed);
197
- if (col) newItems.push({ kind: "collection", data: col });
198
- } else if (prefixed.startsWith("d-")) {
199
- const div = dividerMap.get(prefixed);
200
- if (div) newItems.push({ kind: "divider", data: div });
201
- }
202
- }
203
- this._items = newItems;
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);
204
201
 
205
- // Persist to server
206
- fetch("/api/collections/reorder", {
202
+ // Persist to server — single item move
203
+ fetch(`/api/collections/sidebar-items/${movedId}/move`, {
207
204
  method: "PUT",
208
205
  headers: { "Content-Type": "application/json" },
209
- body: JSON.stringify({ items }),
206
+ body: JSON.stringify({
207
+ after: afterId ?? null,
208
+ before: beforeId ?? null,
209
+ }),
210
210
  }).then((res) => {
211
211
  if (res.ok) showToast(this.labels.orderSaved);
212
212
  else showToast(this.labels.saveFailed, "error");
@@ -242,7 +242,7 @@ export class JantCollectionSidebar extends LitElement {
242
242
  this._showMoreMenu = false;
243
243
  document.removeEventListener("click", this.#closeMoreMenu);
244
244
  try {
245
- const res = await fetch("/api/collections/dividers", {
245
+ const res = await fetch("/api/collections/sidebar-items", {
246
246
  method: "POST",
247
247
  headers: { "Content-Type": "application/json" },
248
248
  });
@@ -253,16 +253,14 @@ export class JantCollectionSidebar extends LitElement {
253
253
  }
254
254
  }
255
255
 
256
- async #deleteDivider(id: number) {
256
+ async #deleteDivider(id: string) {
257
257
  try {
258
- const res = await fetch(`/api/collections/dividers/${id}`, {
258
+ const res = await fetch(`/api/collections/sidebar-items/${id}`, {
259
259
  method: "DELETE",
260
260
  });
261
261
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
262
262
  // Remove locally for instant feedback
263
- this._items = this._items.filter(
264
- (item) => !(item.kind === "divider" && item.data.id === id),
265
- );
263
+ this._items = this._items.filter((item) => item.id !== id);
266
264
  } catch {
267
265
  showToast(this.labels.saveFailed, "error");
268
266
  }
@@ -510,13 +508,16 @@ export class JantCollectionSidebar extends LitElement {
510
508
  `;
511
509
  }
512
510
 
513
- #renderCollectionItem(col: SidebarCollection) {
511
+ #renderCollectionItem(item: ClientSidebarItem) {
512
+ const col = item.collection;
513
+ if (!col) return nothing;
514
+
514
515
  const isActive = col.slug === this.activeSlug;
515
516
 
516
517
  if (this._reorderMode) {
517
518
  return html`
518
519
  <div
519
- data-sidebar-item="c-${col.id}"
520
+ data-sidebar-item="${item.id}"
520
521
  class="flex items-center gap-2 px-3 py-2 text-sm rounded-md"
521
522
  >
522
523
  <div class="cursor-grab text-muted-foreground" data-drag-handle>
@@ -551,16 +552,16 @@ export class JantCollectionSidebar extends LitElement {
551
552
 
552
553
  return html`
553
554
  <div
554
- data-sidebar-item="c-${col.id}"
555
+ data-sidebar-item="${item.id}"
555
556
  class=${classMap({
556
557
  "group relative": true,
557
- "z-50": this._showItemMenuId === col.id,
558
+ "z-50": this._showItemMenuId === item.id,
558
559
  })}
559
560
  @mouseenter=${() => {
560
- this._hoveringId = col.id;
561
+ this._hoveringId = item.id;
561
562
  }}
562
563
  @mouseleave=${() => {
563
- if (this._hoveringId === col.id) this._hoveringId = null;
564
+ if (this._hoveringId === item.id) this._hoveringId = null;
564
565
  }}
565
566
  >
566
567
  <a
@@ -579,15 +580,18 @@ export class JantCollectionSidebar extends LitElement {
579
580
  </span>
580
581
  <span class="truncate">${col.title}</span>
581
582
  </a>
582
- ${this._hoveringId === col.id || this._showItemMenuId === col.id
583
- ? this.#renderItemMenu(col)
583
+ ${this._hoveringId === item.id || this._showItemMenuId === item.id
584
+ ? this.#renderItemMenu(item)
584
585
  : nothing}
585
586
  </div>
586
587
  `;
587
588
  }
588
589
 
589
- #renderItemMenu(col: SidebarCollection) {
590
- const isOpen = this._showItemMenuId === col.id;
590
+ #renderItemMenu(item: ClientSidebarItem) {
591
+ const col = item.collection;
592
+ if (!col) return nothing;
593
+
594
+ const isOpen = this._showItemMenuId === item.id;
591
595
 
592
596
  return html`
593
597
  <div class="absolute right-1 top-1/2 -translate-y-1/2">
@@ -601,7 +605,7 @@ export class JantCollectionSidebar extends LitElement {
601
605
  this._showItemMenuId = null;
602
606
  document.removeEventListener("click", this.#closeItemMenu);
603
607
  } else {
604
- this._showItemMenuId = col.id;
608
+ this._showItemMenuId = item.id;
605
609
  setTimeout(() => {
606
610
  document.addEventListener("click", this.#closeItemMenu);
607
611
  });
@@ -651,11 +655,11 @@ export class JantCollectionSidebar extends LitElement {
651
655
  `;
652
656
  }
653
657
 
654
- #renderDividerItem(div: SidebarDivider) {
658
+ #renderDividerItem(item: ClientSidebarItem) {
655
659
  if (this._reorderMode) {
656
660
  return html`
657
661
  <div
658
- data-sidebar-item="d-${div.id}"
662
+ data-sidebar-item="${item.id}"
659
663
  class="flex items-center gap-2 px-3 py-1"
660
664
  >
661
665
  <div class="cursor-grab text-muted-foreground" data-drag-handle>
@@ -683,7 +687,7 @@ export class JantCollectionSidebar extends LitElement {
683
687
  type="button"
684
688
  class="flex items-center justify-center w-5 h-5 rounded-md text-muted-foreground hover:text-destructive"
685
689
  title=${this.labels.deleteDivider}
686
- @click=${() => this.#deleteDivider(div.id)}
690
+ @click=${() => this.#deleteDivider(item.id)}
687
691
  >
688
692
  <svg
689
693
  xmlns="http://www.w3.org/2000/svg"
@@ -705,7 +709,7 @@ export class JantCollectionSidebar extends LitElement {
705
709
  }
706
710
 
707
711
  return html`
708
- <div data-sidebar-item="d-${div.id}" class="px-3 py-1">
712
+ <div data-sidebar-item="${item.id}" class="px-3 py-1">
709
713
  <hr class="border-border" />
710
714
  </div>
711
715
  `;
@@ -786,9 +790,9 @@ export class JantCollectionSidebar extends LitElement {
786
790
 
787
791
  <div id="sidebar-collections-list" class="flex flex-col">
788
792
  ${this._items.map((item) =>
789
- item.kind === "collection"
790
- ? this.#renderCollectionItem(item.data as SidebarCollection)
791
- : this.#renderDividerItem(item.data as SidebarDivider),
793
+ item.type === "collection"
794
+ ? this.#renderCollectionItem(item)
795
+ : this.#renderDividerItem(item),
792
796
  )}
793
797
  </div>
794
798