@jant/core 0.3.34 → 0.3.36

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 (156) hide show
  1. package/dist/client/assets/module-RjUF93sV.js +716 -0
  2. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  3. package/dist/client/assets/url-8Dj-5CLW.js +1 -0
  4. package/dist/client/client.css +1 -1
  5. package/dist/client/client.js +3109 -2294
  6. package/dist/index.js +3327 -3031
  7. package/package.json +13 -4
  8. package/src/__tests__/helpers/app.ts +1 -1
  9. package/src/__tests__/helpers/db.ts +6 -0
  10. package/src/app.tsx +1 -5
  11. package/src/{lib → client}/avatar-upload.ts +1 -1
  12. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  13. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  14. package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
  15. package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
  16. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
  17. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  18. package/src/client/components/collection-sidebar-types.ts +45 -0
  19. package/src/{ui → client}/components/collection-types.ts +3 -4
  20. package/src/{ui → client}/components/compose-types.ts +3 -1
  21. package/src/{ui → client}/components/jant-collection-form.ts +301 -182
  22. package/src/client/components/jant-collection-sidebar.ts +801 -0
  23. package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
  24. package/src/client/components/jant-compose-editor.ts +1249 -0
  25. package/src/client/components/jant-compose-fullscreen.ts +338 -0
  26. package/src/client/components/jant-media-lightbox.ts +257 -0
  27. package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
  28. package/src/{ui → client}/components/jant-post-form.ts +57 -8
  29. package/src/{ui → client}/components/jant-settings-general.ts +2 -2
  30. package/src/{ui → client}/components/nav-manager-types.ts +3 -0
  31. package/src/{ui → client}/components/post-form-template.ts +35 -31
  32. package/src/{ui → client}/components/post-form-types.ts +7 -3
  33. package/src/{lib → client}/compose-bridge.ts +9 -7
  34. package/src/client/lazy-slugify.ts +51 -0
  35. package/src/{lib → client}/media-upload.ts +16 -3
  36. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  37. package/src/client/page-slug-bridge.ts +42 -0
  38. package/src/{lib → client}/post-form-bridge.ts +2 -2
  39. package/src/{lib → client}/settings-bridge.ts +3 -3
  40. package/src/client/tiptap/bubble-menu.ts +205 -0
  41. package/src/client/tiptap/create-editor.ts +40 -0
  42. package/src/client/tiptap/exitable-marks.ts +73 -0
  43. package/src/client/tiptap/extensions.ts +60 -0
  44. package/src/client/tiptap/image-node.ts +488 -0
  45. package/src/client/tiptap/link-toolbar.ts +371 -0
  46. package/src/client/tiptap/more-break.ts +50 -0
  47. package/src/client/tiptap/paste-image.ts +140 -0
  48. package/src/client/tiptap/slash-commands.ts +328 -0
  49. package/src/{types → client/types}/sortablejs.d.ts +1 -1
  50. package/src/client.ts +24 -17
  51. package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
  52. package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
  53. package/src/db/schema.ts +6 -1
  54. package/src/i18n/locales/en.po +641 -215
  55. package/src/i18n/locales/en.ts +1 -1
  56. package/src/i18n/locales/zh-Hans.po +642 -204
  57. package/src/i18n/locales/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/zh-Hant.po +642 -204
  59. package/src/i18n/locales/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  61. package/src/lib/__tests__/schemas.test.ts +9 -6
  62. package/src/lib/__tests__/url.test.ts +2 -2
  63. package/src/lib/__tests__/view.test.ts +9 -9
  64. package/src/lib/emoji-catalog.ts +146 -0
  65. package/src/lib/feed.ts +1 -1
  66. package/src/lib/media-helpers.ts +10 -9
  67. package/src/lib/render.tsx +4 -3
  68. package/src/lib/resolve-config.ts +8 -1
  69. package/src/lib/schemas.ts +2 -3
  70. package/src/lib/summary.ts +92 -0
  71. package/src/lib/timeline.ts +2 -0
  72. package/src/lib/tiptap-render.ts +196 -0
  73. package/src/lib/upload.ts +97 -9
  74. package/src/lib/url.ts +7 -23
  75. package/src/lib/view.ts +33 -19
  76. package/src/middleware/error-handler.ts +3 -3
  77. package/src/preset.css +38 -0
  78. package/src/routes/api/collections.ts +20 -3
  79. package/src/routes/api/posts.ts +48 -33
  80. package/src/routes/api/upload.ts +7 -5
  81. package/src/routes/auth/reset.tsx +5 -4
  82. package/src/routes/auth/setup.tsx +26 -11
  83. package/src/routes/auth/signin.tsx +10 -7
  84. package/src/routes/compose.tsx +20 -11
  85. package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
  86. package/src/routes/dash/index.tsx +7 -1
  87. package/src/routes/dash/media.tsx +3 -0
  88. package/src/routes/dash/pages.tsx +8 -2
  89. package/src/routes/dash/posts.tsx +6 -2
  90. package/src/routes/dash/redirects.tsx +15 -9
  91. package/src/routes/dash/settings.tsx +336 -32
  92. package/src/routes/feed/__tests__/rss.test.ts +245 -6
  93. package/src/routes/feed/rss.ts +70 -6
  94. package/src/routes/pages/__tests__/featured.test.ts +6 -7
  95. package/src/routes/pages/archive.tsx +11 -7
  96. package/src/routes/pages/collection.tsx +32 -15
  97. package/src/routes/pages/collections.tsx +11 -2
  98. package/src/routes/pages/featured.tsx +1 -1
  99. package/src/routes/pages/home.tsx +1 -1
  100. package/src/services/__tests__/post.test.ts +124 -33
  101. package/src/services/__tests__/settings.test.ts +3 -3
  102. package/src/services/page.ts +16 -3
  103. package/src/services/post.ts +96 -37
  104. package/src/services/search.ts +4 -2
  105. package/src/services/settings.ts +6 -2
  106. package/src/styles/components.css +240 -60
  107. package/src/styles/tokens.css +10 -0
  108. package/src/styles/ui.css +1157 -81
  109. package/src/types/bindings.ts +5 -0
  110. package/src/types/config.ts +23 -1
  111. package/src/types/constants.ts +3 -0
  112. package/src/types/entities.ts +9 -2
  113. package/src/types/operations.ts +9 -3
  114. package/src/types/props.ts +3 -3
  115. package/src/types/views.ts +3 -2
  116. package/src/ui/compose/ComposeDialog.tsx +24 -7
  117. package/src/ui/dash/PageForm.tsx +2 -0
  118. package/src/ui/dash/PostList.tsx +5 -5
  119. package/src/ui/dash/StatusBadge.tsx +13 -5
  120. package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
  121. package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
  122. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  123. package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
  124. package/src/ui/dash/media/MediaListContent.tsx +9 -4
  125. package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
  126. package/src/ui/dash/pages/PagesContent.tsx +2 -1
  127. package/src/ui/dash/posts/PostForm.tsx +19 -7
  128. package/src/ui/dash/settings/AccountContent.tsx +133 -138
  129. package/src/ui/dash/settings/AvatarContent.tsx +70 -0
  130. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  131. package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
  132. package/src/ui/layouts/DashLayout.tsx +157 -75
  133. package/src/ui/layouts/SiteLayout.tsx +13 -13
  134. package/src/ui/pages/ArchivePage.tsx +10 -7
  135. package/src/ui/pages/CollectionPage.tsx +6 -35
  136. package/src/ui/pages/CollectionsPage.tsx +2 -1
  137. package/src/ui/pages/FeaturedPage.tsx +2 -1
  138. package/src/ui/pages/HomePage.tsx +1 -1
  139. package/src/ui/pages/SearchPage.tsx +1 -1
  140. package/src/ui/shared/CollectionsSidebar.tsx +228 -3
  141. package/src/ui/shared/MediaGallery.tsx +179 -41
  142. package/src/lib/collections-reorder.ts +0 -28
  143. package/src/routes/dash/appearance.tsx +0 -240
  144. package/src/routes/dash/collections.tsx +0 -211
  145. package/src/ui/components/jant-compose-editor.ts +0 -814
  146. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  147. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  148. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  149. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  150. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  151. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  152. /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
  153. /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
  154. /package/src/{ui → client}/components/settings-types.ts +0 -0
  155. /package/src/{lib → client}/image-processor.ts +0 -0
  156. /package/src/{lib → client}/toast.ts +0 -0
@@ -38,7 +38,8 @@ export interface ComposeLabels {
38
38
  attachedTextHint: string;
39
39
  done: string;
40
40
  media: string;
41
- score: string;
41
+ rate: string;
42
+ emoji: string;
42
43
  title: string;
43
44
  collection: string;
44
45
  searchCollections: string;
@@ -51,6 +52,7 @@ export interface ComposeLabels {
51
52
  addMore: string;
52
53
  uploading: string;
53
54
  published: string;
55
+ retryAll: string;
54
56
  }
55
57
 
56
58
  export interface ComposeSubmitDetail {
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * Handles create/edit collection form interactions:
5
5
  * - Maintains form state for title, slug, description, sort order, and icon
6
- * - Opens the icon picker dialog with search and color presets
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
7
9
  * - Dispatches `jant:collection-submit` for the bridge to POST to the server
8
10
  *
9
11
  * Light DOM only — BaseCoat and Tailwind classes apply directly.
@@ -11,10 +13,10 @@
11
13
 
12
14
  import { LitElement, html, nothing } from "lit";
13
15
  import type { PropertyValueMap } from "lit";
14
- import { classMap } from "lit/directives/class-map.js";
15
16
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
16
17
  import {
17
18
  DEFAULT_ICON_COLOR,
19
+ DEFAULT_ICON_NAME,
18
20
  ICON_COLOR_PRESETS,
19
21
  createIconValue,
20
22
  parseCollectionIcon,
@@ -22,6 +24,8 @@ import {
22
24
  getIconSvg,
23
25
  } from "../../lib/icons.js";
24
26
  import { ICON_CATALOG } from "../../lib/icon-catalog.js";
27
+ import { EMOJI_CATALOG } from "../../lib/emoji-catalog.js";
28
+ import { slugify } from "../lazy-slugify.js";
25
29
  import type {
26
30
  CollectionFormInitial,
27
31
  CollectionFormLabels,
@@ -33,14 +37,10 @@ type CatalogCategory = {
33
37
  icons: Array<{ name: string; svg: string }>;
34
38
  };
35
39
 
36
- function slugifyTitle(title: string): string {
37
- return title
38
- .toLowerCase()
39
- .trim()
40
- .replace(/[^\w\s-]/g, "")
41
- .replace(/[\s_-]+/g, "-")
42
- .replace(/^-+|-+$/g, "");
43
- }
40
+ type EmojiCategory = {
41
+ name: string;
42
+ emojis: string[];
43
+ };
44
44
 
45
45
  export class JantCollectionForm extends LitElement {
46
46
  static properties = {
@@ -57,7 +57,10 @@ export class JantCollectionForm extends LitElement {
57
57
  _iconName: { state: true },
58
58
  _iconSvg: { state: true },
59
59
  _iconColor: { state: true },
60
+ _iconEmoji: { state: true },
60
61
  _iconSearch: { state: true },
62
+ _pickerOpen: { state: true },
63
+ _pickerTab: { state: true },
61
64
  _loading: { state: true },
62
65
  };
63
66
 
@@ -74,10 +77,27 @@ export class JantCollectionForm extends LitElement {
74
77
  declare _iconName: string;
75
78
  declare _iconSvg: string;
76
79
  declare _iconColor: string;
80
+ declare _iconEmoji: string;
77
81
  declare _iconSearch: string;
82
+ declare _pickerOpen: boolean;
83
+ declare _pickerTab: "icons" | "emojis";
78
84
  declare _loading: boolean;
79
85
 
80
86
  #initialized = false;
87
+ #closePickerHandler = (e: Event) => {
88
+ const target = e.target as HTMLElement | null;
89
+ if (!target) return;
90
+ const pickerEl = this.querySelector<HTMLElement>("[data-icon-picker]");
91
+ const triggerEl = this.querySelector<HTMLElement>("[data-icon-trigger]");
92
+ if (
93
+ pickerEl &&
94
+ !pickerEl.contains(target) &&
95
+ triggerEl &&
96
+ !triggerEl.contains(target)
97
+ ) {
98
+ this._pickerOpen = false;
99
+ }
100
+ };
81
101
 
82
102
  createRenderRoot() {
83
103
  this.innerHTML = "";
@@ -105,7 +125,10 @@ export class JantCollectionForm extends LitElement {
105
125
  this._iconName = "";
106
126
  this._iconSvg = "";
107
127
  this._iconColor = DEFAULT_ICON_COLOR;
128
+ this._iconEmoji = "";
108
129
  this._iconSearch = "";
130
+ this._pickerOpen = false;
131
+ this._pickerTab = "icons";
109
132
  this._loading = false;
110
133
  }
111
134
 
@@ -126,6 +149,16 @@ export class JantCollectionForm extends LitElement {
126
149
  return this._loading;
127
150
  }
128
151
 
152
+ connectedCallback() {
153
+ super.connectedCallback();
154
+ document.addEventListener("click", this.#closePickerHandler, true);
155
+ }
156
+
157
+ disconnectedCallback() {
158
+ super.disconnectedCallback();
159
+ document.removeEventListener("click", this.#closePickerHandler, true);
160
+ }
161
+
129
162
  #applyInitialData() {
130
163
  if (!this.initial) return;
131
164
  this.#initialized = true;
@@ -134,29 +167,44 @@ export class JantCollectionForm extends LitElement {
134
167
  this._description = this.initial.description ?? "";
135
168
  this._sortOrder = this.initial.sortOrder ?? "newest";
136
169
 
137
- const parsed = parseCollectionIcon(this.initial.icon ?? "");
170
+ const rawIcon = this.initial.icon ?? "";
171
+ const parsed = parseCollectionIcon(rawIcon);
138
172
  if (parsed) {
139
173
  this._iconName = parsed.name;
140
174
  this._iconSvg = parsed.svg;
141
175
  this._iconColor = parsed.color || DEFAULT_ICON_COLOR;
176
+ this._iconEmoji = "";
177
+ } else if (rawIcon && !rawIcon.startsWith("{")) {
178
+ // Legacy emoji value
179
+ this._iconEmoji = rawIcon;
180
+ this._iconName = "";
181
+ this._iconSvg = "";
182
+ this._iconColor = DEFAULT_ICON_COLOR;
142
183
  } else {
143
184
  this._iconName = "";
144
185
  this._iconSvg = "";
145
186
  this._iconColor = DEFAULT_ICON_COLOR;
187
+ this._iconEmoji = "";
188
+ // Default icon in create mode
189
+ if (!this.isEdit) {
190
+ this.#applyDefaultIcon();
191
+ }
146
192
  }
147
193
  }
148
194
 
149
- disconnectedCallback() {
150
- super.disconnectedCallback();
151
- const dialog = this.#iconDialog;
152
- if (dialog?.open) dialog.close();
153
- }
154
-
155
- get #iconDialog(): HTMLDialogElement | null {
156
- return this.querySelector<HTMLDialogElement>("#collection-icon-dialog");
195
+ #applyDefaultIcon() {
196
+ const svg = getIconSvg(DEFAULT_ICON_NAME);
197
+ if (svg) {
198
+ this._iconName = DEFAULT_ICON_NAME;
199
+ this._iconSvg = svg;
200
+ this._iconColor = DEFAULT_ICON_COLOR;
201
+ }
157
202
  }
158
203
 
159
204
  get #iconValue(): string {
205
+ if (this._iconEmoji) {
206
+ return this._iconEmoji;
207
+ }
160
208
  if (this._iconName && this._iconSvg) {
161
209
  return createIconValue(
162
210
  this._iconName,
@@ -185,12 +233,47 @@ export class JantCollectionForm extends LitElement {
185
233
  return result;
186
234
  }
187
235
 
188
- #openDialog() {
189
- this.#iconDialog?.showModal();
236
+ #filteredEmojiCatalog(): EmojiCategory[] {
237
+ const q = this._iconSearch.trim().toLowerCase();
238
+ const result: EmojiCategory[] = [];
239
+ for (const [category, emojis] of Object.entries(EMOJI_CATALOG)) {
240
+ if (q && !category.includes(q)) continue;
241
+ result.push({ name: category, emojis });
242
+ }
243
+ return result;
244
+ }
245
+
246
+ #togglePicker(e: Event) {
247
+ e.stopPropagation();
248
+ this._pickerOpen = !this._pickerOpen;
249
+ this._iconSearch = "";
250
+ }
251
+
252
+ #selectIcon(name: string, svg: string) {
253
+ this._iconName = name;
254
+ this._iconSvg = svg;
255
+ this._iconEmoji = "";
256
+ if (!this._iconColor) {
257
+ this._iconColor = DEFAULT_ICON_COLOR;
258
+ }
259
+ this._iconSearch = "";
260
+ this._pickerOpen = false;
261
+ }
262
+
263
+ #selectEmoji(emoji: string) {
264
+ this._iconEmoji = emoji;
265
+ this._iconName = "";
266
+ this._iconSvg = "";
267
+ this._iconSearch = "";
268
+ this._pickerOpen = false;
190
269
  }
191
270
 
192
- #closeDialog() {
193
- this.#iconDialog?.close();
271
+ #removeIcon() {
272
+ this._iconName = "";
273
+ this._iconSvg = "";
274
+ this._iconColor = DEFAULT_ICON_COLOR;
275
+ this._iconEmoji = "";
276
+ this._pickerOpen = false;
194
277
  }
195
278
 
196
279
  #handleSubmit(e: Event) {
@@ -221,48 +304,47 @@ export class JantCollectionForm extends LitElement {
221
304
  );
222
305
  }
223
306
 
224
- #renderIconPreview() {
307
+ #renderTriggerIcon() {
308
+ if (this._iconEmoji) {
309
+ return html`<span class="text-lg leading-none">${this._iconEmoji}</span>`;
310
+ }
225
311
  if (this._iconSvg) {
226
312
  const htmlString = renderCollectionIcon(this.#iconValue, {
227
- size: 24,
313
+ size: 20,
228
314
  fallback: false,
229
315
  });
230
316
  return html`<span
231
- class="w-6 h-6 flex items-center justify-center"
317
+ class="w-5 h-5 flex items-center justify-center"
232
318
  style=${`color:${this._iconColor}`}
233
319
  >
234
320
  ${unsafeHTML(htmlString)}
235
321
  </span>`;
236
322
  }
237
- if (this.initial.icon && !this.initial.icon.startsWith("{")) {
238
- const htmlString = renderCollectionIcon(this.initial.icon, {
239
- size: 24,
240
- fallback: false,
241
- });
242
- if (htmlString) {
243
- return html`<span class="w-6 h-6 flex items-center justify-center">
244
- ${unsafeHTML(htmlString)}
245
- </span>`;
246
- }
247
- }
248
- return html`<span class="text-muted-foreground text-lg">?</span>`;
323
+ return html`<span class="text-muted-foreground text-base">+</span>`;
249
324
  }
250
325
 
251
- #renderColorPresets() {
252
- if (!this._iconSvg) return nothing;
326
+ #renderInlineIconTrigger() {
253
327
  return html`
254
- <div class="flex items-center gap-2 mt-2">
328
+ <button
329
+ type="button"
330
+ data-icon-trigger
331
+ 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"
332
+ @click=${(e: Event) => this.#togglePicker(e)}
333
+ >
334
+ ${this.#renderTriggerIcon()}
335
+ </button>
336
+ `;
337
+ }
338
+
339
+ #renderPickerColorPresets() {
340
+ return html`
341
+ <div class="flex items-center gap-1.5 px-3 pb-2">
255
342
  ${ICON_COLOR_PRESETS.map((preset) => {
256
343
  const isActive = this._iconColor === preset.value;
257
344
  return html`
258
345
  <button
259
346
  type="button"
260
- class=${classMap({
261
- "w-6 h-6 rounded-full border-2 transition-transform hover:scale-110": true,
262
- "ring-2": isActive,
263
- "ring-offset-1": isActive,
264
- "ring-primary": isActive,
265
- })}
347
+ class=${`w-5 h-5 rounded-full border-2 transition-transform hover:scale-110${isActive ? " ring-2 ring-offset-1 ring-primary" : ""}`}
266
348
  style=${`background-color:${preset.value}; border-color: transparent`}
267
349
  title=${preset.name}
268
350
  @click=${() => {
@@ -275,97 +357,157 @@ export class JantCollectionForm extends LitElement {
275
357
  `;
276
358
  }
277
359
 
278
- #renderIconDialog() {
360
+ #renderIconsGrid() {
279
361
  const categories = this.#filteredCatalog();
280
- return html`
281
- <dialog
282
- id="collection-icon-dialog"
283
- class="m-auto rounded-lg border border-border bg-background text-foreground p-0 w-full max-w-md max-h-[80vh] shadow-lg backdrop:bg-black/50"
284
- @cancel=${() => this.#closeDialog()}
285
- >
286
- <div class="flex flex-col max-h-[80vh]">
287
- <div class="flex flex-col gap-3 p-4 border-b border-border">
288
- <div class="flex items-center justify-between">
289
- <h2 class="font-semibold">${this.labels.dialogTitle}</h2>
290
- <button
291
- type="button"
292
- class="btn-ghost text-sm"
293
- @click=${() => this.#closeDialog()}
294
- >
295
- ${this.labels.dialogClose}
296
- </button>
297
- </div>
298
- <input
299
- type="search"
300
- class="input text-sm"
301
- placeholder=${this.labels.searchIconsPlaceholder}
302
- .value=${this._iconSearch}
303
- @input=${(event: Event) => {
304
- const target = event.target as HTMLInputElement;
305
- this._iconSearch = target.value;
306
- }}
307
- />
362
+ if (categories.length === 0) {
363
+ return html`<p class="text-sm text-muted-foreground px-3 py-2">
364
+ No icons found
365
+ </p>`;
366
+ }
367
+ return categories.map(
368
+ (category) => html`
369
+ <div class="flex flex-col gap-1.5 mb-3" data-category=${category.name}>
370
+ <h3
371
+ class="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3"
372
+ >
373
+ ${category.name}
374
+ </h3>
375
+ <div class="grid grid-cols-8 gap-0.5 px-2">
376
+ ${category.icons.map(
377
+ (icon) => html`
378
+ <button
379
+ type="button"
380
+ 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
+ data-icon-name=${icon.name}
382
+ style=${`color:${this._iconColor}`}
383
+ @click=${() => this.#selectIcon(icon.name, icon.svg)}
384
+ >
385
+ <span class="w-4 h-4 flex items-center justify-center">
386
+ ${unsafeHTML(
387
+ icon.svg
388
+ .replace(/width="24"/, 'width="16"')
389
+ .replace(/height="24"/, 'height="16"'),
390
+ )}
391
+ </span>
392
+ </button>
393
+ `,
394
+ )}
308
395
  </div>
309
- <div class="overflow-y-auto p-4 flex-1">
310
- ${categories.length === 0
311
- ? html`<p class="text-sm text-muted-foreground">
312
- ${this.labels.searchIconsPlaceholder}
313
- </p>`
314
- : categories.map(
315
- (category) => html`
316
- <div
317
- class="flex flex-col gap-2 mb-4"
318
- data-category=${category.name}
319
- >
320
- <h3
321
- class="text-xs font-medium text-muted-foreground uppercase tracking-wider"
322
- >
323
- ${category.name}
324
- </h3>
325
- <div class="grid grid-cols-8 gap-1">
326
- ${category.icons.map(
327
- (icon) => html`
328
- <button
329
- type="button"
330
- class=${classMap({
331
- "flex items-center justify-center w-9 h-9 rounded-md hover:bg-accent transition-colors": true,
332
- "ring-2":
333
- this._iconName === icon.name &&
334
- this._iconSvg === icon.svg,
335
- "ring-primary":
336
- this._iconName === icon.name &&
337
- this._iconSvg === icon.svg,
338
- })}
339
- data-icon-name=${icon.name}
340
- @click=${() => {
341
- this._iconName = icon.name;
342
- this._iconSvg = icon.svg;
343
- if (!this._iconColor) {
344
- this._iconColor = DEFAULT_ICON_COLOR;
345
- }
346
- this._iconSearch = "";
347
- this.#closeDialog();
348
- }}
349
- >
350
- <span
351
- class="w-5 h-5 flex items-center justify-center"
352
- >
353
- ${unsafeHTML(
354
- icon.svg
355
- .replace(/width="24"/, 'width="20"')
356
- .replace(/height="24"/, 'height="20"'),
357
- )}
358
- </span>
359
- </button>
360
- `,
361
- )}
362
- </div>
363
- </div>
364
- `,
365
- )}
396
+ </div>
397
+ `,
398
+ );
399
+ }
400
+
401
+ #renderEmojisGrid() {
402
+ const categories = this.#filteredEmojiCatalog();
403
+ if (categories.length === 0) {
404
+ return html`<p class="text-sm text-muted-foreground px-3 py-2">
405
+ No emojis found
406
+ </p>`;
407
+ }
408
+ return categories.map(
409
+ (category) => html`
410
+ <div class="flex flex-col gap-1.5 mb-3" data-category=${category.name}>
411
+ <h3
412
+ class="text-xs font-medium text-muted-foreground uppercase tracking-wider px-3"
413
+ >
414
+ ${category.name}
415
+ </h3>
416
+ <div class="grid grid-cols-8 gap-0.5 px-2">
417
+ ${category.emojis.map(
418
+ (emoji) => html`
419
+ <button
420
+ type="button"
421
+ 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" : ""}`}
422
+ @click=${() => this.#selectEmoji(emoji)}
423
+ >
424
+ ${emoji}
425
+ </button>
426
+ `,
427
+ )}
366
428
  </div>
367
429
  </div>
368
- </dialog>
430
+ `,
431
+ );
432
+ }
433
+
434
+ #renderIconPopover() {
435
+ if (!this._pickerOpen) return nothing;
436
+
437
+ const isIconsTab = this._pickerTab === "icons";
438
+ const searchPlaceholder = isIconsTab
439
+ ? this.labels.searchIconsPlaceholder
440
+ : this.labels.searchEmojisPlaceholder;
441
+ const hasIcon = this._iconSvg || this._iconEmoji;
442
+
443
+ return html`
444
+ <div
445
+ data-icon-picker
446
+ class="absolute left-0 top-full mt-1 z-50 w-80 rounded-lg border border-border bg-background shadow-lg"
447
+ >
448
+ <!-- Tabs -->
449
+ <div class="flex border-b border-border">
450
+ <button
451
+ type="button"
452
+ 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"}`}
453
+ @click=${() => {
454
+ this._pickerTab = "icons";
455
+ this._iconSearch = "";
456
+ }}
457
+ >
458
+ ${this.labels.iconsTab}
459
+ </button>
460
+ <button
461
+ type="button"
462
+ 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"}`}
463
+ @click=${() => {
464
+ this._pickerTab = "emojis";
465
+ this._iconSearch = "";
466
+ }}
467
+ >
468
+ ${this.labels.emojisTab}
469
+ </button>
470
+ </div>
471
+
472
+ <!-- Color presets (icons tab only) -->
473
+ ${isIconsTab
474
+ ? html`<div class="pt-2">${this.#renderPickerColorPresets()}</div>`
475
+ : nothing}
476
+
477
+ <!-- Search -->
478
+ <div class="px-3 py-2">
479
+ <input
480
+ type="search"
481
+ class="input text-sm w-full"
482
+ placeholder=${searchPlaceholder}
483
+ .value=${this._iconSearch}
484
+ @input=${(event: Event) => {
485
+ const target = event.target as HTMLInputElement;
486
+ this._iconSearch = target.value;
487
+ }}
488
+ />
489
+ </div>
490
+
491
+ <!-- Grid -->
492
+ <div class="overflow-y-auto max-h-56">
493
+ ${isIconsTab ? this.#renderIconsGrid() : this.#renderEmojisGrid()}
494
+ </div>
495
+
496
+ <!-- Remove button -->
497
+ ${hasIcon
498
+ ? html`
499
+ <div class="border-t border-border px-3 py-2">
500
+ <button
501
+ type="button"
502
+ class="btn-ghost text-sm w-full"
503
+ @click=${() => this.#removeIcon()}
504
+ >
505
+ ${this.labels.removeIcon}
506
+ </button>
507
+ </div>
508
+ `
509
+ : nothing}
510
+ </div>
369
511
  `;
370
512
  }
371
513
 
@@ -377,20 +519,31 @@ export class JantCollectionForm extends LitElement {
377
519
  >
378
520
  <div class="field">
379
521
  <label class="label">${this.labels.titleLabel}</label>
380
- <input
381
- type="text"
382
- class="input"
383
- required
384
- .value=${this._title}
385
- placeholder=${this.isEdit ? nothing : this.labels.titlePlaceholder}
386
- @input=${(event: Event) => {
387
- const target = event.target as HTMLInputElement;
388
- this._title = target.value;
389
- if (!this.isEdit) {
390
- this._slug = slugifyTitle(target.value);
391
- }
392
- }}
393
- />
522
+ <div class="relative">
523
+ ${this.#renderInlineIconTrigger()}
524
+ <input
525
+ type="text"
526
+ class="input pl-12"
527
+ required
528
+ .value=${this._title}
529
+ placeholder=${this.isEdit
530
+ ? nothing
531
+ : this.labels.titlePlaceholder}
532
+ @input=${(event: Event) => {
533
+ const target = event.target as HTMLInputElement;
534
+ this._title = target.value;
535
+ if (!this.isEdit) {
536
+ const currentTitle = target.value;
537
+ slugify(currentTitle).then((slug) => {
538
+ if (this._title === currentTitle) {
539
+ this._slug = slug;
540
+ }
541
+ });
542
+ }
543
+ }}
544
+ />
545
+ ${this.#renderIconPopover()}
546
+ </div>
394
547
  </div>
395
548
 
396
549
  <div class="field">
@@ -430,38 +583,6 @@ export class JantCollectionForm extends LitElement {
430
583
  ></textarea>
431
584
  </div>
432
585
 
433
- <div class="field">
434
- <label class="label">${this.labels.iconLabel}</label>
435
- <div class="flex items-center gap-3">
436
- <div
437
- class="flex items-center justify-center w-10 h-10 rounded-md border border-border"
438
- >
439
- ${this.#renderIconPreview()}
440
- </div>
441
- <button
442
- type="button"
443
- class="btn-outline text-sm"
444
- @click=${() => this.#openDialog()}
445
- >
446
- ${this.labels.chooseIcon}
447
- </button>
448
- ${this._iconSvg
449
- ? html`<button
450
- type="button"
451
- class="btn-ghost text-sm"
452
- @click=${() => {
453
- this._iconName = "";
454
- this._iconSvg = "";
455
- this._iconColor = DEFAULT_ICON_COLOR;
456
- }}
457
- >
458
- ${this.labels.removeIcon}
459
- </button>`
460
- : nothing}
461
- </div>
462
- ${this.#renderColorPresets()}
463
- </div>
464
-
465
586
  <div class="field">
466
587
  <label class="label">${this.labels.sortOrderLabel}</label>
467
588
  <select
@@ -503,8 +624,6 @@ export class JantCollectionForm extends LitElement {
503
624
  </a>
504
625
  </div>
505
626
  </form>
506
-
507
- ${this.#renderIconDialog()}
508
627
  `;
509
628
  }
510
629
  }