@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,1019 @@
1
+ /**
2
+ * Post Menu
3
+ *
4
+ * Global singleton dropdown that appears on any post's [...] trigger button.
5
+ * Reads post metadata from `data-*` attributes on the closest `article[data-post]`.
6
+ * Uses BaseCoat dropdown-menu component structure for styling.
7
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
8
+ *
9
+ * Includes a collection picker sub-view that replaces the menu content
10
+ * when "Add to collection" is clicked (multi-select with search).
11
+ */
12
+
13
+ import { LitElement, html, nothing } from "lit";
14
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
15
+ import { showToast } from "../toast.js";
16
+ import type { CollectionSubmitDetail } from "./collection-types.js";
17
+
18
+ interface PostMenuData {
19
+ id: string;
20
+ permalink: string;
21
+ pinned: boolean;
22
+ featured: boolean;
23
+ visibility: string;
24
+ isReply: boolean;
25
+ }
26
+
27
+ interface CollectionItem {
28
+ id: string;
29
+ title: string;
30
+ slug: string;
31
+ icon: string | null;
32
+ }
33
+
34
+ /**
35
+ * Render a collection icon from its raw DB value (JSON or legacy emoji).
36
+ * Inline helper to avoid pulling lucide-static into the post-menu bundle.
37
+ */
38
+ function renderIconHtml(icon: string | null): string {
39
+ if (!icon) return "";
40
+ if (icon.startsWith("{")) {
41
+ try {
42
+ const parsed = JSON.parse(icon) as {
43
+ svg?: string;
44
+ color?: string;
45
+ };
46
+ if (typeof parsed.svg === "string") {
47
+ let svg = parsed.svg
48
+ .replace(/width="\d+"/, 'width="16"')
49
+ .replace(/height="\d+"/, 'height="16"');
50
+ if (parsed.color) {
51
+ svg = svg.replace(/^<svg/, `<svg style="color: ${parsed.color}"`);
52
+ }
53
+ return svg;
54
+ }
55
+ } catch {
56
+ /* not JSON — treat as text */
57
+ }
58
+ }
59
+ // Legacy emoji/text value
60
+ return `<span>${icon}</span>`;
61
+ }
62
+
63
+ export class JantPostMenu extends LitElement {
64
+ static properties = {
65
+ _open: { state: true },
66
+ _data: { state: true },
67
+ _x: { state: true },
68
+ _y: { state: true },
69
+ _openAbove: { state: true },
70
+ _collectionPickerOpen: { state: true },
71
+ _collections: { state: true },
72
+ _collectionsLoading: { state: true },
73
+ _collectionSearch: { state: true },
74
+ _postCollectionIds: { state: true },
75
+ _addCollectionPanelOpen: { state: true },
76
+ };
77
+
78
+ declare _open: boolean;
79
+ declare _data: PostMenuData | null;
80
+ declare _x: number;
81
+ declare _y: number;
82
+ declare _openAbove: boolean;
83
+ declare _collectionPickerOpen: boolean;
84
+ declare _collections: CollectionItem[] | null;
85
+ declare _collectionsLoading: boolean;
86
+ declare _collectionSearch: string;
87
+ declare _postCollectionIds: string[];
88
+ declare _addCollectionPanelOpen: boolean;
89
+ declare _triggerEl: HTMLElement | null;
90
+
91
+ /** Whether collections were modified during this session (triggers page reload on close) */
92
+ #collectionsDirty = false;
93
+
94
+ createRenderRoot() {
95
+ this.innerHTML = "";
96
+ return this;
97
+ }
98
+
99
+ constructor() {
100
+ super();
101
+ this._open = false;
102
+ this._data = null;
103
+ this._x = 0;
104
+ this._y = 0;
105
+ this._openAbove = true;
106
+ this._collectionPickerOpen = false;
107
+ this._collections = null;
108
+ this._collectionsLoading = false;
109
+ this._collectionSearch = "";
110
+ this._postCollectionIds = [];
111
+ this._addCollectionPanelOpen = false;
112
+ this._triggerEl = null;
113
+ }
114
+
115
+ connectedCallback() {
116
+ super.connectedCallback();
117
+ document.addEventListener("click", this.#handleDocumentClick);
118
+ document.addEventListener("keydown", this.#handleKeydown);
119
+ }
120
+
121
+ disconnectedCallback() {
122
+ super.disconnectedCallback();
123
+ document.removeEventListener("click", this.#handleDocumentClick);
124
+ document.removeEventListener("keydown", this.#handleKeydown);
125
+ }
126
+
127
+ #handleKeydown = (e: Event) => {
128
+ const ke = e as globalThis.KeyboardEvent;
129
+ if (ke.key === "Escape") {
130
+ // Close collection popovers first
131
+ const openPopover = document.querySelector(
132
+ "[data-collection-popover].open",
133
+ );
134
+ if (openPopover) {
135
+ openPopover.classList.remove("open");
136
+ return;
137
+ }
138
+ if (this._open) {
139
+ this.#close();
140
+ }
141
+ }
142
+ };
143
+
144
+ #handleDocumentClick = (e: Event) => {
145
+ const target = e.target as HTMLElement;
146
+
147
+ // Collection popover toggle
148
+ const popoverTrigger = target.closest<HTMLElement>(
149
+ "[data-collection-popover-trigger]",
150
+ );
151
+ if (popoverTrigger) {
152
+ e.preventDefault();
153
+ e.stopPropagation();
154
+ const popover = popoverTrigger.parentElement?.querySelector<HTMLElement>(
155
+ "[data-collection-popover]",
156
+ );
157
+ if (popover) {
158
+ popover.classList.toggle("open");
159
+ }
160
+ return;
161
+ }
162
+
163
+ // Click inside a collection popover — don't close it
164
+ if (target.closest("[data-collection-popover]")) {
165
+ return;
166
+ }
167
+
168
+ // Close any open collection popovers on outside click
169
+ const openPopover = document.querySelector(
170
+ "[data-collection-popover].open",
171
+ );
172
+ if (openPopover) {
173
+ openPopover.classList.remove("open");
174
+ }
175
+
176
+ // Clicking a trigger button
177
+ const trigger = target.closest<HTMLButtonElement>(
178
+ "[data-post-menu-trigger]",
179
+ );
180
+ if (trigger) {
181
+ e.preventDefault();
182
+ e.stopPropagation();
183
+
184
+ const article = trigger.closest<HTMLElement>("article[data-post]");
185
+ if (!article) return;
186
+
187
+ const postId = article.dataset.postId;
188
+ if (!postId) return;
189
+
190
+ // Toggle: close if same post, open if different
191
+ if (this._open && this._data?.id === postId) {
192
+ this.#close();
193
+ return;
194
+ }
195
+
196
+ this._data = {
197
+ id: postId,
198
+ permalink: article.dataset.postPermalink ?? "",
199
+ pinned: article.hasAttribute("data-post-pinned"),
200
+ featured: article.hasAttribute("data-post-featured"),
201
+ visibility: article.dataset.postVisibility ?? "public",
202
+ isReply: article.hasAttribute("data-post-reply"),
203
+ };
204
+
205
+ // Position relative to trigger
206
+ const rect = trigger.getBoundingClientRect();
207
+ const spaceBelow = window.innerHeight - rect.bottom;
208
+ const menuHeight = 280; // estimate
209
+ this._openAbove = spaceBelow < menuHeight;
210
+
211
+ this._x = rect.right;
212
+ this._y = this._openAbove ? rect.top : rect.bottom;
213
+ this._triggerEl = trigger;
214
+ trigger.setAttribute("aria-expanded", "true");
215
+ this._collectionPickerOpen = false;
216
+ this._open = true;
217
+ return;
218
+ }
219
+
220
+ // Clicking inside the dropdown — don't close (menu or collection picker)
221
+ if (this._open) {
222
+ const inside = target.closest?.(
223
+ "[role='menu'], [data-collection-picker]",
224
+ );
225
+ if (inside) return;
226
+ }
227
+
228
+ // Clicking outside — close
229
+ if (this._open) {
230
+ this.#close();
231
+ }
232
+ };
233
+
234
+ #close() {
235
+ this._triggerEl?.setAttribute("aria-expanded", "false");
236
+ this._triggerEl = null;
237
+ this._open = false;
238
+ this._collectionPickerOpen = false;
239
+ this._addCollectionPanelOpen = false;
240
+ this._collectionSearch = "";
241
+
242
+ if (this.#collectionsDirty) {
243
+ this.#collectionsDirty = false;
244
+ window.location.reload();
245
+ }
246
+ }
247
+
248
+ // --- Actions ---
249
+
250
+ async #edit() {
251
+ if (!this._data) return;
252
+ const postId = this._data.id;
253
+ this.#close();
254
+
255
+ const dialog = document.getElementById(
256
+ "compose-dialog",
257
+ ) as HTMLDialogElement | null;
258
+ const composeEl = dialog?.querySelector("jant-compose-dialog") as
259
+ | import("./jant-compose-dialog.js").JantComposeDialog
260
+ | null;
261
+ if (composeEl) {
262
+ await composeEl.openEdit(postId);
263
+ }
264
+ }
265
+
266
+ async #setVisibility(newVisibility: string) {
267
+ if (!this._data) return;
268
+
269
+ try {
270
+ const res = await fetch(`/api/posts/${this._data.id}`, {
271
+ method: "PUT",
272
+ headers: { "Content-Type": "application/json" },
273
+ body: JSON.stringify({ visibility: newVisibility }),
274
+ });
275
+ if (!res.ok) throw new Error();
276
+
277
+ // Update article's data attribute
278
+ const article = document.querySelector<HTMLElement>(
279
+ `article[data-post-id="${this._data.id}"]`,
280
+ );
281
+ if (article) article.dataset.postVisibility = newVisibility;
282
+ this._data = { ...this._data, visibility: newVisibility };
283
+
284
+ const messages: Record<string, string> = {
285
+ public: "Post made public.",
286
+ unlisted: "Post unlisted.",
287
+ private: "Post made private.",
288
+ };
289
+ showToast(messages[newVisibility] ?? "Visibility updated.");
290
+ } catch {
291
+ showToast("Could not update post. Try again.", "error");
292
+ }
293
+ this.#close();
294
+ }
295
+
296
+ async #setFeatured(featured: boolean) {
297
+ if (!this._data) return;
298
+
299
+ try {
300
+ const res = await fetch(`/api/posts/${this._data.id}`, {
301
+ method: "PUT",
302
+ headers: { "Content-Type": "application/json" },
303
+ body: JSON.stringify({ featured }),
304
+ });
305
+ if (!res.ok) throw new Error();
306
+
307
+ // Update article's data attribute
308
+ const article = document.querySelector<HTMLElement>(
309
+ `article[data-post-id="${this._data.id}"]`,
310
+ );
311
+ if (article) {
312
+ if (featured) {
313
+ article.setAttribute("data-post-featured", "");
314
+ } else {
315
+ article.removeAttribute("data-post-featured");
316
+ }
317
+ }
318
+ this._data = { ...this._data, featured };
319
+
320
+ showToast(featured ? "Post featured." : "Post unfeatured.");
321
+ } catch {
322
+ showToast("Could not update post. Try again.", "error");
323
+ }
324
+ this.#close();
325
+ }
326
+
327
+ async #togglePin() {
328
+ if (!this._data) return;
329
+ const newPinned = !this._data.pinned;
330
+
331
+ try {
332
+ const res = await fetch(`/api/posts/${this._data.id}`, {
333
+ method: "PUT",
334
+ headers: { "Content-Type": "application/json" },
335
+ body: JSON.stringify({ pinned: newPinned }),
336
+ });
337
+ if (!res.ok) throw new Error();
338
+
339
+ // Update article's data attribute
340
+ const article = document.querySelector<HTMLElement>(
341
+ `article[data-post-id="${this._data.id}"]`,
342
+ );
343
+ if (article) {
344
+ if (newPinned) {
345
+ article.setAttribute("data-post-pinned", "");
346
+ } else {
347
+ article.removeAttribute("data-post-pinned");
348
+ }
349
+ }
350
+ this._data = { ...this._data, pinned: newPinned };
351
+
352
+ showToast(newPinned ? "Post pinned." : "Post unpinned.");
353
+ } catch {
354
+ showToast("Could not update post. Try again.", "error");
355
+ }
356
+ this.#close();
357
+ }
358
+
359
+ async #delete() {
360
+ if (!this._data) return;
361
+ if (!window.confirm("Delete this post permanently? This can't be undone."))
362
+ return;
363
+
364
+ try {
365
+ const res = await fetch(`/api/posts/${this._data.id}`, {
366
+ method: "DELETE",
367
+ });
368
+ if (!res.ok) throw new Error();
369
+
370
+ // Remove article from DOM
371
+ const article = document.querySelector<HTMLElement>(
372
+ `article[data-post-id="${this._data.id}"]`,
373
+ );
374
+ // Remove the feed item wrapper if it exists, otherwise the article itself
375
+ const feedItem = article?.closest(".feed-item");
376
+ (feedItem ?? article)?.remove();
377
+
378
+ showToast("Post deleted.");
379
+ } catch {
380
+ showToast("Could not delete post. Try again.", "error");
381
+ }
382
+ this.#close();
383
+ }
384
+
385
+ async #copyLink() {
386
+ if (!this._data) return;
387
+ try {
388
+ await globalThis.navigator.clipboard.writeText(
389
+ window.location.origin + this._data.permalink,
390
+ );
391
+ showToast("Link copied.");
392
+ } catch {
393
+ showToast("Could not copy link.", "error");
394
+ }
395
+ this.#close();
396
+ }
397
+
398
+ async #openCollectionPicker() {
399
+ if (!this._data) return;
400
+ const postId = this._data.id;
401
+ this._collectionPickerOpen = true;
402
+ this._collectionSearch = "";
403
+ this._collectionsLoading = true;
404
+
405
+ try {
406
+ const [collectionsRes, postRes] = await Promise.all([
407
+ fetch("/api/collections"),
408
+ fetch(`/api/posts/${postId}`),
409
+ ]);
410
+
411
+ if (!collectionsRes.ok) throw new Error();
412
+ const collectionsData = await collectionsRes.json();
413
+ this._collections = collectionsData.collections ?? [];
414
+
415
+ if (postRes.ok) {
416
+ const postData = await postRes.json();
417
+ this._postCollectionIds = postData.collectionIds ?? [];
418
+ }
419
+ } catch {
420
+ this._collections = this._collections ?? [];
421
+ showToast("Could not load collections.", "error");
422
+ }
423
+ this._collectionsLoading = false;
424
+ }
425
+
426
+ async #toggleCollection(collectionId: string) {
427
+ if (!this._data) return;
428
+ const isSelected = this._postCollectionIds.includes(collectionId);
429
+
430
+ try {
431
+ if (isSelected) {
432
+ const res = await fetch(
433
+ `/api/collections/${collectionId}/posts/${this._data.id}`,
434
+ { method: "DELETE" },
435
+ );
436
+ if (!res.ok) throw new Error();
437
+ this._postCollectionIds = this._postCollectionIds.filter(
438
+ (id) => id !== collectionId,
439
+ );
440
+ this.#collectionsDirty = true;
441
+ showToast("Removed from collection.");
442
+ } else {
443
+ const res = await fetch(`/api/collections/${collectionId}/posts`, {
444
+ method: "POST",
445
+ headers: { "Content-Type": "application/json" },
446
+ body: JSON.stringify({ postId: this._data.id }),
447
+ });
448
+ if (!res.ok) {
449
+ const body = await res.json().catch(() => null);
450
+ if (res.status === 409 || body?.error?.includes("already")) {
451
+ if (!this._postCollectionIds.includes(collectionId)) {
452
+ this._postCollectionIds = [
453
+ ...this._postCollectionIds,
454
+ collectionId,
455
+ ];
456
+ }
457
+ return;
458
+ }
459
+ throw new Error();
460
+ }
461
+ this._postCollectionIds = [...this._postCollectionIds, collectionId];
462
+ this.#collectionsDirty = true;
463
+ showToast("Added to collection.");
464
+ }
465
+ } catch {
466
+ showToast(
467
+ isSelected
468
+ ? "Could not remove from collection. Try again."
469
+ : "Could not add to collection. Try again.",
470
+ "error",
471
+ );
472
+ }
473
+ }
474
+
475
+ #openAddCollectionPanel() {
476
+ this._addCollectionPanelOpen = true;
477
+ }
478
+
479
+ #closeAddCollectionPanel() {
480
+ this._addCollectionPanelOpen = false;
481
+ }
482
+
483
+ async #handleAddCollectionSubmit(e: Event) {
484
+ const event = e as CustomEvent<CollectionSubmitDetail>;
485
+ event.stopPropagation();
486
+
487
+ const detail = event.detail;
488
+ if (!detail) return;
489
+
490
+ const formEl = this.querySelector("jant-collection-form") as
491
+ | (HTMLElement & { loading: boolean })
492
+ | null;
493
+ if (formEl) formEl.loading = true;
494
+
495
+ try {
496
+ const res = await fetch("/api/collections", {
497
+ method: "POST",
498
+ headers: { "Content-Type": "application/json" },
499
+ body: JSON.stringify(detail.data),
500
+ });
501
+
502
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
503
+
504
+ const created = await res.json();
505
+ const newItem: CollectionItem = {
506
+ id: created.id,
507
+ title: created.title,
508
+ slug: created.slug,
509
+ icon: created.icon ?? null,
510
+ };
511
+
512
+ this._collections = [...(this._collections ?? []), newItem];
513
+
514
+ // Auto-add the post to the newly created collection
515
+ if (this._data) {
516
+ await fetch(`/api/collections/${created.id}/posts`, {
517
+ method: "POST",
518
+ headers: { "Content-Type": "application/json" },
519
+ body: JSON.stringify({ postId: this._data.id }),
520
+ });
521
+ this._postCollectionIds = [...this._postCollectionIds, created.id];
522
+ }
523
+
524
+ this.#collectionsDirty = true;
525
+ this._addCollectionPanelOpen = false;
526
+ showToast("Collection created.");
527
+ } catch {
528
+ showToast("Could not create collection. Try again.", "error");
529
+ } finally {
530
+ if (formEl) formEl.loading = false;
531
+ }
532
+ }
533
+
534
+ #submitAddCollectionForm() {
535
+ const form = this.querySelector<HTMLFormElement>(
536
+ ".post-menu-add-collection-panel form",
537
+ );
538
+ if (form) form.requestSubmit();
539
+ }
540
+
541
+ /** Get collection form labels from the compose dialog (already on the page) */
542
+ #getCollectionFormLabels() {
543
+ const composeEl = document.querySelector("jant-compose-dialog") as
544
+ | import("./jant-compose-dialog.js").JantComposeDialog
545
+ | null;
546
+ return composeEl?.labels?.collectionFormLabels ?? null;
547
+ }
548
+
549
+ // --- Icons (inline SVG) ---
550
+
551
+ #iconEdit() {
552
+ return html`<svg
553
+ xmlns="http://www.w3.org/2000/svg"
554
+ viewBox="0 0 24 24"
555
+ fill="none"
556
+ stroke="currentColor"
557
+ stroke-width="1.75"
558
+ stroke-linecap="round"
559
+ stroke-linejoin="round"
560
+ >
561
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
562
+ <path d="m15 5 4 4" />
563
+ </svg>`;
564
+ }
565
+
566
+ #iconCollection() {
567
+ return html`<svg
568
+ xmlns="http://www.w3.org/2000/svg"
569
+ viewBox="0 0 24 24"
570
+ fill="none"
571
+ stroke="currentColor"
572
+ stroke-width="1.75"
573
+ stroke-linecap="round"
574
+ stroke-linejoin="round"
575
+ >
576
+ <rect width="7" height="7" x="3" y="3" rx="1" />
577
+ <rect width="7" height="7" x="14" y="3" rx="1" />
578
+ <rect width="7" height="7" x="14" y="14" rx="1" />
579
+ <rect width="7" height="7" x="3" y="14" rx="1" />
580
+ </svg>`;
581
+ }
582
+
583
+ // Lucide: heart (feature) / heart-off (unfeature)
584
+ #iconHeart() {
585
+ return html`<svg
586
+ xmlns="http://www.w3.org/2000/svg"
587
+ viewBox="0 0 24 24"
588
+ fill="none"
589
+ stroke="currentColor"
590
+ stroke-width="1.75"
591
+ stroke-linecap="round"
592
+ stroke-linejoin="round"
593
+ >
594
+ <path
595
+ d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
596
+ />
597
+ </svg>`;
598
+ }
599
+
600
+ #iconHeartOff() {
601
+ return html`<svg
602
+ xmlns="http://www.w3.org/2000/svg"
603
+ viewBox="0 0 24 24"
604
+ fill="none"
605
+ stroke="currentColor"
606
+ stroke-width="1.75"
607
+ stroke-linecap="round"
608
+ stroke-linejoin="round"
609
+ >
610
+ <line x1="2" y1="2" x2="22" y2="22" />
611
+ <path
612
+ d="M16.5 16.5 12 21l-7-7c-1.5-1.45-3-3.2-3-5.5a5.5 5.5 0 0 1 2.14-4.35"
613
+ />
614
+ <path
615
+ d="M8.76 3.1c1.15.22 2.13.78 3.24 1.9 1.5-1.5 2.74-2 4.5-2A5.5 5.5 0 0 1 22 8.5c0 2.12-1.3 3.78-2.67 5.17"
616
+ />
617
+ </svg>`;
618
+ }
619
+
620
+ // Lucide: pin / pin-off
621
+ #iconPin() {
622
+ return html`<svg
623
+ xmlns="http://www.w3.org/2000/svg"
624
+ viewBox="0 0 24 24"
625
+ fill="none"
626
+ stroke="currentColor"
627
+ stroke-width="1.75"
628
+ stroke-linecap="round"
629
+ stroke-linejoin="round"
630
+ >
631
+ <line x1="12" x2="12" y1="17" y2="22" />
632
+ <path
633
+ d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"
634
+ />
635
+ </svg>`;
636
+ }
637
+
638
+ #iconPinOff() {
639
+ return html`<svg
640
+ xmlns="http://www.w3.org/2000/svg"
641
+ viewBox="0 0 24 24"
642
+ fill="none"
643
+ stroke="currentColor"
644
+ stroke-width="1.75"
645
+ stroke-linecap="round"
646
+ stroke-linejoin="round"
647
+ >
648
+ <line x1="2" x2="22" y1="2" y2="22" />
649
+ <line x1="12" x2="12" y1="17" y2="22" />
650
+ <path d="M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17h12" />
651
+ <path d="M15 9.34V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0-1.4.6" />
652
+ </svg>`;
653
+ }
654
+
655
+ #iconTrash() {
656
+ return html`<svg
657
+ xmlns="http://www.w3.org/2000/svg"
658
+ viewBox="0 0 24 24"
659
+ fill="none"
660
+ stroke="currentColor"
661
+ stroke-width="1.75"
662
+ stroke-linecap="round"
663
+ stroke-linejoin="round"
664
+ >
665
+ <path d="M3 6h18" />
666
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
667
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
668
+ </svg>`;
669
+ }
670
+
671
+ // Lucide: globe (make public)
672
+ #iconGlobe() {
673
+ return html`<svg
674
+ xmlns="http://www.w3.org/2000/svg"
675
+ viewBox="0 0 24 24"
676
+ fill="none"
677
+ stroke="currentColor"
678
+ stroke-width="1.75"
679
+ stroke-linecap="round"
680
+ stroke-linejoin="round"
681
+ >
682
+ <circle cx="12" cy="12" r="10" />
683
+ <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
684
+ <path d="M2 12h20" />
685
+ </svg>`;
686
+ }
687
+
688
+ // Lucide: link-2-off (unlisted)
689
+ #iconLinkOff() {
690
+ return html`<svg
691
+ xmlns="http://www.w3.org/2000/svg"
692
+ viewBox="0 0 24 24"
693
+ fill="none"
694
+ stroke="currentColor"
695
+ stroke-width="1.75"
696
+ stroke-linecap="round"
697
+ stroke-linejoin="round"
698
+ >
699
+ <path d="M9 17H7A5 5 0 0 1 7 7" />
700
+ <path d="M15 7h2a5 5 0 0 1 4 8" />
701
+ <line x1="8" x2="12" y1="12" y2="12" />
702
+ <line x1="2" x2="22" y1="2" y2="22" />
703
+ </svg>`;
704
+ }
705
+
706
+ // Lucide: eye-off (private)
707
+ #iconEyeOff() {
708
+ return html`<svg
709
+ xmlns="http://www.w3.org/2000/svg"
710
+ viewBox="0 0 24 24"
711
+ fill="none"
712
+ stroke="currentColor"
713
+ stroke-width="1.75"
714
+ stroke-linecap="round"
715
+ stroke-linejoin="round"
716
+ >
717
+ <path
718
+ d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49"
719
+ />
720
+ <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
721
+ <path
722
+ d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143"
723
+ />
724
+ <path d="m2 2 20 20" />
725
+ </svg>`;
726
+ }
727
+
728
+ #iconLink() {
729
+ return html`<svg
730
+ xmlns="http://www.w3.org/2000/svg"
731
+ viewBox="0 0 24 24"
732
+ fill="none"
733
+ stroke="currentColor"
734
+ stroke-width="1.75"
735
+ stroke-linecap="round"
736
+ stroke-linejoin="round"
737
+ >
738
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
739
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
740
+ </svg>`;
741
+ }
742
+
743
+ // --- Render ---
744
+
745
+ #renderCollectionPicker() {
746
+ if (this._addCollectionPanelOpen) {
747
+ return this.#renderAddCollectionPanel();
748
+ }
749
+
750
+ const collections = this._collections ?? [];
751
+ const search = this._collectionSearch.toLowerCase();
752
+ const filtered = search
753
+ ? collections.filter((c) => c.title.toLowerCase().includes(search))
754
+ : collections;
755
+
756
+ return html`
757
+ <div data-collection-picker class="post-menu-collection-picker">
758
+ <div class="post-menu-picker-header">
759
+ <span>Collections</span>
760
+ </div>
761
+ ${collections.length > 0
762
+ ? html`<div class="post-menu-picker-search">
763
+ <svg
764
+ width="14"
765
+ height="14"
766
+ viewBox="0 0 24 24"
767
+ fill="none"
768
+ stroke="currentColor"
769
+ stroke-width="2"
770
+ stroke-linecap="round"
771
+ stroke-linejoin="round"
772
+ >
773
+ <circle cx="11" cy="11" r="8" />
774
+ <path d="m21 21-4.3-4.3" />
775
+ </svg>
776
+ <input
777
+ type="text"
778
+ placeholder="Search collections..."
779
+ autocomplete="off"
780
+ autocorrect="off"
781
+ spellcheck="false"
782
+ .value=${this._collectionSearch}
783
+ @input=${(e: Event) => {
784
+ this._collectionSearch = (e.target as HTMLInputElement).value;
785
+ }}
786
+ />
787
+ </div>`
788
+ : nothing}
789
+ <div
790
+ class="post-menu-picker-list"
791
+ role="listbox"
792
+ aria-multiselectable="true"
793
+ >
794
+ ${this._collectionsLoading
795
+ ? html`<div class="post-menu-picker-empty">Loading...</div>`
796
+ : filtered.length > 0
797
+ ? filtered.map((c) => {
798
+ const selected = this._postCollectionIds.includes(c.id);
799
+ const iconStr = renderIconHtml(c.icon);
800
+ return html`
801
+ <div
802
+ role="option"
803
+ aria-selected=${selected ? "true" : "false"}
804
+ class="post-menu-picker-option"
805
+ @click=${() => this.#toggleCollection(c.id)}
806
+ >
807
+ ${iconStr
808
+ ? html`<span class="post-menu-picker-icon"
809
+ >${unsafeHTML(iconStr)}</span
810
+ >`
811
+ : nothing}
812
+ <span class="post-menu-picker-title">${c.title}</span>
813
+ ${selected
814
+ ? html`<svg
815
+ class="post-menu-picker-check"
816
+ xmlns="http://www.w3.org/2000/svg"
817
+ viewBox="0 0 24 24"
818
+ fill="none"
819
+ stroke="currentColor"
820
+ stroke-width="2.5"
821
+ stroke-linecap="round"
822
+ stroke-linejoin="round"
823
+ width="14"
824
+ height="14"
825
+ >
826
+ <path d="M20 6 9 17l-5-5" />
827
+ </svg>`
828
+ : nothing}
829
+ </div>
830
+ `;
831
+ })
832
+ : html`<div class="post-menu-picker-empty">
833
+ ${search ? "No matching collections" : "No collections yet"}
834
+ </div>`}
835
+ </div>
836
+ <div
837
+ class="post-menu-picker-add"
838
+ @click=${() => this.#openAddCollectionPanel()}
839
+ >
840
+ <svg
841
+ width="14"
842
+ height="14"
843
+ viewBox="0 0 16 16"
844
+ fill="none"
845
+ stroke="currentColor"
846
+ stroke-width="1.5"
847
+ stroke-linecap="round"
848
+ stroke-linejoin="round"
849
+ >
850
+ <path d="M8 3v10M3 8h10" />
851
+ </svg>
852
+ Add Collection
853
+ </div>
854
+ </div>
855
+ `;
856
+ }
857
+
858
+ #renderAddCollectionPanel() {
859
+ const labels = this.#getCollectionFormLabels();
860
+ if (!labels) return nothing;
861
+
862
+ const initial = {
863
+ title: "",
864
+ slug: "",
865
+ description: "",
866
+ sortOrder: "newest",
867
+ icon: "",
868
+ };
869
+
870
+ return html`
871
+ <div data-collection-picker class="post-menu-add-collection-panel">
872
+ <div class="post-menu-picker-header">
873
+ <button
874
+ type="button"
875
+ class="post-menu-panel-back"
876
+ @click=${() => this.#closeAddCollectionPanel()}
877
+ >
878
+ <svg
879
+ width="16"
880
+ height="16"
881
+ viewBox="0 0 24 24"
882
+ fill="none"
883
+ stroke="currentColor"
884
+ stroke-width="2"
885
+ stroke-linecap="round"
886
+ stroke-linejoin="round"
887
+ >
888
+ <path d="m15 18-6-6 6-6" />
889
+ </svg>
890
+ </button>
891
+ <span>Add Collection</span>
892
+ <button
893
+ type="button"
894
+ class="post-menu-panel-done"
895
+ @click=${() => this.#submitAddCollectionForm()}
896
+ >
897
+ Done
898
+ </button>
899
+ </div>
900
+ <div class="post-menu-panel-body">
901
+ <jant-collection-form
902
+ class="post-menu-collection-form"
903
+ .labels=${labels}
904
+ .initial=${initial}
905
+ action="/api/collections"
906
+ cancel-href="javascript:void(0)"
907
+ @jant:collection-submit=${(e: Event) =>
908
+ this.#handleAddCollectionSubmit(e)}
909
+ ></jant-collection-form>
910
+ </div>
911
+ </div>
912
+ `;
913
+ }
914
+
915
+ #renderMenu() {
916
+ if (!this._data) return nothing;
917
+ const visibility = this._data.visibility;
918
+ const isPinned = this._data.pinned;
919
+ const isFeatured = this._data.featured;
920
+
921
+ return html`
922
+ <div role="menu">
923
+ <div role="menuitem" @click=${() => this.#edit()}>
924
+ ${this.#iconEdit()} Edit
925
+ </div>
926
+
927
+ <hr role="separator" />
928
+
929
+ <div role="menuitem" @click=${() => this.#openCollectionPicker()}>
930
+ ${this.#iconCollection()} Add to collection
931
+ </div>
932
+ ${isFeatured
933
+ ? html`<div role="menuitem" @click=${() => this.#setFeatured(false)}>
934
+ ${this.#iconHeartOff()} Unfeature
935
+ </div>`
936
+ : html`<div role="menuitem" @click=${() => this.#setFeatured(true)}>
937
+ ${this.#iconHeart()} Feature
938
+ </div>`}
939
+ ${this._data.isReply
940
+ ? nothing
941
+ : html`
942
+ ${visibility !== "public"
943
+ ? html`<div
944
+ role="menuitem"
945
+ @click=${() => this.#setVisibility("public")}
946
+ >
947
+ ${this.#iconGlobe()} Make Public
948
+ </div>`
949
+ : nothing}
950
+ ${visibility !== "unlisted"
951
+ ? html`<div
952
+ role="menuitem"
953
+ @click=${() => this.#setVisibility("unlisted")}
954
+ >
955
+ ${this.#iconLinkOff()} Make Unlisted
956
+ </div>`
957
+ : nothing}
958
+ ${visibility !== "private"
959
+ ? html`<div
960
+ role="menuitem"
961
+ @click=${() => this.#setVisibility("private")}
962
+ >
963
+ ${this.#iconEyeOff()} Make Private
964
+ </div>`
965
+ : nothing}
966
+ `}
967
+ ${this._data.isReply
968
+ ? nothing
969
+ : html`<div role="menuitem" @click=${() => this.#togglePin()}>
970
+ ${isPinned ? this.#iconPinOff() : this.#iconPin()}
971
+ ${isPinned ? "Unpin" : "Pin this post"}
972
+ </div>`}
973
+
974
+ <hr role="separator" />
975
+
976
+ <div
977
+ role="menuitem"
978
+ class="text-destructive! [&_svg]:text-destructive!"
979
+ @click=${() => this.#delete()}
980
+ >
981
+ ${this.#iconTrash()} Delete
982
+ </div>
983
+
984
+ <hr role="separator" />
985
+
986
+ <div
987
+ role="menuitem"
988
+ class="text-muted-foreground!"
989
+ @click=${() => this.#copyLink()}
990
+ >
991
+ ${this.#iconLink()} Copy link
992
+ </div>
993
+ </div>
994
+ `;
995
+ }
996
+
997
+ render() {
998
+ if (!this._open || !this._data) return nothing;
999
+
1000
+ const wrapperStyle = `position:fixed;z-index:100;right:${document.documentElement.clientWidth - this._x}px;${
1001
+ this._openAbove
1002
+ ? `bottom:${window.innerHeight - this._y + 6}px;`
1003
+ : `top:${this._y + 6}px;`
1004
+ }`;
1005
+
1006
+ return html`
1007
+ <div class="post-menu-backdrop" @click=${() => this.#close()}></div>
1008
+ <div class="dropdown-menu" style=${wrapperStyle}>
1009
+ <div data-popover aria-hidden="false" class="!static min-w-52">
1010
+ ${this._collectionPickerOpen
1011
+ ? this.#renderCollectionPicker()
1012
+ : this.#renderMenu()}
1013
+ </div>
1014
+ </div>
1015
+ `;
1016
+ }
1017
+ }
1018
+
1019
+ customElements.define("jant-post-menu", JantPostMenu);