@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,667 @@
1
+ /**
2
+ * Collection Form Component
3
+ *
4
+ * Handles create/edit collection form interactions:
5
+ * - Maintains form state for title, slug, description, sort order, and icon
6
+ * - Notion-style inline icon trigger with anchored popover (Icons + Emojis tabs)
7
+ * - Color presets that instantly recolor all icon previews
8
+ * - Default "library" icon in create mode
9
+ * - Dispatches `jant:collection-submit` for the bridge to POST to the server
10
+ *
11
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
12
+ */
13
+
14
+ import { LitElement, html, nothing } from "lit";
15
+ import type { PropertyValueMap } from "lit";
16
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
17
+ import {
18
+ DEFAULT_ICON_COLOR,
19
+ DEFAULT_ICON_NAME,
20
+ ICON_COLOR_PRESETS,
21
+ createIconValue,
22
+ parseCollectionIcon,
23
+ renderCollectionIcon,
24
+ getIconSvg,
25
+ } from "../../lib/icons.js";
26
+ import { ALL_ICON_NAMES, ALL_ICON_CATEGORIES } from "../../lib/icon-catalog.js";
27
+ import { EMOJI_CATALOG } from "../../lib/emoji-catalog.js";
28
+ import { slugify } from "../lazy-slugify.js";
29
+ import type {
30
+ CollectionFormInitial,
31
+ CollectionFormLabels,
32
+ CollectionSubmitDetail,
33
+ } from "./collection-types.js";
34
+
35
+ type CatalogCategory = {
36
+ name: string;
37
+ icons: Array<{ name: string; svg: string }>;
38
+ };
39
+
40
+ type EmojiCategory = {
41
+ name: string;
42
+ emojis: string[];
43
+ };
44
+
45
+ export class JantCollectionForm extends LitElement {
46
+ static properties = {
47
+ labels: { type: Object },
48
+ initial: { type: Object },
49
+ action: { type: String },
50
+ cancelHref: { type: String, attribute: "cancel-href" },
51
+ isEdit: { type: Boolean, attribute: "is-edit" },
52
+
53
+ _title: { state: true },
54
+ _slug: { state: true },
55
+ _description: { state: true },
56
+ _sortOrder: { state: true },
57
+ _iconName: { state: true },
58
+ _iconSvg: { state: true },
59
+ _iconColor: { state: true },
60
+ _iconEmoji: { state: true },
61
+ _iconSearch: { state: true },
62
+ _pickerOpen: { state: true },
63
+ _pickerTab: { state: true },
64
+ _loading: { state: true },
65
+ };
66
+
67
+ declare labels: CollectionFormLabels;
68
+ declare initial: CollectionFormInitial;
69
+ declare action: string;
70
+ declare cancelHref: string;
71
+ declare isEdit: boolean;
72
+
73
+ declare _title: string;
74
+ declare _slug: string;
75
+ declare _description: string;
76
+ declare _sortOrder: string;
77
+ declare _iconName: string;
78
+ declare _iconSvg: string;
79
+ declare _iconColor: string;
80
+ declare _iconEmoji: string;
81
+ declare _iconSearch: string;
82
+ declare _pickerOpen: boolean;
83
+ declare _pickerTab: "icons" | "emojis";
84
+ declare _loading: boolean;
85
+
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
+
97
+ #closePickerHandler = (e: Event) => {
98
+ const target = e.target as HTMLElement | null;
99
+ if (!target) return;
100
+ const pickerEl = this.querySelector<HTMLElement>("[data-icon-picker]");
101
+ const triggerEl = this.querySelector<HTMLElement>("[data-icon-trigger]");
102
+ if (
103
+ pickerEl &&
104
+ !pickerEl.contains(target) &&
105
+ triggerEl &&
106
+ !triggerEl.contains(target)
107
+ ) {
108
+ this._pickerOpen = false;
109
+ }
110
+ };
111
+
112
+ createRenderRoot() {
113
+ this.innerHTML = "";
114
+ return this;
115
+ }
116
+
117
+ constructor() {
118
+ super();
119
+ this.labels = {} as CollectionFormLabels;
120
+ this.initial = {
121
+ title: "",
122
+ slug: "",
123
+ description: "",
124
+ sortOrder: "newest",
125
+ icon: "",
126
+ };
127
+ this.action = "";
128
+ this.cancelHref = "/";
129
+ this.isEdit = false;
130
+
131
+ this._title = "";
132
+ this._slug = "";
133
+ this._description = "";
134
+ this._sortOrder = "newest";
135
+ this._iconName = "";
136
+ this._iconSvg = "";
137
+ this._iconColor = DEFAULT_ICON_COLOR;
138
+ this._iconEmoji = "";
139
+ this._iconSearch = "";
140
+ this._pickerOpen = false;
141
+ this._pickerTab = "icons";
142
+ this._loading = false;
143
+ }
144
+
145
+ protected update(
146
+ changedProperties: PropertyValueMap<JantCollectionForm>,
147
+ ): void {
148
+ if (!this.#initialized || changedProperties.has("initial")) {
149
+ this.#applyInitialData();
150
+ }
151
+ super.update(changedProperties);
152
+ }
153
+
154
+ set loading(value: boolean) {
155
+ this._loading = value;
156
+ }
157
+
158
+ get loading(): boolean {
159
+ return this._loading;
160
+ }
161
+
162
+ connectedCallback() {
163
+ super.connectedCallback();
164
+ document.addEventListener("click", this.#closePickerHandler, true);
165
+ }
166
+
167
+ disconnectedCallback() {
168
+ super.disconnectedCallback();
169
+ document.removeEventListener("click", this.#closePickerHandler, true);
170
+ }
171
+
172
+ #applyInitialData() {
173
+ if (!this.initial) return;
174
+ this.#initialized = true;
175
+ this._title = this.initial.title ?? "";
176
+ this._slug = this.initial.slug ?? "";
177
+ this._description = this.initial.description ?? "";
178
+ this._sortOrder = this.initial.sortOrder ?? "newest";
179
+
180
+ const rawIcon = this.initial.icon ?? "";
181
+ const parsed = parseCollectionIcon(rawIcon);
182
+ if (parsed) {
183
+ this._iconName = parsed.name;
184
+ this._iconSvg = parsed.svg;
185
+ this._iconColor = parsed.color || DEFAULT_ICON_COLOR;
186
+ this._iconEmoji = "";
187
+ } else if (rawIcon && !rawIcon.startsWith("{")) {
188
+ // Legacy emoji value
189
+ this._iconEmoji = rawIcon;
190
+ this._iconName = "";
191
+ this._iconSvg = "";
192
+ this._iconColor = DEFAULT_ICON_COLOR;
193
+ } else {
194
+ this._iconName = "";
195
+ this._iconSvg = "";
196
+ this._iconColor = DEFAULT_ICON_COLOR;
197
+ this._iconEmoji = "";
198
+ // Default icon in create mode
199
+ if (!this.isEdit) {
200
+ this.#applyDefaultIcon();
201
+ }
202
+ }
203
+ }
204
+
205
+ #applyDefaultIcon() {
206
+ const svg = getIconSvg(DEFAULT_ICON_NAME);
207
+ if (svg) {
208
+ this._iconName = DEFAULT_ICON_NAME;
209
+ this._iconSvg = svg;
210
+ this._iconColor = DEFAULT_ICON_COLOR;
211
+ }
212
+ }
213
+
214
+ get #iconValue(): string {
215
+ if (this._iconEmoji) {
216
+ return this._iconEmoji;
217
+ }
218
+ if (this._iconName && this._iconSvg) {
219
+ return createIconValue(
220
+ this._iconName,
221
+ this._iconSvg,
222
+ this._iconColor || DEFAULT_ICON_COLOR,
223
+ );
224
+ }
225
+ return "";
226
+ }
227
+
228
+ #allIconsByCategory: CatalogCategory[] | null = null;
229
+
230
+ #getAllIconsByCategory(): CatalogCategory[] {
231
+ if (this.#allIconsByCategory) return this.#allIconsByCategory;
232
+ const result: CatalogCategory[] = [];
233
+ for (const [category, names] of Object.entries(ALL_ICON_CATEGORIES)) {
234
+ const icons = names
235
+ .map((name) => {
236
+ const svg = this.#getCachedSvg(name);
237
+ return svg ? { name, svg } : null;
238
+ })
239
+ .filter((icon): icon is { name: string; svg: string } => Boolean(icon));
240
+ if (icons.length > 0) {
241
+ result.push({ name: category, icons });
242
+ }
243
+ }
244
+ this.#allIconsByCategory = result;
245
+ return result;
246
+ }
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
+
271
+ #filteredEmojiCatalog(): EmojiCategory[] {
272
+ const q = this._iconSearch.trim().toLowerCase();
273
+ const result: EmojiCategory[] = [];
274
+ for (const [category, emojis] of Object.entries(EMOJI_CATALOG)) {
275
+ if (q && !category.includes(q)) continue;
276
+ result.push({ name: category, emojis });
277
+ }
278
+ return result;
279
+ }
280
+
281
+ #togglePicker(e: Event) {
282
+ e.stopPropagation();
283
+ this._pickerOpen = !this._pickerOpen;
284
+ this._iconSearch = "";
285
+ }
286
+
287
+ #selectIcon(name: string, svg: string) {
288
+ this._iconName = name;
289
+ this._iconSvg = svg;
290
+ this._iconEmoji = "";
291
+ if (!this._iconColor) {
292
+ this._iconColor = DEFAULT_ICON_COLOR;
293
+ }
294
+ this._iconSearch = "";
295
+ this._pickerOpen = false;
296
+ }
297
+
298
+ #selectEmoji(emoji: string) {
299
+ this._iconEmoji = emoji;
300
+ this._iconName = "";
301
+ this._iconSvg = "";
302
+ this._iconSearch = "";
303
+ this._pickerOpen = false;
304
+ }
305
+
306
+ #removeIcon() {
307
+ this._iconName = "";
308
+ this._iconSvg = "";
309
+ this._iconColor = DEFAULT_ICON_COLOR;
310
+ this._iconEmoji = "";
311
+ this._pickerOpen = false;
312
+ }
313
+
314
+ #handleSubmit(e: Event) {
315
+ e.preventDefault();
316
+ const title = this._title.trim();
317
+ const slug = this._slug.trim();
318
+ if (!title || !slug) {
319
+ return;
320
+ }
321
+
322
+ const detail: CollectionSubmitDetail = {
323
+ endpoint: this.action,
324
+ isEdit: this.isEdit,
325
+ data: {
326
+ title,
327
+ slug,
328
+ description: this._description.trim() || undefined,
329
+ icon: this.#iconValue || undefined,
330
+ sortOrder: this._sortOrder || undefined,
331
+ },
332
+ };
333
+
334
+ this.dispatchEvent(
335
+ new CustomEvent<CollectionSubmitDetail>("jant:collection-submit", {
336
+ bubbles: true,
337
+ detail,
338
+ }),
339
+ );
340
+ }
341
+
342
+ #renderTriggerIcon() {
343
+ if (this._iconEmoji) {
344
+ return html`<span class="text-lg leading-none">${this._iconEmoji}</span>`;
345
+ }
346
+ if (this._iconSvg) {
347
+ const htmlString = renderCollectionIcon(this.#iconValue, {
348
+ size: 20,
349
+ fallback: false,
350
+ });
351
+ return html`<span
352
+ class="w-5 h-5 flex items-center justify-center"
353
+ style=${`color:${this._iconColor}`}
354
+ >
355
+ ${unsafeHTML(htmlString)}
356
+ </span>`;
357
+ }
358
+ return html`<span class="text-muted-foreground text-base">+</span>`;
359
+ }
360
+
361
+ #renderInlineIconTrigger() {
362
+ return html`
363
+ <button
364
+ type="button"
365
+ data-icon-trigger
366
+ class="absolute left-2 top-1/2 -translate-y-1/2 flex items-center justify-center w-8 h-8 rounded-md hover:bg-accent transition-colors z-10"
367
+ @click=${(e: Event) => this.#togglePicker(e)}
368
+ >
369
+ ${this.#renderTriggerIcon()}
370
+ </button>
371
+ `;
372
+ }
373
+
374
+ #renderPickerColorPresets() {
375
+ return html`
376
+ <div class="flex items-center gap-1.5 px-3 pb-2">
377
+ ${ICON_COLOR_PRESETS.map((preset) => {
378
+ const isActive = this._iconColor === preset.value;
379
+ return html`
380
+ <button
381
+ type="button"
382
+ class=${`w-5 h-5 rounded-full border-2 transition-transform hover:scale-110${isActive ? " ring-2 ring-offset-1 ring-primary" : ""}`}
383
+ style=${`background-color:${preset.value}; border-color: transparent`}
384
+ title=${preset.name}
385
+ @click=${() => {
386
+ this._iconColor = preset.value;
387
+ }}
388
+ ></button>
389
+ `;
390
+ })}
391
+ </div>
392
+ `;
393
+ }
394
+
395
+ #renderIconsGrid() {
396
+ const categories = this.#filteredCatalog();
397
+ if (categories.length === 0) {
398
+ return html`<p class="text-sm text-muted-foreground px-3 py-2">
399
+ No icons found
400
+ </p>`;
401
+ }
402
+ return categories.map(
403
+ (category) => html`
404
+ <div class="flex flex-col gap-1.5 mb-3" data-category=${category.name}>
405
+ <h3
406
+ class="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3"
407
+ >
408
+ ${category.name}
409
+ </h3>
410
+ <div class="grid grid-cols-8 gap-0.5 px-2">
411
+ ${category.icons.map(
412
+ (icon) => html`
413
+ <button
414
+ type="button"
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" : ""}`}
416
+ data-icon-name=${icon.name}
417
+ title=${icon.name}
418
+ style=${`color:${this._iconColor}`}
419
+ @click=${() => this.#selectIcon(icon.name, icon.svg)}
420
+ >
421
+ <span class="w-4 h-4 flex items-center justify-center">
422
+ ${unsafeHTML(
423
+ icon.svg
424
+ .replace(/width="24"/, 'width="16"')
425
+ .replace(/height="24"/, 'height="16"'),
426
+ )}
427
+ </span>
428
+ </button>
429
+ `,
430
+ )}
431
+ </div>
432
+ </div>
433
+ `,
434
+ );
435
+ }
436
+
437
+ #renderEmojisGrid() {
438
+ const categories = this.#filteredEmojiCatalog();
439
+ if (categories.length === 0) {
440
+ return html`<p class="text-sm text-muted-foreground px-3 py-2">
441
+ No emojis found
442
+ </p>`;
443
+ }
444
+ return categories.map(
445
+ (category) => html`
446
+ <div class="flex flex-col gap-1.5 mb-3" data-category=${category.name}>
447
+ <h3
448
+ class="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3"
449
+ >
450
+ ${category.name}
451
+ </h3>
452
+ <div class="grid grid-cols-8 gap-0.5 px-2">
453
+ ${category.emojis.map(
454
+ (emoji) => html`
455
+ <button
456
+ type="button"
457
+ class=${`flex items-center justify-center w-8 h-8 rounded-md hover:bg-accent transition-colors text-lg${this._iconEmoji === emoji ? " ring-2 ring-primary" : ""}`}
458
+ @click=${() => this.#selectEmoji(emoji)}
459
+ >
460
+ ${emoji}
461
+ </button>
462
+ `,
463
+ )}
464
+ </div>
465
+ </div>
466
+ `,
467
+ );
468
+ }
469
+
470
+ #renderIconPopover() {
471
+ if (!this._pickerOpen) return nothing;
472
+
473
+ const isIconsTab = this._pickerTab === "icons";
474
+ const searchPlaceholder = isIconsTab
475
+ ? this.labels.searchIconsPlaceholder
476
+ : this.labels.searchEmojisPlaceholder;
477
+ const hasIcon = this._iconSvg || this._iconEmoji;
478
+
479
+ return html`
480
+ <div
481
+ data-icon-picker
482
+ class="absolute left-0 top-full mt-1 z-50 w-80 rounded-lg border border-border bg-background shadow-lg"
483
+ >
484
+ <!-- Tabs -->
485
+ <div class="flex border-b border-border">
486
+ <button
487
+ type="button"
488
+ class=${`flex-1 px-3 py-2 text-sm font-medium transition-colors ${isIconsTab ? "border-b-2 border-primary text-foreground" : "text-muted-foreground hover:text-foreground"}`}
489
+ @click=${() => {
490
+ this._pickerTab = "icons";
491
+ this._iconSearch = "";
492
+ }}
493
+ >
494
+ ${this.labels.iconsTab}
495
+ </button>
496
+ <button
497
+ type="button"
498
+ class=${`flex-1 px-3 py-2 text-sm font-medium transition-colors ${!isIconsTab ? "border-b-2 border-primary text-foreground" : "text-muted-foreground hover:text-foreground"}`}
499
+ @click=${() => {
500
+ this._pickerTab = "emojis";
501
+ this._iconSearch = "";
502
+ }}
503
+ >
504
+ ${this.labels.emojisTab}
505
+ </button>
506
+ </div>
507
+
508
+ <!-- Color presets (icons tab only) -->
509
+ ${isIconsTab
510
+ ? html`<div class="pt-2">${this.#renderPickerColorPresets()}</div>`
511
+ : nothing}
512
+
513
+ <!-- Search -->
514
+ <div class="px-3 py-2">
515
+ <input
516
+ type="search"
517
+ class="input text-sm w-full"
518
+ placeholder=${searchPlaceholder}
519
+ .value=${this._iconSearch}
520
+ @input=${(event: Event) => {
521
+ const target = event.target as HTMLInputElement;
522
+ this._iconSearch = target.value;
523
+ }}
524
+ />
525
+ </div>
526
+
527
+ <!-- Grid -->
528
+ <div class="overflow-y-auto max-h-80">
529
+ ${isIconsTab ? this.#renderIconsGrid() : this.#renderEmojisGrid()}
530
+ </div>
531
+
532
+ <!-- Remove button -->
533
+ ${hasIcon
534
+ ? html`
535
+ <div class="border-t border-border px-3 py-2">
536
+ <button
537
+ type="button"
538
+ class="btn-ghost text-sm w-full"
539
+ @click=${() => this.#removeIcon()}
540
+ >
541
+ ${this.labels.removeIcon}
542
+ </button>
543
+ </div>
544
+ `
545
+ : nothing}
546
+ </div>
547
+ `;
548
+ }
549
+
550
+ render() {
551
+ return html`
552
+ <form
553
+ class="flex flex-col gap-4 max-w-lg"
554
+ @submit=${(event: Event) => this.#handleSubmit(event)}
555
+ >
556
+ <div class="field">
557
+ <label class="label">${this.labels.titleLabel}</label>
558
+ <div class="relative">
559
+ ${this.#renderInlineIconTrigger()}
560
+ <input
561
+ type="text"
562
+ class="input pl-12"
563
+ required
564
+ .value=${this._title}
565
+ placeholder=${this.isEdit
566
+ ? nothing
567
+ : this.labels.titlePlaceholder}
568
+ @input=${(event: Event) => {
569
+ const target = event.target as HTMLInputElement;
570
+ this._title = target.value;
571
+ if (!this.isEdit) {
572
+ const currentTitle = target.value;
573
+ slugify(currentTitle).then((slug) => {
574
+ if (this._title === currentTitle) {
575
+ this._slug = slug;
576
+ }
577
+ });
578
+ }
579
+ }}
580
+ />
581
+ ${this.#renderIconPopover()}
582
+ </div>
583
+ </div>
584
+
585
+ <div class="field">
586
+ <label class="label">${this.labels.slugLabel}</label>
587
+ <input
588
+ type="text"
589
+ class="input"
590
+ required
591
+ pattern="[a-z0-9\\-]+"
592
+ .value=${this._slug}
593
+ placeholder=${this.isEdit ? nothing : "my-collection"}
594
+ @input=${(event: Event) => {
595
+ const target = event.target as HTMLInputElement;
596
+ this._slug = target.value.toLowerCase();
597
+ }}
598
+ />
599
+ ${this.isEdit
600
+ ? nothing
601
+ : html`<p class="text-xs text-muted-foreground mt-1">
602
+ ${this.labels.slugHelp}
603
+ </p>`}
604
+ </div>
605
+
606
+ <div class="field">
607
+ <label class="label">${this.labels.descriptionLabel}</label>
608
+ <textarea
609
+ class="textarea"
610
+ rows="3"
611
+ .value=${this._description}
612
+ placeholder=${this.isEdit
613
+ ? nothing
614
+ : this.labels.descriptionPlaceholder}
615
+ @input=${(event: Event) => {
616
+ const target = event.target as HTMLTextAreaElement;
617
+ this._description = target.value;
618
+ }}
619
+ ></textarea>
620
+ </div>
621
+
622
+ <div class="field">
623
+ <label class="label">${this.labels.sortOrderLabel}</label>
624
+ <select
625
+ class="select"
626
+ .value=${this._sortOrder}
627
+ @change=${(event: Event) => {
628
+ const target = event.target as HTMLSelectElement;
629
+ this._sortOrder = target.value;
630
+ }}
631
+ >
632
+ <option value="newest">${this.labels.sortNewest}</option>
633
+ <option value="oldest">${this.labels.sortOldest}</option>
634
+ <option value="rating_desc">${this.labels.sortRatingDesc}</option>
635
+ <option value="rating_asc">${this.labels.sortRatingAsc}</option>
636
+ </select>
637
+ </div>
638
+
639
+ <div class="flex gap-2">
640
+ <button type="submit" class="btn" ?disabled=${this._loading}>
641
+ ${this._loading
642
+ ? html`<svg
643
+ class="animate-spin size-4"
644
+ xmlns="http://www.w3.org/2000/svg"
645
+ viewBox="0 0 24 24"
646
+ fill="none"
647
+ stroke="currentColor"
648
+ stroke-width="2"
649
+ stroke-linecap="round"
650
+ stroke-linejoin="round"
651
+ role="status"
652
+ >
653
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
654
+ </svg>`
655
+ : nothing}
656
+ ${this.labels.submitLabel}
657
+ </button>
658
+ <a href=${this.cancelHref} class="btn-outline">
659
+ ${this.labels.cancelLabel}
660
+ </a>
661
+ </div>
662
+ </form>
663
+ `;
664
+ }
665
+ }
666
+
667
+ customElements.define("jant-collection-form", JantCollectionForm);