@jant/core 0.3.35 → 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 +3026 -2778
  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 +7 -7
  93. package/src/routes/feed/rss.ts +8 -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
@@ -0,0 +1,801 @@
1
+ /**
2
+ * Collection Sidebar Component
3
+ *
4
+ * Manages collections in the public /c page sidebar for authenticated users:
5
+ * - Renders collections + dividers as an interleaved sorted list
6
+ * - Dropdown menus for "More" (reorder, add divider) and per-collection edit
7
+ * - SortableJS drag-and-drop reorder mode
8
+ * - Create/edit collection dialogs embedding <jant-collection-form>
9
+ * - Divider CRUD
10
+ *
11
+ * Anonymous users see a static list rendered server-side; this component
12
+ * is only instantiated for authenticated users.
13
+ *
14
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
15
+ */
16
+
17
+ import { LitElement, html, nothing } from "lit";
18
+ import type { PropertyValueMap } from "lit";
19
+ import { classMap } from "lit/directives/class-map.js";
20
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
21
+ import Sortable from "sortablejs";
22
+ import { showToast } from "../toast.js";
23
+ import { renderCollectionIcon } from "../../lib/icons.js";
24
+ import type { CollectionSubmitDetail } from "./collection-types.js";
25
+ import type {
26
+ CollectionSidebarLabels,
27
+ SidebarCollection,
28
+ SidebarDivider,
29
+ SidebarItem,
30
+ } from "./collection-sidebar-types.js";
31
+
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
+ export class JantCollectionSidebar extends LitElement {
45
+ static properties = {
46
+ collections: { type: Array },
47
+ dividers: { type: Array },
48
+ labels: { type: Object },
49
+ activeSlug: { type: String, attribute: "active-slug" },
50
+
51
+ _items: { state: true },
52
+ _reorderMode: { state: true },
53
+ _dialogMode: { state: true },
54
+ _editingCollection: { state: true },
55
+ _showMoreMenu: { state: true },
56
+ _hoveringId: { state: true },
57
+ _showItemMenuId: { state: true },
58
+ };
59
+
60
+ declare collections: SidebarCollection[];
61
+ declare dividers: SidebarDivider[];
62
+ declare labels: CollectionSidebarLabels;
63
+ declare activeSlug: string;
64
+
65
+ declare _items: SidebarItem[];
66
+ declare _reorderMode: boolean;
67
+ declare _dialogMode: "create" | "edit" | null;
68
+ declare _editingCollection: SidebarCollection | null;
69
+ declare _showMoreMenu: boolean;
70
+ declare _hoveringId: number | null;
71
+ declare _showItemMenuId: number | null;
72
+
73
+ #sortable: { destroy(): void } | null = null;
74
+ #initialized = false;
75
+
76
+ #closeMoreMenu = () => {
77
+ this._showMoreMenu = false;
78
+ document.removeEventListener("click", this.#closeMoreMenu);
79
+ };
80
+
81
+ #closeItemMenu = () => {
82
+ this._showItemMenuId = null;
83
+ document.removeEventListener("click", this.#closeItemMenu);
84
+ };
85
+
86
+ createRenderRoot() {
87
+ this.innerHTML = "";
88
+ return this;
89
+ }
90
+
91
+ constructor() {
92
+ super();
93
+ this.collections = [];
94
+ this.dividers = [];
95
+ this.labels = {} as CollectionSidebarLabels;
96
+ this.activeSlug = "";
97
+
98
+ this._items = [];
99
+ this._reorderMode = false;
100
+ this._dialogMode = null;
101
+ this._editingCollection = null;
102
+ this._showMoreMenu = false;
103
+ this._hoveringId = null;
104
+ this._showItemMenuId = null;
105
+ }
106
+
107
+ protected update(
108
+ changedProperties: PropertyValueMap<JantCollectionSidebar>,
109
+ ): void {
110
+ if (
111
+ !this.#initialized ||
112
+ changedProperties.has("collections") ||
113
+ changedProperties.has("dividers")
114
+ ) {
115
+ this._items = interleaveItems(
116
+ this.collections ?? [],
117
+ this.dividers ?? [],
118
+ );
119
+ this.#initialized = true;
120
+ }
121
+ super.update(changedProperties);
122
+ }
123
+
124
+ disconnectedCallback() {
125
+ super.disconnectedCallback();
126
+ this.#sortable?.destroy();
127
+ this.#sortable = null;
128
+ document.removeEventListener("click", this.#closeMoreMenu);
129
+ document.removeEventListener("click", this.#closeItemMenu);
130
+ }
131
+
132
+ // ===========================================================================
133
+ // Data fetching
134
+ // ===========================================================================
135
+
136
+ async #refreshList() {
137
+ try {
138
+ const res = await fetch("/api/collections");
139
+ if (!res.ok) return;
140
+ const json = await res.json();
141
+ this.collections = json.collections;
142
+ this.dividers = json.dividers;
143
+ // update triggers via the `update` lifecycle
144
+ } catch {
145
+ // silent — stale list is acceptable
146
+ }
147
+ }
148
+
149
+ // ===========================================================================
150
+ // SortableJS
151
+ // ===========================================================================
152
+
153
+ #initSortable() {
154
+ const list = this.querySelector<HTMLElement>("#sidebar-collections-list");
155
+ if (!list || this.#sortable) return;
156
+
157
+ this.#sortable = Sortable.create(list, {
158
+ animation: 150,
159
+ handle: "[data-drag-handle]",
160
+ onEnd: (evt) => {
161
+ // Read new order from DOM BEFORE reverting
162
+ const els = [
163
+ ...list.querySelectorAll<HTMLElement>("[data-sidebar-item]"),
164
+ ];
165
+ const items = els
166
+ .map((el) => el.dataset.sidebarItem)
167
+ .filter((id): id is string => id !== undefined);
168
+
169
+ // Revert SortableJS DOM manipulation so Lit can re-render cleanly
170
+ const { item, oldIndex, newIndex } = evt;
171
+ if (oldIndex != null && newIndex != null && oldIndex !== newIndex) {
172
+ item.parentNode?.removeChild(item);
173
+ const children = list.children;
174
+ if (oldIndex >= children.length) {
175
+ list.appendChild(item);
176
+ } else {
177
+ list.insertBefore(item, children[oldIndex]);
178
+ }
179
+ }
180
+
181
+ // Destroy sortable so it doesn't fight Lit's re-render
182
+ this.#sortable?.destroy();
183
+ this.#sortable = null;
184
+
185
+ // 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;
204
+
205
+ // Persist to server
206
+ fetch("/api/collections/reorder", {
207
+ method: "PUT",
208
+ headers: { "Content-Type": "application/json" },
209
+ body: JSON.stringify({ items }),
210
+ }).then((res) => {
211
+ if (res.ok) showToast(this.labels.orderSaved);
212
+ else showToast(this.labels.saveFailed, "error");
213
+ });
214
+ },
215
+ });
216
+ }
217
+
218
+ #enterReorderMode() {
219
+ this._reorderMode = true;
220
+ this._showMoreMenu = false;
221
+ document.removeEventListener("click", this.#closeMoreMenu);
222
+ // SortableJS will be initialized after Lit re-renders (in updated())
223
+ }
224
+
225
+ #exitReorderMode() {
226
+ this._reorderMode = false;
227
+ this.#sortable?.destroy();
228
+ this.#sortable = null;
229
+ }
230
+
231
+ protected updated(): void {
232
+ if (this._reorderMode) {
233
+ this.#initSortable();
234
+ }
235
+ }
236
+
237
+ // ===========================================================================
238
+ // Divider handlers
239
+ // ===========================================================================
240
+
241
+ async #addDivider() {
242
+ this._showMoreMenu = false;
243
+ document.removeEventListener("click", this.#closeMoreMenu);
244
+ try {
245
+ const res = await fetch("/api/collections/dividers", {
246
+ method: "POST",
247
+ headers: { "Content-Type": "application/json" },
248
+ });
249
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
250
+ await this.#refreshList();
251
+ } catch {
252
+ showToast(this.labels.saveFailed, "error");
253
+ }
254
+ }
255
+
256
+ async #deleteDivider(id: number) {
257
+ try {
258
+ const res = await fetch(`/api/collections/dividers/${id}`, {
259
+ method: "DELETE",
260
+ });
261
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
262
+ // Remove locally for instant feedback
263
+ this._items = this._items.filter(
264
+ (item) => !(item.kind === "divider" && item.data.id === id),
265
+ );
266
+ } catch {
267
+ showToast(this.labels.saveFailed, "error");
268
+ }
269
+ }
270
+
271
+ // ===========================================================================
272
+ // Collection CRUD handlers
273
+ // ===========================================================================
274
+
275
+ #openCreateDialog() {
276
+ this._dialogMode = "create";
277
+ this._editingCollection = null;
278
+ this._showMoreMenu = false;
279
+ document.removeEventListener("click", this.#closeMoreMenu);
280
+ // Wait for render, then show the dialog
281
+ this.updateComplete.then(() => {
282
+ this.querySelector<HTMLDialogElement>(
283
+ "#sidebar-collection-dialog",
284
+ )?.showModal();
285
+ });
286
+ }
287
+
288
+ #openEditDialog(col: SidebarCollection) {
289
+ this._dialogMode = "edit";
290
+ this._editingCollection = col;
291
+ this._showItemMenuId = null;
292
+ document.removeEventListener("click", this.#closeItemMenu);
293
+ this.updateComplete.then(() => {
294
+ this.querySelector<HTMLDialogElement>(
295
+ "#sidebar-collection-dialog",
296
+ )?.showModal();
297
+ });
298
+ }
299
+
300
+ #closeDialog() {
301
+ this.querySelector<HTMLDialogElement>(
302
+ "#sidebar-collection-dialog",
303
+ )?.close();
304
+ this._dialogMode = null;
305
+ this._editingCollection = null;
306
+ }
307
+
308
+ async #handleCollectionSubmit(e: Event) {
309
+ const event = e as CustomEvent<CollectionSubmitDetail>;
310
+ event.stopPropagation(); // prevent global bridge from handling
311
+
312
+ const detail = event.detail;
313
+ if (!detail) return;
314
+
315
+ const formEl = this.querySelector("jant-collection-form") as
316
+ | (HTMLElement & {
317
+ loading: boolean;
318
+ })
319
+ | null;
320
+ if (formEl) formEl.loading = true;
321
+
322
+ try {
323
+ const isEdit = detail.isEdit;
324
+ const url = isEdit
325
+ ? `/api/collections/${this._editingCollection?.id}`
326
+ : "/api/collections";
327
+ const method = isEdit ? "PUT" : "POST";
328
+
329
+ const res = await fetch(url, {
330
+ method,
331
+ headers: { "Content-Type": "application/json" },
332
+ body: JSON.stringify(detail.data),
333
+ });
334
+
335
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
336
+
337
+ showToast(this.labels.saved);
338
+ this.#closeDialog();
339
+ await this.#refreshList();
340
+ } catch {
341
+ showToast(this.labels.saveFailed, "error");
342
+ } finally {
343
+ if (formEl) formEl.loading = false;
344
+ }
345
+ }
346
+
347
+ async #deleteCollection(col: SidebarCollection) {
348
+ if (!window.confirm(this.labels.confirmDelete)) return;
349
+
350
+ this._showItemMenuId = null;
351
+ document.removeEventListener("click", this.#closeItemMenu);
352
+
353
+ try {
354
+ const res = await fetch(`/api/collections/${col.id}`, {
355
+ method: "DELETE",
356
+ });
357
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
358
+
359
+ showToast(this.labels.deleted);
360
+ await this.#refreshList();
361
+ } catch {
362
+ showToast(this.labels.saveFailed, "error");
363
+ }
364
+ }
365
+
366
+ // ===========================================================================
367
+ // Render
368
+ // ===========================================================================
369
+
370
+ #renderHeading() {
371
+ return html`
372
+ <div class="flex items-center justify-between px-3 pb-2">
373
+ <h2
374
+ class="text-xs font-semibold uppercase tracking-wider text-muted-foreground"
375
+ >
376
+ ${this.labels.collections}
377
+ </h2>
378
+ <div class="flex items-center gap-1">
379
+ ${this._reorderMode
380
+ ? html`
381
+ <button
382
+ type="button"
383
+ class="text-xs font-medium text-primary hover:underline"
384
+ @click=${() => this.#exitReorderMode()}
385
+ >
386
+ ${this.labels.done}
387
+ </button>
388
+ `
389
+ : html` ${this.#renderMoreButton()} ${this.#renderAddButton()} `}
390
+ </div>
391
+ </div>
392
+ `;
393
+ }
394
+
395
+ #renderMoreButton() {
396
+ return html`
397
+ <div class="relative">
398
+ <button
399
+ type="button"
400
+ class="flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
401
+ aria-label=${this.labels.moreActions}
402
+ @click=${(e: Event) => {
403
+ e.stopPropagation();
404
+ this._showMoreMenu = !this._showMoreMenu;
405
+ if (this._showMoreMenu) {
406
+ setTimeout(() => {
407
+ document.addEventListener("click", this.#closeMoreMenu);
408
+ });
409
+ } else {
410
+ document.removeEventListener("click", this.#closeMoreMenu);
411
+ }
412
+ }}
413
+ >
414
+ <svg
415
+ xmlns="http://www.w3.org/2000/svg"
416
+ width="14"
417
+ height="14"
418
+ viewBox="0 0 24 24"
419
+ fill="none"
420
+ stroke="currentColor"
421
+ stroke-width="2"
422
+ stroke-linecap="round"
423
+ stroke-linejoin="round"
424
+ >
425
+ <circle cx="12" cy="5" r="1" />
426
+ <circle cx="12" cy="12" r="1" />
427
+ <circle cx="12" cy="19" r="1" />
428
+ </svg>
429
+ </button>
430
+ ${this._showMoreMenu
431
+ ? html`
432
+ <div
433
+ class="absolute right-0 top-full mt-1 z-50 min-w-[160px] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
434
+ @click=${(e: Event) => e.stopPropagation()}
435
+ >
436
+ <button
437
+ type="button"
438
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
439
+ @click=${() => this.#enterReorderMode()}
440
+ >
441
+ <svg
442
+ xmlns="http://www.w3.org/2000/svg"
443
+ width="14"
444
+ height="14"
445
+ viewBox="0 0 24 24"
446
+ fill="none"
447
+ stroke="currentColor"
448
+ stroke-width="2"
449
+ stroke-linecap="round"
450
+ stroke-linejoin="round"
451
+ >
452
+ <path d="m3 16 4 4 4-4" />
453
+ <path d="M7 20V4" />
454
+ <path d="m21 8-4-4-4 4" />
455
+ <path d="M17 4v16" />
456
+ </svg>
457
+ ${this.labels.reorder}
458
+ </button>
459
+ <button
460
+ type="button"
461
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
462
+ @click=${() => this.#addDivider()}
463
+ >
464
+ <svg
465
+ xmlns="http://www.w3.org/2000/svg"
466
+ width="14"
467
+ height="14"
468
+ viewBox="0 0 24 24"
469
+ fill="none"
470
+ stroke="currentColor"
471
+ stroke-width="2"
472
+ stroke-linecap="round"
473
+ stroke-linejoin="round"
474
+ >
475
+ <path d="M3 12h18" />
476
+ </svg>
477
+ ${this.labels.addDivider}
478
+ </button>
479
+ </div>
480
+ `
481
+ : nothing}
482
+ </div>
483
+ `;
484
+ }
485
+
486
+ #renderAddButton() {
487
+ return html`
488
+ <button
489
+ type="button"
490
+ class="flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
491
+ title=${this.labels.newCollection}
492
+ aria-label=${this.labels.newCollection}
493
+ @click=${() => this.#openCreateDialog()}
494
+ >
495
+ <svg
496
+ xmlns="http://www.w3.org/2000/svg"
497
+ width="14"
498
+ height="14"
499
+ viewBox="0 0 24 24"
500
+ fill="none"
501
+ stroke="currentColor"
502
+ stroke-width="2"
503
+ stroke-linecap="round"
504
+ stroke-linejoin="round"
505
+ >
506
+ <path d="M5 12h14" />
507
+ <path d="M12 5v14" />
508
+ </svg>
509
+ </button>
510
+ `;
511
+ }
512
+
513
+ #renderCollectionItem(col: SidebarCollection) {
514
+ const isActive = col.slug === this.activeSlug;
515
+
516
+ if (this._reorderMode) {
517
+ return html`
518
+ <div
519
+ data-sidebar-item="c-${col.id}"
520
+ class="flex items-center gap-2 px-3 py-2 text-sm rounded-md"
521
+ >
522
+ <div class="cursor-grab text-muted-foreground" data-drag-handle>
523
+ <svg
524
+ xmlns="http://www.w3.org/2000/svg"
525
+ width="14"
526
+ height="14"
527
+ viewBox="0 0 24 24"
528
+ fill="none"
529
+ stroke="currentColor"
530
+ stroke-width="2"
531
+ stroke-linecap="round"
532
+ stroke-linejoin="round"
533
+ >
534
+ <circle cx="9" cy="12" r="1" />
535
+ <circle cx="9" cy="5" r="1" />
536
+ <circle cx="9" cy="19" r="1" />
537
+ <circle cx="15" cy="12" r="1" />
538
+ <circle cx="15" cy="5" r="1" />
539
+ <circle cx="15" cy="19" r="1" />
540
+ </svg>
541
+ </div>
542
+ <span class="flex items-center justify-center w-4 h-4 shrink-0">
543
+ ${unsafeHTML(
544
+ renderCollectionIcon(col.icon, { size: 16, fallback: true }),
545
+ )}
546
+ </span>
547
+ <span class="truncate">${col.title}</span>
548
+ </div>
549
+ `;
550
+ }
551
+
552
+ return html`
553
+ <div
554
+ data-sidebar-item="c-${col.id}"
555
+ class=${classMap({
556
+ "group relative": true,
557
+ "z-50": this._showItemMenuId === col.id,
558
+ })}
559
+ @mouseenter=${() => {
560
+ this._hoveringId = col.id;
561
+ }}
562
+ @mouseleave=${() => {
563
+ if (this._hoveringId === col.id) this._hoveringId = null;
564
+ }}
565
+ >
566
+ <a
567
+ href=${`/c/${col.slug}`}
568
+ class=${classMap({
569
+ "flex items-center gap-2.5 px-3 py-2 text-sm rounded-md truncate": true,
570
+ "bg-accent text-accent-foreground font-medium": isActive,
571
+ "text-muted-foreground hover:bg-accent hover:text-accent-foreground":
572
+ !isActive,
573
+ })}
574
+ >
575
+ <span class="flex items-center justify-center w-4 h-4 shrink-0">
576
+ ${unsafeHTML(
577
+ renderCollectionIcon(col.icon, { size: 16, fallback: true }),
578
+ )}
579
+ </span>
580
+ <span class="truncate">${col.title}</span>
581
+ </a>
582
+ ${this._hoveringId === col.id || this._showItemMenuId === col.id
583
+ ? this.#renderItemMenu(col)
584
+ : nothing}
585
+ </div>
586
+ `;
587
+ }
588
+
589
+ #renderItemMenu(col: SidebarCollection) {
590
+ const isOpen = this._showItemMenuId === col.id;
591
+
592
+ return html`
593
+ <div class="absolute right-1 top-1/2 -translate-y-1/2">
594
+ <button
595
+ type="button"
596
+ class="flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
597
+ @click=${(e: Event) => {
598
+ e.preventDefault();
599
+ e.stopPropagation();
600
+ if (isOpen) {
601
+ this._showItemMenuId = null;
602
+ document.removeEventListener("click", this.#closeItemMenu);
603
+ } else {
604
+ this._showItemMenuId = col.id;
605
+ setTimeout(() => {
606
+ document.addEventListener("click", this.#closeItemMenu);
607
+ });
608
+ }
609
+ }}
610
+ >
611
+ <svg
612
+ xmlns="http://www.w3.org/2000/svg"
613
+ width="14"
614
+ height="14"
615
+ viewBox="0 0 24 24"
616
+ fill="none"
617
+ stroke="currentColor"
618
+ stroke-width="2"
619
+ stroke-linecap="round"
620
+ stroke-linejoin="round"
621
+ >
622
+ <circle cx="12" cy="5" r="1" />
623
+ <circle cx="12" cy="12" r="1" />
624
+ <circle cx="12" cy="19" r="1" />
625
+ </svg>
626
+ </button>
627
+ ${isOpen
628
+ ? html`
629
+ <div
630
+ class="absolute right-0 top-full mt-1 z-50 min-w-[120px] rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md"
631
+ @click=${(e: Event) => e.stopPropagation()}
632
+ >
633
+ <button
634
+ type="button"
635
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground"
636
+ @click=${() => this.#openEditDialog(col)}
637
+ >
638
+ ${this.labels.edit}
639
+ </button>
640
+ <button
641
+ type="button"
642
+ class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-destructive hover:bg-accent"
643
+ @click=${() => this.#deleteCollection(col)}
644
+ >
645
+ ${this.labels.deleteCollection}
646
+ </button>
647
+ </div>
648
+ `
649
+ : nothing}
650
+ </div>
651
+ `;
652
+ }
653
+
654
+ #renderDividerItem(div: SidebarDivider) {
655
+ if (this._reorderMode) {
656
+ return html`
657
+ <div
658
+ data-sidebar-item="d-${div.id}"
659
+ class="flex items-center gap-2 px-3 py-1"
660
+ >
661
+ <div class="cursor-grab text-muted-foreground" data-drag-handle>
662
+ <svg
663
+ xmlns="http://www.w3.org/2000/svg"
664
+ width="14"
665
+ height="14"
666
+ viewBox="0 0 24 24"
667
+ fill="none"
668
+ stroke="currentColor"
669
+ stroke-width="2"
670
+ stroke-linecap="round"
671
+ stroke-linejoin="round"
672
+ >
673
+ <circle cx="9" cy="12" r="1" />
674
+ <circle cx="9" cy="5" r="1" />
675
+ <circle cx="9" cy="19" r="1" />
676
+ <circle cx="15" cy="12" r="1" />
677
+ <circle cx="15" cy="5" r="1" />
678
+ <circle cx="15" cy="19" r="1" />
679
+ </svg>
680
+ </div>
681
+ <hr class="flex-1 border-border" />
682
+ <button
683
+ type="button"
684
+ class="flex items-center justify-center w-5 h-5 rounded-md text-muted-foreground hover:text-destructive"
685
+ title=${this.labels.deleteDivider}
686
+ @click=${() => this.#deleteDivider(div.id)}
687
+ >
688
+ <svg
689
+ xmlns="http://www.w3.org/2000/svg"
690
+ width="12"
691
+ height="12"
692
+ viewBox="0 0 24 24"
693
+ fill="none"
694
+ stroke="currentColor"
695
+ stroke-width="2"
696
+ stroke-linecap="round"
697
+ stroke-linejoin="round"
698
+ >
699
+ <path d="M18 6 6 18" />
700
+ <path d="m6 6 12 12" />
701
+ </svg>
702
+ </button>
703
+ </div>
704
+ `;
705
+ }
706
+
707
+ return html`
708
+ <div data-sidebar-item="d-${div.id}" class="px-3 py-1">
709
+ <hr class="border-border" />
710
+ </div>
711
+ `;
712
+ }
713
+
714
+ #renderDialog() {
715
+ if (!this._dialogMode) return nothing;
716
+
717
+ const isEdit = this._dialogMode === "edit";
718
+ const col = this._editingCollection;
719
+
720
+ const formLabels = this.labels.formLabels;
721
+ const initial =
722
+ isEdit && col
723
+ ? {
724
+ title: col.title,
725
+ slug: col.slug,
726
+ description: col.description ?? "",
727
+ sortOrder: col.sortOrder ?? "newest",
728
+ icon: col.icon ?? "",
729
+ }
730
+ : {
731
+ title: "",
732
+ slug: "",
733
+ description: "",
734
+ sortOrder: "newest",
735
+ icon: "",
736
+ };
737
+
738
+ const dialogLabels = {
739
+ ...formLabels,
740
+ submitLabel: isEdit ? formLabels.submitLabel : formLabels.submitLabel,
741
+ };
742
+
743
+ return html`
744
+ <dialog
745
+ id="sidebar-collection-dialog"
746
+ class="m-auto rounded-lg border border-border bg-background text-foreground p-6 w-full max-w-md shadow-lg backdrop:bg-black/50"
747
+ @cancel=${() => this.#closeDialog()}
748
+ @close=${() => {
749
+ this._dialogMode = null;
750
+ this._editingCollection = null;
751
+ }}
752
+ @click=${(e: Event) => {
753
+ // Backdrop click — target is the <dialog> itself when clicking outside the box
754
+ if (e.target === e.currentTarget) {
755
+ this.#closeDialog();
756
+ }
757
+ }}
758
+ >
759
+ <jant-collection-form
760
+ .labels=${dialogLabels}
761
+ .initial=${initial}
762
+ action=${isEdit && col
763
+ ? `/api/collections/${col.id}`
764
+ : "/api/collections"}
765
+ cancel-href="javascript:void(0)"
766
+ ?is-edit=${isEdit}
767
+ @jant:collection-submit=${(e: Event) =>
768
+ this.#handleCollectionSubmit(e)}
769
+ @click=${(e: Event) => {
770
+ // Intercept cancel link click
771
+ const target = (e.target as HTMLElement).closest?.("a.btn-outline");
772
+ if (target) {
773
+ e.preventDefault();
774
+ this.#closeDialog();
775
+ }
776
+ }}
777
+ ></jant-collection-form>
778
+ </dialog>
779
+ `;
780
+ }
781
+
782
+ render() {
783
+ return html`
784
+ <nav class="flex flex-col gap-1 pt-6">
785
+ ${this.#renderHeading()}
786
+
787
+ <div id="sidebar-collections-list" class="flex flex-col">
788
+ ${this._items.map((item) =>
789
+ item.kind === "collection"
790
+ ? this.#renderCollectionItem(item.data as SidebarCollection)
791
+ : this.#renderDividerItem(item.data as SidebarDivider),
792
+ )}
793
+ </div>
794
+
795
+ ${this.#renderDialog()}
796
+ </nav>
797
+ `;
798
+ }
799
+ }
800
+
801
+ customElements.define("jant-collection-sidebar", JantCollectionSidebar);