@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,2161 @@
1
+ /**
2
+ * Compose Dialog
3
+ *
4
+ * Outer shell for the compose dialog: header with format switcher,
5
+ * collection selector, action row, and attachment upload coordination.
6
+ *
7
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
8
+ */
9
+
10
+ import { LitElement, html, nothing } from "lit";
11
+ import { classMap } from "lit/directives/class-map.js";
12
+ import { unsafeHTML } from "lit/directives/unsafe-html.js";
13
+ import type { Editor, JSONContent } from "@tiptap/core";
14
+ import type {
15
+ ComposeFormat,
16
+ ComposeVisibility,
17
+ ComposeLabels,
18
+ ComposeCollection,
19
+ ComposeSubmitDetail,
20
+ ComposeAttachment,
21
+ DraftItem,
22
+ LocalDraft,
23
+ } from "./compose-types.js";
24
+ import type { CollectionSubmitDetail } from "./collection-types.js";
25
+ import { showToast } from "../toast.js";
26
+ import type { JantComposeEditor } from "./jant-compose-editor.js";
27
+ import { getMediaCategory } from "../../lib/upload.js";
28
+ import { createTiptapEditor } from "../tiptap/create-editor.js";
29
+ import { renderCollectionIcon } from "../../lib/icons.js";
30
+
31
+ interface ReplyToData {
32
+ contentHtml: string;
33
+ dateText: string;
34
+ }
35
+
36
+ interface ComposeStateSnapshot {
37
+ format: ComposeFormat;
38
+ collectionIds: string[];
39
+ title: string;
40
+ bodyJson: JSONContent | null;
41
+ url: string;
42
+ quoteText: string;
43
+ quoteAuthor: string;
44
+ rating: number;
45
+ showTitle: boolean;
46
+ showRating: boolean;
47
+ attachments: Array<{
48
+ clientId: string;
49
+ mediaId: string | null;
50
+ previewUrl: string;
51
+ mimeType: string;
52
+ alt: string;
53
+ status: ComposeAttachment["status"];
54
+ summary: string | null;
55
+ chars: number | null;
56
+ }>;
57
+ attachedTexts: Array<{
58
+ clientId: string;
59
+ mediaId: string | null;
60
+ bodyJson: JSONContent | null;
61
+ bodyHtml: string;
62
+ summary: string;
63
+ }>;
64
+ attachmentOrder: string[];
65
+ }
66
+
67
+ export class JantComposeDialog extends LitElement {
68
+ static properties = {
69
+ collections: { type: Array },
70
+ labels: { type: Object },
71
+ uploadMaxFileSize: { type: Number, attribute: "upload-max-file-size" },
72
+ pageMode: { type: Boolean, attribute: "page-mode" },
73
+ closeHref: { type: String, attribute: "close-href" },
74
+ autoRestoreDraft: { type: Boolean, attribute: "auto-restore-draft" },
75
+ _format: { state: true },
76
+ _status: { state: true },
77
+ _loading: { state: true },
78
+ _collectionIds: { state: true },
79
+ _showCollection: { state: true },
80
+ _showMoreMenu: { state: true },
81
+ _collectionSearch: { state: true },
82
+ _altPanelOpen: { state: true },
83
+ _altPanelIndex: { state: true },
84
+ _attachedPanelOpen: { state: true },
85
+ _attachedTextIndex: { state: true },
86
+ _confirmPanelOpen: { state: true },
87
+ _editPostId: { state: true },
88
+ _draftSourceId: { state: true },
89
+ _draftsPanelOpen: { state: true },
90
+ _drafts: { state: true },
91
+ _draftsLoading: { state: true },
92
+ _draftsError: { state: true },
93
+ _draftMenuOpenId: { state: true },
94
+ _addCollectionPanelOpen: { state: true },
95
+ _replyToId: { state: true },
96
+ _replyToData: { state: true },
97
+ _replyExpanded: { state: true },
98
+ _visibility: { state: true },
99
+ _featured: { state: true },
100
+ _showVisibilityMenu: { state: true },
101
+ };
102
+
103
+ declare collections: ComposeCollection[];
104
+ declare labels: ComposeLabels;
105
+ declare uploadMaxFileSize: number;
106
+ declare pageMode: boolean;
107
+ declare closeHref: string;
108
+ declare autoRestoreDraft: boolean;
109
+ declare _format: ComposeFormat;
110
+ declare _status: "published" | "draft";
111
+ declare _loading: boolean;
112
+ declare _collectionIds: string[];
113
+ declare _showCollection: boolean;
114
+ declare _showMoreMenu: boolean;
115
+ declare _collectionSearch: string;
116
+ declare _altPanelOpen: boolean;
117
+ declare _altPanelIndex: number;
118
+ declare _attachedPanelOpen: boolean;
119
+ declare _attachedTextIndex: number;
120
+ declare _confirmPanelOpen: boolean;
121
+ declare _editPostId: string | null;
122
+ declare _draftSourceId: string | null;
123
+ declare _draftsPanelOpen: boolean;
124
+ declare _drafts: DraftItem[];
125
+ declare _draftsLoading: boolean;
126
+ declare _draftsError: string | null;
127
+ declare _draftMenuOpenId: string | null;
128
+ declare _addCollectionPanelOpen: boolean;
129
+ declare _replyToId: string | null;
130
+ declare _replyToData: ReplyToData | null;
131
+ declare _replyExpanded: boolean;
132
+ declare _visibility: ComposeVisibility;
133
+ declare _featured: boolean;
134
+ declare _showVisibilityMenu: boolean;
135
+
136
+ private _attachedEditor: Editor | null = null;
137
+ private _attachedTextSnapshot: JSONContent | null = null;
138
+ private _confirmForDrafts = false;
139
+ private _draftSaveTimer: ReturnType<typeof setTimeout> | null = null;
140
+ private _draftRestored = false;
141
+ private _initialSnapshot: string | null = null;
142
+ private _pageFocusApplied = false;
143
+ private _pageLeaveRequested = false;
144
+ private _suppressBeforeUnload = false;
145
+
146
+ createRenderRoot() {
147
+ this.innerHTML = "";
148
+ return this;
149
+ }
150
+
151
+ constructor() {
152
+ super();
153
+ this.collections = [];
154
+ this.labels = {} as ComposeLabels;
155
+ this.uploadMaxFileSize = 500;
156
+ this.pageMode = false;
157
+ this.closeHref = "/";
158
+ this.autoRestoreDraft = false;
159
+ this._format = "note";
160
+ this._status = "published";
161
+ this._loading = false;
162
+ this._collectionIds = [];
163
+ this._showCollection = false;
164
+ this._showMoreMenu = false;
165
+ this._collectionSearch = "";
166
+ this._altPanelOpen = false;
167
+ this._altPanelIndex = 0;
168
+ this._attachedPanelOpen = false;
169
+ this._attachedTextIndex = 0;
170
+ this._confirmPanelOpen = false;
171
+ this._editPostId = null;
172
+ this._draftSourceId = null;
173
+ this._draftsPanelOpen = false;
174
+ this._drafts = [];
175
+ this._draftsLoading = false;
176
+ this._draftsError = null;
177
+ this._draftMenuOpenId = null;
178
+ this._addCollectionPanelOpen = false;
179
+ this._replyToId = null;
180
+ this._replyToData = null;
181
+ this._replyExpanded = false;
182
+ this._visibility = "public";
183
+ this._featured = false;
184
+ this._showVisibilityMenu = false;
185
+ }
186
+
187
+ private get _editor(): JantComposeEditor | null {
188
+ return this.querySelector("jant-compose-editor");
189
+ }
190
+
191
+ protected updated(changed: Map<string, unknown>) {
192
+ super.updated(changed);
193
+ if (this._initialSnapshot === null && this._editor) {
194
+ this._captureInitialSnapshot();
195
+ }
196
+ if (changed.has("_format") || changed.has("_collectionIds")) {
197
+ // Schedule draft auto-save for new-post mode only
198
+ if (!this._editPostId && !this._draftSourceId) {
199
+ this._scheduleDraftSave();
200
+ }
201
+ }
202
+ }
203
+
204
+ reset() {
205
+ this._format = "note";
206
+ this._status = "published";
207
+ this._loading = false;
208
+ this._collectionIds = [];
209
+ this._showCollection = false;
210
+ this._showMoreMenu = false;
211
+ this._collectionSearch = "";
212
+ this._altPanelOpen = false;
213
+ this._altPanelIndex = 0;
214
+ this._attachedPanelOpen = false;
215
+ this._attachedTextIndex = 0;
216
+ this._confirmPanelOpen = false;
217
+ this._editPostId = null;
218
+ this._draftSourceId = null;
219
+ this._draftsPanelOpen = false;
220
+ this._drafts = [];
221
+ this._draftsLoading = false;
222
+ this._draftsError = null;
223
+ this._draftMenuOpenId = null;
224
+ this._addCollectionPanelOpen = false;
225
+ this._replyToId = null;
226
+ this._replyToData = null;
227
+ this._replyExpanded = false;
228
+ this._visibility = "public";
229
+ this._featured = false;
230
+ this._showVisibilityMenu = false;
231
+ this._confirmForDrafts = false;
232
+ this._initialSnapshot = null;
233
+ this._pageFocusApplied = false;
234
+ this._pageLeaveRequested = false;
235
+ this._suppressBeforeUnload = false;
236
+ this._destroyAttachedEditor();
237
+ this._editor?.reset();
238
+ this._captureInitialSnapshot();
239
+ }
240
+
241
+ async openEdit(id: string) {
242
+ this.reset();
243
+
244
+ const res = await fetch(`/api/posts/${id}`);
245
+ if (!res.ok) return;
246
+ const post = await res.json();
247
+
248
+ this._editPostId = id;
249
+ this._format = post.format;
250
+
251
+ // Pre-fill collection memberships if present
252
+ if (post.collectionIds?.length) {
253
+ this._collectionIds = post.collectionIds;
254
+ }
255
+
256
+ // Wait for Lit to render with the new format before populating editor
257
+ await this.updateComplete;
258
+
259
+ // Separate text media items from other media attachments
260
+ const allMedia = post.mediaAttachments ?? [];
261
+ const nonTextMedia = allMedia.filter(
262
+ (m: { mimeType: string }) => !m.mimeType.startsWith("text/"),
263
+ );
264
+ const textMedia = allMedia.filter(
265
+ (m: { mimeType: string }) => m.mimeType === "text/x-tiptap+json",
266
+ );
267
+
268
+ // Fetch text content for TipTap text media items (stored as { json, html } envelope)
269
+ const textAttachments = await Promise.all(
270
+ textMedia.map(
271
+ async (m: { id: string; url: string; summary?: string }) => {
272
+ try {
273
+ const textRes = await fetch(`/api/media/${m.id}/content`);
274
+ if (textRes.ok) {
275
+ const raw = await textRes.text();
276
+ const envelope = JSON.parse(raw) as {
277
+ json?: unknown;
278
+ html?: string;
279
+ };
280
+ return {
281
+ bodyJson: JSON.stringify(envelope.json ?? {}),
282
+ bodyHtml: envelope.html ?? "",
283
+ summary: m.summary ?? "",
284
+ mediaId: m.id,
285
+ };
286
+ }
287
+ } catch {
288
+ // Fetch failed — skip
289
+ }
290
+ return {
291
+ bodyJson: "{}",
292
+ bodyHtml: "",
293
+ summary: m.summary ?? "",
294
+ mediaId: m.id,
295
+ };
296
+ },
297
+ ),
298
+ );
299
+
300
+ this._editor?.populate({
301
+ format: post.format,
302
+ title: post.title ?? undefined,
303
+ bodyJson: post.body ?? undefined,
304
+ url: post.url ?? undefined,
305
+ quoteText: post.quoteText ?? undefined,
306
+ quoteAuthor:
307
+ post.format === "quote" ? (post.title ?? undefined) : undefined,
308
+ rating: post.rating ?? undefined,
309
+ media: nonTextMedia.map(
310
+ (m: {
311
+ id: string;
312
+ previewUrl: string;
313
+ alt?: string;
314
+ mimeType: string;
315
+ }) => ({
316
+ id: m.id,
317
+ previewUrl: m.previewUrl,
318
+ alt: m.alt,
319
+ mimeType: m.mimeType,
320
+ }),
321
+ ),
322
+ textAttachments,
323
+ attachmentOrder: allMedia.map((m: { id: string }) => m.id),
324
+ });
325
+
326
+ this.closest("dialog")?.showModal();
327
+ globalThis.requestAnimationFrame(() => {
328
+ this._editor?.focusInput();
329
+ this._captureInitialSnapshot();
330
+ });
331
+ }
332
+
333
+ /**
334
+ * Open compose dialog in reply mode.
335
+ *
336
+ * @param id - UUID of the post being replied to
337
+ * @param replyData - Pre-captured content from the DOM (avoids API fetch)
338
+ */
339
+ async openReply(id: string, replyData?: ReplyToData) {
340
+ this.reset();
341
+ this._replyToId = id;
342
+ this._replyToData = replyData ?? null;
343
+ this._format = "note";
344
+
345
+ this.closest("dialog")?.showModal();
346
+ await this.updateComplete;
347
+ this._editor?.focusInput();
348
+ this._captureInitialSnapshot();
349
+ }
350
+
351
+ /**
352
+ * Fetch parent post from API to populate reply context preview.
353
+ * Falls back gracefully if the parent is unavailable (deleted, etc.).
354
+ */
355
+ private async _fetchReplyContext(replyToId: string) {
356
+ try {
357
+ const res = await fetch(`/api/posts/${replyToId}`);
358
+ if (!res.ok) return;
359
+ const post = await res.json();
360
+ const dateText = post.publishedAt
361
+ ? new Date(post.publishedAt * 1000).toLocaleDateString(undefined, {
362
+ month: "short",
363
+ day: "numeric",
364
+ })
365
+ : "";
366
+ this._replyToData = {
367
+ contentHtml: (post.bodyHtml as string) ?? "",
368
+ dateText,
369
+ };
370
+ } catch {
371
+ // Parent unavailable — reply mode still works, just no preview
372
+ }
373
+ }
374
+
375
+ set loading(v: boolean) {
376
+ this._loading = v;
377
+ }
378
+
379
+ private _closeDialog() {
380
+ const dialog = this.closest("dialog");
381
+ if (dialog) {
382
+ dialog.close();
383
+ return;
384
+ }
385
+
386
+ if (this.pageMode) {
387
+ this._suppressBeforeUnload = true;
388
+ globalThis.location.assign(this.closeHref || "/");
389
+ }
390
+ }
391
+
392
+ requestCloseAndLeave() {
393
+ this._pageLeaveRequested = true;
394
+ this.requestClose();
395
+ }
396
+
397
+ consumePageLeaveRequest(): boolean {
398
+ const shouldLeave = this._pageLeaveRequested;
399
+ this._pageLeaveRequested = false;
400
+ return shouldLeave;
401
+ }
402
+
403
+ preparePageLeave() {
404
+ this._suppressBeforeUnload = true;
405
+ }
406
+
407
+ private _hasContent(): boolean {
408
+ const editor = this._editor;
409
+ if (!editor) return false;
410
+
411
+ const data = editor.getData();
412
+ if (data.body) return true;
413
+ if (data.title.trim()) return true;
414
+ if (data.url.trim()) return true;
415
+ if (data.quoteText.trim()) return true;
416
+ if (data.quoteAuthor.trim()) return true;
417
+ if (data.attachedTexts.some((t) => t.bodyJson !== null)) return true;
418
+ if (data.rating > 0) return true;
419
+ if (data.attachments.length > 0) return true;
420
+ // Collection selection alone isn't content — it's metadata that
421
+ // only matters when paired with actual post content above.
422
+
423
+ return false;
424
+ }
425
+
426
+ private _buildSnapshot(): ComposeStateSnapshot | null {
427
+ const editor = this._editor;
428
+ if (!editor) return null;
429
+
430
+ return {
431
+ format: this._format,
432
+ collectionIds: [...this._collectionIds],
433
+ title: editor._title,
434
+ bodyJson: editor._bodyJson,
435
+ url: editor._url,
436
+ quoteText: editor._quoteText,
437
+ quoteAuthor: editor._quoteAuthor,
438
+ rating: editor._rating,
439
+ showTitle: editor._showTitle,
440
+ showRating: editor._showRating,
441
+ attachments: editor._attachments.map((attachment) => ({
442
+ clientId: attachment.clientId,
443
+ mediaId: attachment.mediaId,
444
+ previewUrl: attachment.previewUrl,
445
+ mimeType: attachment.file.type,
446
+ alt: attachment.alt,
447
+ status: attachment.status,
448
+ summary: attachment.summary,
449
+ chars: attachment.chars,
450
+ })),
451
+ attachedTexts: editor._attachedTexts.map((item) => ({
452
+ clientId: item.clientId,
453
+ mediaId: item.mediaId ?? null,
454
+ bodyJson: item.bodyJson,
455
+ bodyHtml: item.bodyHtml,
456
+ summary: item.summary,
457
+ })),
458
+ attachmentOrder: [...editor._attachmentOrder],
459
+ };
460
+ }
461
+
462
+ private _serializeSnapshot(
463
+ snapshot: ComposeStateSnapshot | null,
464
+ ): string | null {
465
+ if (!snapshot) return null;
466
+ return JSON.stringify(snapshot);
467
+ }
468
+
469
+ private _captureInitialSnapshot() {
470
+ this._initialSnapshot = this._serializeSnapshot(this._buildSnapshot());
471
+ }
472
+
473
+ private _hasUnsavedChanges(): boolean {
474
+ const currentSnapshot = this._serializeSnapshot(this._buildSnapshot());
475
+ if (currentSnapshot === null) return false;
476
+ if (this._initialSnapshot === null) return this._hasContent();
477
+ return currentSnapshot !== this._initialSnapshot;
478
+ }
479
+
480
+ requestClose() {
481
+ if (this._loading) return;
482
+
483
+ // Dismiss any open dropdowns first
484
+ if (this._showCollection) {
485
+ this._showCollection = false;
486
+ this._collectionSearch = "";
487
+ }
488
+ if (this._showMoreMenu) {
489
+ this._showMoreMenu = false;
490
+ }
491
+ if (this._showVisibilityMenu) {
492
+ this._showVisibilityMenu = false;
493
+ }
494
+
495
+ if (this._confirmPanelOpen) {
496
+ this._confirmPanelOpen = false;
497
+ this._confirmForDrafts = false;
498
+ this._pageLeaveRequested = false;
499
+ this.updateComplete.then(() => this._editor?.focusInput());
500
+ return;
501
+ }
502
+
503
+ // In edit mode, only prompt if actual changes were made
504
+ if (this._editPostId) {
505
+ if (this._hasUnsavedChanges()) {
506
+ this._confirmForDrafts = false;
507
+ this._confirmPanelOpen = true;
508
+ } else {
509
+ this._closeDialog();
510
+ this.reset();
511
+ }
512
+ return;
513
+ }
514
+
515
+ if (this._hasContent()) {
516
+ this._confirmForDrafts = false;
517
+ this._confirmPanelOpen = true;
518
+ } else {
519
+ this._closeDialog();
520
+ this.reset();
521
+ }
522
+ }
523
+
524
+ private _discardAndClose() {
525
+ if (this._draftSourceId) {
526
+ const id = this._draftSourceId;
527
+ fetch(`/api/posts/${id}`, { method: "DELETE" }).catch(() => {});
528
+ showToast(this.labels.draftDeleted);
529
+ }
530
+ this._clearDraftFromStorage();
531
+ this._confirmPanelOpen = false;
532
+ this._closeDialog();
533
+ (document.activeElement as HTMLElement)?.blur();
534
+ this.reset();
535
+ }
536
+
537
+ private _handleConfirmSave() {
538
+ if (this._confirmForDrafts) {
539
+ this._dispatchSubmit("draft");
540
+ this._confirmPanelOpen = false;
541
+ this.reset();
542
+ this._openDraftsPanel();
543
+ } else if (this._editPostId) {
544
+ // Editing a published post — publish the update directly
545
+ this._confirmPanelOpen = false;
546
+ this._submit("published");
547
+ } else {
548
+ this._confirmPanelOpen = false;
549
+ this._submit("draft");
550
+ }
551
+ }
552
+
553
+ private _handleConfirmDiscard() {
554
+ if (this._confirmForDrafts) {
555
+ if (this._draftSourceId) {
556
+ const id = this._draftSourceId;
557
+ fetch(`/api/posts/${id}`, { method: "DELETE" }).catch(() => {});
558
+ showToast(this.labels.draftDeleted);
559
+ }
560
+ this._confirmPanelOpen = false;
561
+ this.reset();
562
+ this._openDraftsPanel();
563
+ } else {
564
+ this._discardAndClose();
565
+ }
566
+ }
567
+
568
+ private _buildSubmitDetail(
569
+ status: "published" | "draft",
570
+ ): ComposeSubmitDetail | null {
571
+ const editor = this._editor;
572
+ if (!editor) return null;
573
+
574
+ const editorData = editor.getData();
575
+ const attachments = editorData.attachments ?? [];
576
+
577
+ // Collect mediaIds from completed uploads
578
+ const mediaIds = attachments
579
+ .filter((a) => a.status === "done" && a.mediaId)
580
+ .map((a) => a.mediaId as string);
581
+
582
+ // Collect alt text keyed by mediaId
583
+ const mediaAlts: Record<string, string> = {};
584
+ for (const a of attachments) {
585
+ if (a.mediaId && a.alt) {
586
+ mediaAlts[a.mediaId] = a.alt;
587
+ }
588
+ }
589
+
590
+ // Capture clientId → mediaId for all done attachments now,
591
+ // because the editor will be reset before the deferred handler runs
592
+ const mediaClientMap: Record<string, string> = {};
593
+ for (const a of attachments) {
594
+ if (a.mediaId) {
595
+ mediaClientMap[a.clientId] = a.mediaId;
596
+ }
597
+ }
598
+
599
+ return {
600
+ format: this._format,
601
+ title: editorData.title,
602
+ body: editorData.body,
603
+ url: editorData.url,
604
+ quoteText: editorData.quoteText,
605
+ quoteAuthor: editorData.quoteAuthor,
606
+ status,
607
+ visibility: this._visibility,
608
+ featured: this._featured || undefined,
609
+ rating: editorData.rating,
610
+ collectionIds: [...this._collectionIds],
611
+ mediaIds,
612
+ mediaAlts,
613
+ attachedTexts: editorData.attachedTexts,
614
+ attachmentOrder: editorData.attachmentOrder ?? [],
615
+ mediaClientMap,
616
+ editPostId: this._editPostId ?? this._draftSourceId ?? undefined,
617
+ replyToId: this._replyToId ?? undefined,
618
+ };
619
+ }
620
+
621
+ private _dispatchSubmit(status: "published" | "draft"): boolean {
622
+ if (this._loading) return false;
623
+ const editor = this._editor;
624
+ if (!editor) return false;
625
+
626
+ const detail = this._buildSubmitDetail(status);
627
+ if (!detail) return false;
628
+
629
+ const attachments = editor._attachments ?? [];
630
+ const pendingAttachments = attachments.filter(
631
+ (a) =>
632
+ a.status === "pending" ||
633
+ a.status === "processing" ||
634
+ a.status === "uploading",
635
+ );
636
+
637
+ this.dispatchEvent(
638
+ new CustomEvent("jant:compose-submit-deferred", {
639
+ bubbles: true,
640
+ detail: { ...detail, pendingAttachments },
641
+ }),
642
+ );
643
+ return true;
644
+ }
645
+
646
+ private _submit(status: "published" | "draft") {
647
+ this._clearDraftFromStorage();
648
+ if (!this._dispatchSubmit(status)) return;
649
+ if (this.pageMode) {
650
+ this._loading = true;
651
+ return;
652
+ }
653
+ this._closeDialog();
654
+ // Prevent browser from restoring focus to the trigger button
655
+ (document.activeElement as HTMLElement)?.blur();
656
+ this.reset();
657
+ }
658
+
659
+ private _toggleCollection(id: string) {
660
+ if (this._collectionIds.includes(id)) {
661
+ this._collectionIds = this._collectionIds.filter((cid) => cid !== id);
662
+ } else {
663
+ this._collectionIds = [...this._collectionIds, id];
664
+ }
665
+ }
666
+
667
+ private _selectedCollectionLabel(collections: ComposeCollection[]): string {
668
+ const ids = this._collectionIds;
669
+ const first = collections.find((c) => c.id === ids[0]);
670
+ if (!first) return "";
671
+ if (ids.length === 1) return first.title;
672
+ return this.labels.collectionCountLabel
673
+ .replace("%name%", first.title)
674
+ .replace("%count%", String(ids.length - 1));
675
+ }
676
+
677
+ connectedCallback() {
678
+ super.connectedCallback();
679
+ this.addEventListener("keydown", this._handleKeydown);
680
+ this.addEventListener("jant:alt-panel-open", this._handleAltPanelOpen);
681
+ this.addEventListener("jant:alt-panel-close", this._handleAltPanelClose);
682
+ this.addEventListener(
683
+ "jant:attached-panel-open",
684
+ this._handleAttachedPanelOpen,
685
+ );
686
+ this.addEventListener(
687
+ "jant:compose-content-changed",
688
+ this._onContentChanged,
689
+ );
690
+ // Listen on document — fullscreen element lives on document.body, outside the dialog
691
+ document.addEventListener(
692
+ "jant:fullscreen-close",
693
+ this._handleFullscreenClose as EventListener,
694
+ );
695
+
696
+ // Flush pending draft save before page unload (covers refresh/close mid-debounce)
697
+ window.addEventListener("beforeunload", this._onBeforeUnload);
698
+
699
+ // Intercept native dialog cancel (ESC) to route through requestClose
700
+ const dialog = this.closest("dialog");
701
+ if (dialog) {
702
+ dialog.addEventListener("cancel", this._handleDialogCancel);
703
+ }
704
+
705
+ if (this.pageMode) {
706
+ this.updateComplete.then(() => this._focusPageEditorOnMount());
707
+ }
708
+ }
709
+
710
+ disconnectedCallback() {
711
+ super.disconnectedCallback();
712
+ this.removeEventListener("keydown", this._handleKeydown);
713
+ this.removeEventListener("jant:alt-panel-open", this._handleAltPanelOpen);
714
+ this.removeEventListener("jant:alt-panel-close", this._handleAltPanelClose);
715
+ this.removeEventListener(
716
+ "jant:attached-panel-open",
717
+ this._handleAttachedPanelOpen,
718
+ );
719
+ this.removeEventListener(
720
+ "jant:compose-content-changed",
721
+ this._onContentChanged,
722
+ );
723
+ document.removeEventListener(
724
+ "jant:fullscreen-close",
725
+ this._handleFullscreenClose as EventListener,
726
+ );
727
+ window.removeEventListener("beforeunload", this._onBeforeUnload);
728
+ this._destroyAttachedEditor();
729
+ this._cancelDraftSaveTimer();
730
+
731
+ const dialog = this.closest("dialog");
732
+ if (dialog) {
733
+ dialog.removeEventListener("cancel", this._handleDialogCancel);
734
+ }
735
+ }
736
+
737
+ private _handleDialogCancel = (e: Event) => {
738
+ e.preventDefault();
739
+ this.requestClose();
740
+ };
741
+
742
+ private _handleKeydown = (e: Event) => {
743
+ const ke = e as globalThis.KeyboardEvent;
744
+ if (ke.key === "Escape") {
745
+ ke.preventDefault();
746
+ ke.stopPropagation();
747
+ if (this._showCollection) {
748
+ this._showCollection = false;
749
+ this._collectionSearch = "";
750
+ } else if (this._showMoreMenu) {
751
+ this._showMoreMenu = false;
752
+ } else if (this._showVisibilityMenu) {
753
+ this._showVisibilityMenu = false;
754
+ } else if (this._addCollectionPanelOpen) {
755
+ this._addCollectionPanelOpen = false;
756
+ } else if (this._draftMenuOpenId) {
757
+ this._draftMenuOpenId = null;
758
+ } else if (this._draftsPanelOpen) {
759
+ this._closeDraftsPanel();
760
+ } else if (this._attachedPanelOpen) {
761
+ this._cancelAttachedPanel();
762
+ } else {
763
+ this.requestClose();
764
+ }
765
+ } else if (ke.key === "Enter" && this._confirmPanelOpen) {
766
+ ke.preventDefault();
767
+ this._handleConfirmSave();
768
+ } else if ((ke.metaKey || ke.ctrlKey) && ke.key === "Enter") {
769
+ e.preventDefault();
770
+ this._submit("published");
771
+ }
772
+ };
773
+
774
+ private _handleAltPanelOpen = (e: Event) => {
775
+ const detail = (e as CustomEvent<{ index: number }>).detail;
776
+ this._altPanelIndex = detail.index;
777
+ this._altPanelOpen = true;
778
+ this.updateComplete.then(() => {
779
+ this.querySelector<HTMLInputElement>(".compose-alt-input")?.focus();
780
+ });
781
+ };
782
+
783
+ private _handleAltPanelClose = () => {
784
+ this._altPanelOpen = false;
785
+ };
786
+
787
+ private _getAltAttachment(): ComposeAttachment | null {
788
+ return this._editor?._attachments[this._altPanelIndex] ?? null;
789
+ }
790
+
791
+ private _onAltInput(e: Event) {
792
+ const value = (e.target as HTMLInputElement).value;
793
+ this._editor?.updateAlt(this._altPanelIndex, value);
794
+ }
795
+
796
+ private _closeAltPanel() {
797
+ this._altPanelOpen = false;
798
+ }
799
+
800
+ private _handleFullscreenClose = (
801
+ e: CustomEvent<{ json: unknown; title: string }>,
802
+ ) => {
803
+ const editor = this._editor;
804
+ if (editor) {
805
+ editor.setEditorState(
806
+ e.detail.json as import("@tiptap/core").JSONContent,
807
+ e.detail.title,
808
+ );
809
+ }
810
+ };
811
+
812
+ private _handleAttachedPanelOpen = (e: Event) => {
813
+ const detail = (e as CustomEvent<{ index: number }>).detail;
814
+ this._attachedTextIndex = detail.index;
815
+ this._attachedPanelOpen = true;
816
+ this.updateComplete.then(() => {
817
+ const container = this.querySelector<HTMLElement>(
818
+ ".compose-attached-tiptap",
819
+ );
820
+ if (!container) return;
821
+ const item = this._editor?._attachedTexts[this._attachedTextIndex];
822
+ const content = item?.bodyJson ?? null;
823
+ this._attachedTextSnapshot = content
824
+ ? JSON.parse(JSON.stringify(content))
825
+ : null;
826
+ this._attachedEditor = createTiptapEditor({
827
+ element: container,
828
+ placeholder: this.labels.attachedTextPlaceholder,
829
+ content,
830
+ });
831
+ this._attachedEditor.commands.focus();
832
+ });
833
+ };
834
+
835
+ private _isAttachedTextDirty(): boolean {
836
+ if (!this._attachedEditor) return false;
837
+ return (
838
+ JSON.stringify(this._attachedEditor.getJSON()) !==
839
+ JSON.stringify(this._attachedTextSnapshot)
840
+ );
841
+ }
842
+
843
+ private _destroyAttachedEditor() {
844
+ if (this._attachedEditor) {
845
+ this._attachedEditor.destroy();
846
+ this._attachedEditor = null;
847
+ }
848
+ this._attachedTextSnapshot = null;
849
+ }
850
+
851
+ private _doneAttachedPanel() {
852
+ if (this._attachedEditor) {
853
+ const json = this._attachedEditor.getJSON();
854
+ const html = this._attachedEditor.getHTML();
855
+ this._editor?.updateAttachedText(this._attachedTextIndex, json, html);
856
+ }
857
+ this._destroyAttachedEditor();
858
+ this._attachedPanelOpen = false;
859
+ this._editor?.closeAttachedPanel(this._attachedTextIndex);
860
+ }
861
+
862
+ private _cancelAttachedPanel() {
863
+ if (this._isAttachedTextDirty()) {
864
+ if (!globalThis.confirm("Discard changes?")) return;
865
+ }
866
+ // Revert to snapshot — don't save current editor content
867
+ this._destroyAttachedEditor();
868
+ this._attachedPanelOpen = false;
869
+ }
870
+
871
+ // ── Drafts panel ─────────────────────────────────────────────────
872
+
873
+ private _handleDraftButtonClick() {
874
+ if (this._loading) return;
875
+ if (this._hasContent()) {
876
+ this._confirmForDrafts = true;
877
+ this._confirmPanelOpen = true;
878
+ } else {
879
+ this._openDraftsPanel();
880
+ }
881
+ }
882
+
883
+ private async _openDraftsPanel() {
884
+ this._draftsPanelOpen = true;
885
+ this._draftsLoading = true;
886
+ this._draftsError = null;
887
+ this._draftMenuOpenId = null;
888
+
889
+ try {
890
+ const res = await fetch("/api/posts?status=draft&limit=50");
891
+ if (!res.ok) throw new Error("Failed to load drafts");
892
+ const json = await res.json();
893
+ const posts = json.posts ?? json;
894
+ this._drafts = (posts as Record<string, unknown>[]).map(
895
+ (p): DraftItem => ({
896
+ id: p.id as string,
897
+ format: p.format as ComposeFormat,
898
+ title: (p.title as string) ?? null,
899
+ bodyText: (p.bodyText as string) ?? null,
900
+ bodyHtml: (p.bodyHtml as string) ?? null,
901
+ url: (p.url as string) ?? null,
902
+ quoteText: (p.quoteText as string) ?? null,
903
+ replyToId: (p.replyToId as string) ?? null,
904
+ updatedAt: p.updatedAt as number,
905
+ mediaAttachments: (
906
+ (p.mediaAttachments as DraftItem["mediaAttachments"]) ?? []
907
+ ).map((m) => ({
908
+ id: m.id,
909
+ previewUrl: m.previewUrl,
910
+ alt: m.alt,
911
+ mimeType: m.mimeType,
912
+ })),
913
+ }),
914
+ );
915
+ } catch {
916
+ this._draftsError = "Could not load drafts. Try again.";
917
+ this._drafts = [];
918
+ } finally {
919
+ this._draftsLoading = false;
920
+ }
921
+ }
922
+
923
+ private _closeDraftsPanel() {
924
+ this._draftsPanelOpen = false;
925
+ this._draftMenuOpenId = null;
926
+ this.updateComplete.then(() => this._editor?.focusInput());
927
+ }
928
+
929
+ private async _loadDraft(id: string) {
930
+ this._draftsPanelOpen = false;
931
+ this._draftMenuOpenId = null;
932
+ this.reset();
933
+
934
+ const res = await fetch(`/api/posts/${id}`);
935
+ if (!res.ok) return;
936
+ const post = await res.json();
937
+
938
+ this._draftSourceId = id;
939
+ this._format = post.format;
940
+
941
+ if (post.collectionIds?.length) {
942
+ this._collectionIds = post.collectionIds;
943
+ }
944
+
945
+ // Restore reply context if this draft was a reply
946
+ if (post.replyToId) {
947
+ this._replyToId = post.replyToId;
948
+ await this._fetchReplyContext(post.replyToId);
949
+ }
950
+
951
+ await this.updateComplete;
952
+
953
+ // Separate text media items from other media attachments
954
+ const allMedia = post.mediaAttachments ?? [];
955
+ const nonTextMedia = allMedia.filter(
956
+ (m: { mimeType: string }) => !m.mimeType.startsWith("text/"),
957
+ );
958
+ const textMedia = allMedia.filter(
959
+ (m: { mimeType: string }) => m.mimeType === "text/x-tiptap+json",
960
+ );
961
+
962
+ // Fetch text content for TipTap text media items (stored as { json, html } envelope)
963
+ const textAttachments = await Promise.all(
964
+ textMedia.map(
965
+ async (m: { id: string; url: string; summary?: string }) => {
966
+ try {
967
+ const textRes = await fetch(`/api/media/${m.id}/content`);
968
+ if (textRes.ok) {
969
+ const raw = await textRes.text();
970
+ const envelope = JSON.parse(raw) as {
971
+ json?: unknown;
972
+ html?: string;
973
+ };
974
+ return {
975
+ bodyJson: JSON.stringify(envelope.json ?? {}),
976
+ bodyHtml: envelope.html ?? "",
977
+ summary: m.summary ?? "",
978
+ mediaId: m.id,
979
+ };
980
+ }
981
+ } catch {
982
+ // Fetch failed — skip
983
+ }
984
+ return {
985
+ bodyJson: "{}",
986
+ bodyHtml: "",
987
+ summary: m.summary ?? "",
988
+ mediaId: m.id,
989
+ };
990
+ },
991
+ ),
992
+ );
993
+
994
+ this._editor?.populate({
995
+ format: post.format,
996
+ title: post.title ?? undefined,
997
+ bodyJson: post.body ?? undefined,
998
+ url: post.url ?? undefined,
999
+ quoteText: post.quoteText ?? undefined,
1000
+ quoteAuthor:
1001
+ post.format === "quote" ? (post.title ?? undefined) : undefined,
1002
+ rating: post.rating ?? undefined,
1003
+ media: nonTextMedia.map(
1004
+ (m: {
1005
+ id: string;
1006
+ previewUrl: string;
1007
+ alt?: string;
1008
+ mimeType: string;
1009
+ }) => ({
1010
+ id: m.id,
1011
+ previewUrl: m.previewUrl,
1012
+ alt: m.alt,
1013
+ mimeType: m.mimeType,
1014
+ }),
1015
+ ),
1016
+ textAttachments,
1017
+ attachmentOrder: allMedia.map((m: { id: string }) => m.id),
1018
+ });
1019
+
1020
+ globalThis.requestAnimationFrame(() => {
1021
+ this._editor?.focusInput();
1022
+ this._captureInitialSnapshot();
1023
+ });
1024
+ }
1025
+
1026
+ private async _deleteDraft(id: string) {
1027
+ this._draftMenuOpenId = null;
1028
+ this._drafts = this._drafts.filter((d) => d.id !== id);
1029
+
1030
+ try {
1031
+ const res = await fetch(`/api/posts/${id}`, { method: "DELETE" });
1032
+ if (!res.ok) throw new Error();
1033
+ showToast(this.labels.draftDeleted);
1034
+ } catch {
1035
+ showToast("Failed to delete draft. Try again.", "error");
1036
+ this._openDraftsPanel();
1037
+ }
1038
+ }
1039
+
1040
+ private _formatDraftDate(timestamp: number): string {
1041
+ const now = Date.now() / 1000;
1042
+ const diff = now - timestamp;
1043
+ if (diff < 60) return "now";
1044
+ if (diff < 3600) return `${Math.floor(diff / 60)}m`;
1045
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
1046
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
1047
+ const d = new Date(timestamp * 1000);
1048
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
1049
+ }
1050
+
1051
+ private _getDraftPreview(draft: DraftItem): string | null {
1052
+ if (draft.bodyText) return draft.bodyText;
1053
+ if (draft.title) return draft.title;
1054
+ if (draft.quoteText) return draft.quoteText;
1055
+ if (draft.url) return draft.url;
1056
+ return null;
1057
+ }
1058
+
1059
+ // ── Local draft auto-save (globalThis.localStorage) ──────────────────────────
1060
+
1061
+ private static _DRAFT_KEY = "jant:compose-draft";
1062
+ private static _DRAFT_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
1063
+
1064
+ private _onContentChanged = () => {
1065
+ // Schedule localStorage auto-save for new-post mode only
1066
+ if (!this._editPostId && !this._draftSourceId) {
1067
+ this._scheduleDraftSave();
1068
+ }
1069
+ };
1070
+
1071
+ private _cancelDraftSaveTimer() {
1072
+ if (this._draftSaveTimer !== null) {
1073
+ clearTimeout(this._draftSaveTimer);
1074
+ this._draftSaveTimer = null;
1075
+ }
1076
+ }
1077
+
1078
+ private _scheduleDraftSave() {
1079
+ this._cancelDraftSaveTimer();
1080
+ this._draftSaveTimer = setTimeout(() => this._saveDraftToStorage(), 1000);
1081
+ }
1082
+
1083
+ /** Flush pending draft save and warn on unsaved changes before page unload */
1084
+ private _onBeforeUnload = (e: globalThis.BeforeUnloadEvent) => {
1085
+ if (this._suppressBeforeUnload) return;
1086
+
1087
+ // Flush any pending debounced draft save
1088
+ if (this._draftSaveTimer !== null) {
1089
+ this._cancelDraftSaveTimer();
1090
+ this._saveDraftToStorage();
1091
+ }
1092
+ // Warn if compose has unsaved modifications in either dialog or page mode.
1093
+ const dialog = this.closest("dialog");
1094
+ const shouldWarn =
1095
+ this._hasUnsavedChanges() && (this.pageMode || dialog?.open === true);
1096
+ if (shouldWarn) {
1097
+ e.preventDefault();
1098
+ e.returnValue = "";
1099
+ }
1100
+ };
1101
+
1102
+ private _saveDraftToStorage() {
1103
+ const editor = this._editor;
1104
+ if (!editor) return;
1105
+
1106
+ const data = editor.getData();
1107
+ const hasContent =
1108
+ !!data.body ||
1109
+ !!data.title.trim() ||
1110
+ !!data.url.trim() ||
1111
+ !!data.quoteText.trim() ||
1112
+ !!data.quoteAuthor.trim() ||
1113
+ data.rating > 0 ||
1114
+ data.attachedTexts.some((t) => t.bodyJson !== null);
1115
+
1116
+ if (!hasContent) {
1117
+ globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
1118
+ return;
1119
+ }
1120
+
1121
+ const draft: LocalDraft = {
1122
+ format: this._format,
1123
+ title: data.title,
1124
+ bodyJson: editor._bodyJson,
1125
+ url: data.url,
1126
+ quoteText: data.quoteText,
1127
+ quoteAuthor: data.quoteAuthor,
1128
+ rating: data.rating,
1129
+ showTitle: editor._showTitle,
1130
+ showRating: editor._showRating,
1131
+ collectionIds: [...this._collectionIds],
1132
+ replyToId: this._replyToId,
1133
+ attachedTexts: data.attachedTexts.map((t) => ({
1134
+ clientId: t.clientId,
1135
+ bodyJson: t.bodyJson,
1136
+ bodyHtml: t.bodyHtml,
1137
+ summary: t.summary,
1138
+ })),
1139
+ attachmentOrder: [...(data.attachmentOrder ?? [])],
1140
+ savedAt: Date.now(),
1141
+ };
1142
+
1143
+ try {
1144
+ globalThis.localStorage.setItem(
1145
+ JantComposeDialog._DRAFT_KEY,
1146
+ JSON.stringify(draft),
1147
+ );
1148
+ } catch {
1149
+ // Storage full or unavailable — silently ignore
1150
+ }
1151
+ }
1152
+
1153
+ private _clearDraftFromStorage() {
1154
+ this._cancelDraftSaveTimer();
1155
+ globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
1156
+ }
1157
+
1158
+ async restoreLocalDraft() {
1159
+ // Don't restore if already in edit or draft-load mode
1160
+ if (this._editPostId || this._draftSourceId) return;
1161
+ // Don't restore if the editor already has content (e.g. reopened dialog)
1162
+ if (this._hasContent()) return;
1163
+
1164
+ let raw: string | null;
1165
+ try {
1166
+ raw = globalThis.localStorage.getItem(JantComposeDialog._DRAFT_KEY);
1167
+ } catch {
1168
+ return;
1169
+ }
1170
+ if (!raw) return;
1171
+
1172
+ let draft: LocalDraft;
1173
+ try {
1174
+ draft = JSON.parse(raw) as LocalDraft;
1175
+ } catch {
1176
+ globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
1177
+ return;
1178
+ }
1179
+
1180
+ // Discard stale drafts
1181
+ if (Date.now() - draft.savedAt > JantComposeDialog._DRAFT_MAX_AGE) {
1182
+ globalThis.localStorage.removeItem(JantComposeDialog._DRAFT_KEY);
1183
+ return;
1184
+ }
1185
+
1186
+ this._format = draft.format;
1187
+ this._collectionIds = [...(draft.collectionIds ?? [])];
1188
+
1189
+ // Restore reply context if this draft was a reply
1190
+ if (draft.replyToId) {
1191
+ this._replyToId = draft.replyToId;
1192
+ await this._fetchReplyContext(draft.replyToId);
1193
+ }
1194
+
1195
+ await this.updateComplete;
1196
+
1197
+ const textAttachments = draft.attachedTexts
1198
+ ?.filter((t) => t.bodyJson !== null)
1199
+ .map((t) => ({
1200
+ clientId: t.clientId,
1201
+ bodyJson: JSON.stringify(t.bodyJson),
1202
+ bodyHtml: t.bodyHtml,
1203
+ summary: t.summary,
1204
+ }));
1205
+
1206
+ this._editor?.populate({
1207
+ format: draft.format,
1208
+ title: draft.title || undefined,
1209
+ bodyJson: draft.bodyJson ? JSON.stringify(draft.bodyJson) : undefined,
1210
+ url: draft.url || undefined,
1211
+ quoteText: draft.quoteText || undefined,
1212
+ quoteAuthor: draft.quoteAuthor || undefined,
1213
+ rating: draft.rating || undefined,
1214
+ showTitle: draft.showTitle,
1215
+ showRating: draft.showRating,
1216
+ textAttachments: textAttachments?.length ? textAttachments : undefined,
1217
+ attachmentOrder: draft.attachmentOrder,
1218
+ });
1219
+
1220
+ this._draftRestored = true;
1221
+ showToast(this.labels.draftRestored);
1222
+ globalThis.requestAnimationFrame(() => {
1223
+ this._captureInitialSnapshot();
1224
+ });
1225
+ }
1226
+
1227
+ private async _focusPageEditorOnMount() {
1228
+ if (this._pageFocusApplied) return;
1229
+
1230
+ if (this.autoRestoreDraft) {
1231
+ await this.restoreLocalDraft();
1232
+ }
1233
+
1234
+ await this.updateComplete;
1235
+ globalThis.requestAnimationFrame(() => {
1236
+ this._editor?.focusInput();
1237
+ this._pageFocusApplied = true;
1238
+ });
1239
+ }
1240
+
1241
+ private _renderDraftsPanel() {
1242
+ if (!this._draftsPanelOpen) return nothing;
1243
+
1244
+ return html`
1245
+ <div class="compose-drafts-panel">
1246
+ <div class="compose-alt-header">
1247
+ <button
1248
+ type="button"
1249
+ class="compose-attached-panel-back"
1250
+ @click=${() => this._closeDraftsPanel()}
1251
+ >
1252
+ <svg
1253
+ class="icon-fine"
1254
+ width="16"
1255
+ height="16"
1256
+ viewBox="0 0 16 16"
1257
+ fill="none"
1258
+ stroke="currentColor"
1259
+ stroke-width="1.5"
1260
+ stroke-linecap="round"
1261
+ stroke-linejoin="round"
1262
+ >
1263
+ <path d="M11 3L6 8l5 5" />
1264
+ </svg>
1265
+ </button>
1266
+ <span class="compose-alt-title">${this.labels.drafts}</span>
1267
+ </div>
1268
+ ${this._draftsLoading
1269
+ ? html`<div class="compose-drafts-loading">
1270
+ <svg
1271
+ class="animate-spin size-5"
1272
+ xmlns="http://www.w3.org/2000/svg"
1273
+ viewBox="0 0 24 24"
1274
+ fill="none"
1275
+ stroke="currentColor"
1276
+ stroke-width="2"
1277
+ stroke-linecap="round"
1278
+ stroke-linejoin="round"
1279
+ >
1280
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
1281
+ </svg>
1282
+ </div>`
1283
+ : this._draftsError
1284
+ ? html`<div class="compose-drafts-empty">${this._draftsError}</div>`
1285
+ : this._drafts.length === 0
1286
+ ? html`<div class="compose-drafts-empty">
1287
+ ${this.labels.draftsEmpty}
1288
+ </div>`
1289
+ : html`<div class="compose-drafts-list">
1290
+ ${this._drafts.map(
1291
+ (draft, i) => html`
1292
+ ${i > 0
1293
+ ? html`<div class="compose-drafts-divider"></div>`
1294
+ : nothing}
1295
+ ${this._renderDraftItem(draft)}
1296
+ `,
1297
+ )}
1298
+ </div>`}
1299
+ </div>
1300
+ `;
1301
+ }
1302
+
1303
+ private _renderDraftItem(draft: DraftItem) {
1304
+ const preview = this._getDraftPreview(draft);
1305
+
1306
+ return html`
1307
+ <div class="compose-draft-item" @click=${() => this._loadDraft(draft.id)}>
1308
+ <div class="compose-draft-content">
1309
+ ${preview
1310
+ ? html`<div class="compose-draft-preview">${preview}</div>`
1311
+ : html`<div
1312
+ class="compose-draft-preview compose-draft-preview-empty"
1313
+ >
1314
+ Empty draft
1315
+ </div>`}
1316
+ <div class="compose-draft-meta">
1317
+ ${this._formatDraftDate(draft.updatedAt)}
1318
+ </div>
1319
+ </div>
1320
+ <div class="relative">
1321
+ ${this._draftMenuOpenId === draft.id
1322
+ ? html`<div
1323
+ class="compose-dropdown-backdrop"
1324
+ @click=${(e: Event) => {
1325
+ e.stopPropagation();
1326
+ this._draftMenuOpenId = null;
1327
+ }}
1328
+ ></div>`
1329
+ : nothing}
1330
+ <button
1331
+ type="button"
1332
+ class="compose-draft-more"
1333
+ @click=${(e: Event) => {
1334
+ e.stopPropagation();
1335
+ this._draftMenuOpenId =
1336
+ this._draftMenuOpenId === draft.id ? null : draft.id;
1337
+ }}
1338
+ >
1339
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1340
+ <circle cx="4" cy="8" r="1.2" />
1341
+ <circle cx="8" cy="8" r="1.2" />
1342
+ <circle cx="12" cy="8" r="1.2" />
1343
+ </svg>
1344
+ </button>
1345
+ ${this._draftMenuOpenId === draft.id
1346
+ ? html`
1347
+ <div class="compose-dropdown compose-dropdown-right">
1348
+ <button
1349
+ type="button"
1350
+ class="compose-dropdown-item compose-dropdown-item-danger"
1351
+ @click=${(e: Event) => {
1352
+ e.stopPropagation();
1353
+ this._deleteDraft(draft.id);
1354
+ }}
1355
+ >
1356
+ ${this.labels.deleteDraft}
1357
+ </button>
1358
+ </div>
1359
+ `
1360
+ : nothing}
1361
+ </div>
1362
+ </div>
1363
+ `;
1364
+ }
1365
+
1366
+ // ── Reply context rendering ──────────────────────────────────────
1367
+
1368
+ private _renderReplyContext() {
1369
+ if (!this._replyToId || !this._replyToData) return nothing;
1370
+
1371
+ const { contentHtml, dateText } = this._replyToData;
1372
+ const isExpanded = this._replyExpanded;
1373
+
1374
+ return html`
1375
+ <div class="compose-reply-row">
1376
+ <div class="compose-thread-dot"></div>
1377
+ <div
1378
+ class=${classMap({
1379
+ "compose-reply-context": true,
1380
+ expanded: isExpanded,
1381
+ })}
1382
+ >
1383
+ <div class="compose-reply-context-body">
1384
+ ${unsafeHTML(contentHtml)}
1385
+ </div>
1386
+ ${!isExpanded
1387
+ ? html`<div class="compose-reply-fade"></div>`
1388
+ : nothing}
1389
+ </div>
1390
+ </div>
1391
+ <div class="compose-reply-meta">
1392
+ ${dateText ? html`<span>${dateText}</span><span>·</span>` : nothing}
1393
+ <button
1394
+ type="button"
1395
+ class="compose-reply-toggle"
1396
+ @click=${() => {
1397
+ this._replyExpanded = !this._replyExpanded;
1398
+ }}
1399
+ >
1400
+ ${isExpanded ? this.labels.showLess : this.labels.showMore}
1401
+ </button>
1402
+ </div>
1403
+ `;
1404
+ }
1405
+
1406
+ // ── Render helpers ────────────────────────────────────────────────
1407
+
1408
+ private _renderHeader() {
1409
+ const formats: ComposeFormat[] = ["note", "link", "quote"];
1410
+ const formatLabels: Record<ComposeFormat, string> = {
1411
+ note: this.labels.note,
1412
+ link: this.labels.link,
1413
+ quote: this.labels.quote,
1414
+ };
1415
+
1416
+ return html`
1417
+ <header class="compose-dialog-header">
1418
+ <button
1419
+ type="button"
1420
+ class="compose-dialog-cancel"
1421
+ @click=${() => this.requestClose()}
1422
+ >
1423
+ ${this.labels.cancel}
1424
+ </button>
1425
+
1426
+ <div class="compose-dialog-header-center">
1427
+ ${this._editPostId
1428
+ ? html`<span class="compose-dialog-title"
1429
+ >${this.labels.editPost}</span
1430
+ >`
1431
+ : html`
1432
+ <div class="compose-segmented">
1433
+ <div
1434
+ class=${classMap({
1435
+ "compose-format-pill": true,
1436
+ "compose-format-pill-link": this._format === "link",
1437
+ "compose-format-pill-quote": this._format === "quote",
1438
+ })}
1439
+ ></div>
1440
+ ${formats.map(
1441
+ (f) => html`
1442
+ <button
1443
+ type="button"
1444
+ class=${classMap({
1445
+ "compose-segmented-item": true,
1446
+ "compose-segmented-item-active": this._format === f,
1447
+ })}
1448
+ @click=${() => {
1449
+ this._format = f;
1450
+ globalThis.requestAnimationFrame(() =>
1451
+ this._editor?.focusInput(),
1452
+ );
1453
+ }}
1454
+ >
1455
+ ${formatLabels[f]}
1456
+ </button>
1457
+ `,
1458
+ )}
1459
+ </div>
1460
+ `}
1461
+ </div>
1462
+
1463
+ <div class="flex items-center gap-0.5 shrink-0">
1464
+ ${this._editPostId
1465
+ ? nothing
1466
+ : html`<button
1467
+ type="button"
1468
+ class="compose-dialog-header-btn"
1469
+ title=${this.labels.saveDraft}
1470
+ ?disabled=${this._loading}
1471
+ @click=${() => this._handleDraftButtonClick()}
1472
+ >
1473
+ <svg
1474
+ class="icon-fine"
1475
+ width="18"
1476
+ height="18"
1477
+ viewBox="0 0 18 18"
1478
+ fill="none"
1479
+ stroke="currentColor"
1480
+ stroke-width="1.3"
1481
+ stroke-linecap="round"
1482
+ stroke-linejoin="round"
1483
+ >
1484
+ <path d="M14 2.5L15.5 4 7 12.5l-3 .5.5-3L14 2.5z" />
1485
+ <path d="M4 15h10" />
1486
+ </svg>
1487
+ </button>`}
1488
+ ${this._renderMoreMenu()}
1489
+ </div>
1490
+ </header>
1491
+ `;
1492
+ }
1493
+
1494
+ private _renderMoreMenu() {
1495
+ return html`
1496
+ <div class="relative">
1497
+ ${this._showMoreMenu
1498
+ ? html`<div
1499
+ class="compose-dropdown-backdrop"
1500
+ @click=${() => {
1501
+ this._showMoreMenu = false;
1502
+ }}
1503
+ ></div>`
1504
+ : nothing}
1505
+ <button
1506
+ type="button"
1507
+ class="compose-dialog-header-btn"
1508
+ @click=${() => {
1509
+ this._showMoreMenu = !this._showMoreMenu;
1510
+ }}
1511
+ >
1512
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
1513
+ <circle cx="4.5" cy="9" r="1.3" />
1514
+ <circle cx="9" cy="9" r="1.3" />
1515
+ <circle cx="13.5" cy="9" r="1.3" />
1516
+ </svg>
1517
+ </button>
1518
+ ${this._showMoreMenu
1519
+ ? html`
1520
+ <div class="compose-dropdown compose-dropdown-right">
1521
+ <button
1522
+ type="button"
1523
+ class="compose-dropdown-item"
1524
+ @click=${() => {
1525
+ this._submit("draft");
1526
+ this._showMoreMenu = false;
1527
+ }}
1528
+ >
1529
+ ${this.labels.saveAsDraft}
1530
+ </button>
1531
+ <div class="compose-dropdown-divider"></div>
1532
+ <button
1533
+ type="button"
1534
+ class="compose-dropdown-item compose-dropdown-item-danger"
1535
+ @click=${() => {
1536
+ this._showMoreMenu = false;
1537
+ this._discardAndClose();
1538
+ }}
1539
+ >
1540
+ ${this.labels.discard}
1541
+ </button>
1542
+ </div>
1543
+ `
1544
+ : nothing}
1545
+ </div>
1546
+ `;
1547
+ }
1548
+
1549
+ private _renderCollectionSelector() {
1550
+ const collections = this.collections ?? [];
1551
+ const search = this._collectionSearch.toLowerCase();
1552
+ const filtered = search
1553
+ ? collections.filter((c) => c.title.toLowerCase().includes(search))
1554
+ : collections;
1555
+ const selectedCount = this._collectionIds.length;
1556
+
1557
+ return html`
1558
+ <div class="flex-1 min-w-0">
1559
+ ${this._showCollection
1560
+ ? html`<div
1561
+ class="compose-dropdown-backdrop"
1562
+ @click=${() => {
1563
+ this._showCollection = false;
1564
+ this._collectionSearch = "";
1565
+ }}
1566
+ ></div>`
1567
+ : nothing}
1568
+ <div class="select compose-collection-select" data-select-initialized>
1569
+ <button
1570
+ type="button"
1571
+ class="compose-collection-trigger"
1572
+ @click=${() => {
1573
+ this._showCollection = !this._showCollection;
1574
+ if (!this._showCollection) {
1575
+ this._collectionSearch = "";
1576
+ }
1577
+ }}
1578
+ >
1579
+ <svg
1580
+ width="14"
1581
+ height="14"
1582
+ viewBox="0 0 18 18"
1583
+ fill="none"
1584
+ stroke="currentColor"
1585
+ stroke-width="1.4"
1586
+ stroke-linecap="round"
1587
+ stroke-linejoin="round"
1588
+ class="shrink-0 icon-fine"
1589
+ >
1590
+ <rect x="3" y="5" width="12" height="10" rx="2" />
1591
+ <path d="M6 5V4a1 1 0 011-1h4a1 1 0 011 1v1" />
1592
+ </svg>
1593
+ ${selectedCount > 0
1594
+ ? html`<span class="compose-collection-label"
1595
+ >${this._selectedCollectionLabel(collections)}</span
1596
+ >`
1597
+ : html`<span>${this.labels.collection}</span>`}
1598
+ <svg
1599
+ width="10"
1600
+ height="10"
1601
+ viewBox="0 0 10 10"
1602
+ fill="none"
1603
+ stroke="currentColor"
1604
+ stroke-width="1.4"
1605
+ stroke-linecap="round"
1606
+ stroke-linejoin="round"
1607
+ class="shrink-0 opacity-50 icon-fine"
1608
+ >
1609
+ <path d="M3 4l2 2 2-2" />
1610
+ </svg>
1611
+ </button>
1612
+ <div
1613
+ data-popover
1614
+ data-side="top"
1615
+ aria-hidden=${this._showCollection ? "false" : "true"}
1616
+ >
1617
+ ${collections.length > 0
1618
+ ? html`<header>
1619
+ <svg
1620
+ width="16"
1621
+ height="16"
1622
+ viewBox="0 0 24 24"
1623
+ fill="none"
1624
+ stroke="currentColor"
1625
+ stroke-width="2"
1626
+ stroke-linecap="round"
1627
+ stroke-linejoin="round"
1628
+ >
1629
+ <circle cx="11" cy="11" r="8" />
1630
+ <path d="m21 21-4.3-4.3" />
1631
+ </svg>
1632
+ <input
1633
+ type="text"
1634
+ role="combobox"
1635
+ placeholder=${this.labels.searchCollections}
1636
+ autocomplete="off"
1637
+ autocorrect="off"
1638
+ spellcheck="false"
1639
+ .value=${this._collectionSearch}
1640
+ @input=${(e: Event) => {
1641
+ this._collectionSearch = (
1642
+ e.target as HTMLInputElement
1643
+ ).value;
1644
+ }}
1645
+ />
1646
+ </header>`
1647
+ : nothing}
1648
+ <div
1649
+ role="listbox"
1650
+ aria-multiselectable="true"
1651
+ data-empty=${filtered.length === 0
1652
+ ? search
1653
+ ? this.labels.noCollections
1654
+ : this.labels.emptyCollections
1655
+ : nothing}
1656
+ >
1657
+ ${filtered.map(
1658
+ (col) => html`
1659
+ <div
1660
+ role="option"
1661
+ data-value=${col.id}
1662
+ aria-selected=${this._collectionIds.includes(col.id)
1663
+ ? "true"
1664
+ : nothing}
1665
+ @click=${() => this._toggleCollection(col.id)}
1666
+ >
1667
+ ${col.iconHtml
1668
+ ? html`<span
1669
+ class="inline-flex items-center justify-center w-4 h-4 shrink-0"
1670
+ >${unsafeHTML(col.iconHtml)}</span
1671
+ >`
1672
+ : nothing}
1673
+ ${col.title}
1674
+ </div>
1675
+ `,
1676
+ )}
1677
+ </div>
1678
+ <div
1679
+ class="compose-collection-add-action"
1680
+ @click=${() => {
1681
+ this._showCollection = false;
1682
+ this._collectionSearch = "";
1683
+ this._addCollectionPanelOpen = true;
1684
+ }}
1685
+ >
1686
+ <svg
1687
+ width="14"
1688
+ height="14"
1689
+ viewBox="0 0 16 16"
1690
+ fill="none"
1691
+ stroke="currentColor"
1692
+ stroke-width="1.5"
1693
+ stroke-linecap="round"
1694
+ stroke-linejoin="round"
1695
+ >
1696
+ <path d="M8 3v10M3 8h10" />
1697
+ </svg>
1698
+ ${this.labels.addCollection}
1699
+ </div>
1700
+ </div>
1701
+ </div>
1702
+ </div>
1703
+ `;
1704
+ }
1705
+
1706
+ // ── Add Collection panel ────────────────────────────────────────
1707
+
1708
+ private async _handleAddCollectionSubmit(e: Event) {
1709
+ const event = e as CustomEvent<CollectionSubmitDetail>;
1710
+ event.stopPropagation();
1711
+
1712
+ const detail = event.detail;
1713
+ if (!detail) return;
1714
+
1715
+ const formEl = this.querySelector("jant-collection-form") as
1716
+ | (HTMLElement & { loading: boolean })
1717
+ | null;
1718
+ if (formEl) formEl.loading = true;
1719
+
1720
+ try {
1721
+ const res = await fetch("/api/collections", {
1722
+ method: "POST",
1723
+ headers: { "Content-Type": "application/json" },
1724
+ body: JSON.stringify(detail.data),
1725
+ });
1726
+
1727
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1728
+
1729
+ const created = await res.json();
1730
+ const newCollection: ComposeCollection = {
1731
+ id: created.id,
1732
+ title: created.title,
1733
+ iconHtml: renderCollectionIcon(created.icon, { size: 16 }),
1734
+ };
1735
+
1736
+ this.collections = [...this.collections, newCollection];
1737
+ this._collectionIds = [...this._collectionIds, created.id];
1738
+ this._addCollectionPanelOpen = false;
1739
+ showToast(this.labels.collectionFormLabels.submitLabel);
1740
+ } catch {
1741
+ showToast("Failed to create collection. Try again.", "error");
1742
+ } finally {
1743
+ if (formEl) formEl.loading = false;
1744
+ }
1745
+ }
1746
+
1747
+ private _submitAddCollectionForm() {
1748
+ const form = this.querySelector<HTMLFormElement>(
1749
+ ".compose-add-collection-panel form",
1750
+ );
1751
+ if (form) form.requestSubmit();
1752
+ }
1753
+
1754
+ private _renderAddCollectionPanel() {
1755
+ if (!this._addCollectionPanelOpen) return nothing;
1756
+
1757
+ const initial = {
1758
+ title: "",
1759
+ slug: "",
1760
+ description: "",
1761
+ sortOrder: "newest",
1762
+ icon: "",
1763
+ };
1764
+
1765
+ return html`
1766
+ <div class="compose-add-collection-panel">
1767
+ <div class="compose-alt-header">
1768
+ <button
1769
+ type="button"
1770
+ class="compose-attached-cancel"
1771
+ @click=${() => {
1772
+ this._addCollectionPanelOpen = false;
1773
+ }}
1774
+ >
1775
+ ${this.labels.cancel}
1776
+ </button>
1777
+ <span class="compose-alt-title">${this.labels.addCollection}</span>
1778
+ <button
1779
+ type="button"
1780
+ class="compose-post-btn ml-auto"
1781
+ @click=${() => this._submitAddCollectionForm()}
1782
+ >
1783
+ ${this.labels.done}
1784
+ </button>
1785
+ </div>
1786
+ <div class="flex-1 overflow-y-auto">
1787
+ <jant-collection-form
1788
+ class="compose-add-collection-form"
1789
+ .labels=${this.labels.collectionFormLabels}
1790
+ .initial=${initial}
1791
+ action="/api/collections"
1792
+ cancel-href="javascript:void(0)"
1793
+ @jant:collection-submit=${(e: Event) =>
1794
+ this._handleAddCollectionSubmit(e)}
1795
+ ></jant-collection-form>
1796
+ </div>
1797
+ </div>
1798
+ `;
1799
+ }
1800
+
1801
+ private _renderAttachedPanel() {
1802
+ if (!this._attachedPanelOpen) return nothing;
1803
+
1804
+ return html`
1805
+ <div class="compose-attached-panel">
1806
+ <div class="compose-alt-header">
1807
+ <button
1808
+ type="button"
1809
+ class="compose-attached-cancel"
1810
+ @click=${() => this._cancelAttachedPanel()}
1811
+ >
1812
+ ${this.labels.cancel}
1813
+ </button>
1814
+ <span class="compose-alt-title">${this.labels.attachedText}</span>
1815
+ <button
1816
+ type="button"
1817
+ class="compose-post-btn ml-auto"
1818
+ @click=${() => this._doneAttachedPanel()}
1819
+ >
1820
+ ${this.labels.done}
1821
+ </button>
1822
+ </div>
1823
+ <div class="flex-1 p-4 overflow-hidden flex flex-col">
1824
+ <div class="compose-attached-tiptap compose-tiptap-body"></div>
1825
+ </div>
1826
+ </div>
1827
+ `;
1828
+ }
1829
+
1830
+ private _renderAltPanel() {
1831
+ if (!this._altPanelOpen) return nothing;
1832
+ const attachment = this._getAltAttachment();
1833
+ if (!attachment) return nothing;
1834
+
1835
+ const category = getMediaCategory(attachment.file.type);
1836
+
1837
+ return html`
1838
+ <div class="compose-alt-panel">
1839
+ <div class="compose-alt-header">
1840
+ <button
1841
+ type="button"
1842
+ class="compose-attached-panel-back"
1843
+ @click=${() => this._closeAltPanel()}
1844
+ >
1845
+ <svg
1846
+ class="icon-fine"
1847
+ width="16"
1848
+ height="16"
1849
+ viewBox="0 0 16 16"
1850
+ fill="none"
1851
+ stroke="currentColor"
1852
+ stroke-width="1.5"
1853
+ stroke-linecap="round"
1854
+ stroke-linejoin="round"
1855
+ >
1856
+ <path d="M11 3L6 8l5 5" />
1857
+ </svg>
1858
+ </button>
1859
+ <span class="compose-alt-title">${this.labels.addAltTitle}</span>
1860
+ </div>
1861
+ <div class="compose-alt-preview">
1862
+ ${category === "image"
1863
+ ? html`<img
1864
+ src=${attachment.previewUrl}
1865
+ alt=""
1866
+ class="compose-alt-preview-img"
1867
+ />`
1868
+ : category === "video"
1869
+ ? html`<video
1870
+ src=${attachment.previewUrl}
1871
+ class="compose-alt-preview-img"
1872
+ preload="metadata"
1873
+ muted
1874
+ ></video>`
1875
+ : html`<span class="text-sm text-muted-foreground"
1876
+ >${attachment.file.name}</span
1877
+ >`}
1878
+ </div>
1879
+ <div class="compose-alt-input-row">
1880
+ <input
1881
+ type="text"
1882
+ .value=${attachment.alt}
1883
+ @input=${(e: Event) => this._onAltInput(e)}
1884
+ class="compose-input compose-alt-input"
1885
+ placeholder=${this.labels.altPlaceholder}
1886
+ />
1887
+ </div>
1888
+ <div class="compose-alt-footer">
1889
+ <span class="text-xs text-muted-foreground"
1890
+ >${this.labels.altHint}</span
1891
+ >
1892
+ <button
1893
+ type="button"
1894
+ class="compose-post-btn"
1895
+ @click=${() => this._closeAltPanel()}
1896
+ >
1897
+ ${this.labels.done}
1898
+ </button>
1899
+ </div>
1900
+ </div>
1901
+ `;
1902
+ }
1903
+
1904
+ private _renderConfirmPanel() {
1905
+ if (!this._confirmPanelOpen) return nothing;
1906
+
1907
+ const isEdit = !!this._editPostId;
1908
+ const title = isEdit
1909
+ ? this.labels.confirmEditTitle
1910
+ : this.labels.confirmCloseTitle;
1911
+ const subtitle = isEdit
1912
+ ? this.labels.confirmEditSubtitle
1913
+ : this.labels.confirmCloseSubtitle;
1914
+ const saveLabel = isEdit
1915
+ ? this.labels.confirmEditPublish
1916
+ : this.labels.confirmCloseSave;
1917
+ const discardLabel = isEdit
1918
+ ? this.labels.confirmEditDiscard
1919
+ : this.labels.confirmCloseDiscard;
1920
+
1921
+ return html`
1922
+ <div class="compose-confirm-panel">
1923
+ <div class="compose-confirm-sheet">
1924
+ <div class="compose-confirm-header">
1925
+ <p class="compose-confirm-title">${title}</p>
1926
+ <p class="compose-confirm-subtitle">${subtitle}</p>
1927
+ </div>
1928
+ <button
1929
+ type="button"
1930
+ class="compose-confirm-action compose-confirm-save"
1931
+ @click=${() => this._handleConfirmSave()}
1932
+ >
1933
+ ${saveLabel}
1934
+ </button>
1935
+ <button
1936
+ type="button"
1937
+ class="compose-confirm-action compose-confirm-discard"
1938
+ @click=${() => this._handleConfirmDiscard()}
1939
+ >
1940
+ ${discardLabel}
1941
+ </button>
1942
+ <button
1943
+ type="button"
1944
+ class="compose-confirm-action compose-confirm-cancel"
1945
+ @click=${() => this.requestClose()}
1946
+ >
1947
+ ${this.labels.confirmCloseCancel}
1948
+ </button>
1949
+ </div>
1950
+ </div>
1951
+ `;
1952
+ }
1953
+
1954
+ private _getSubmitLabel(): string {
1955
+ if (this._editPostId) return this.labels.update;
1956
+ if (this._replyToId) return this.labels.reply;
1957
+ return this.labels.post;
1958
+ }
1959
+
1960
+ private _submitWithVisibility(visibility: ComposeVisibility) {
1961
+ this._visibility = visibility;
1962
+ this._showVisibilityMenu = false;
1963
+ // Wait for state to update before submitting
1964
+ this.updateComplete.then(() => this._submit("published"));
1965
+ }
1966
+
1967
+ private _renderPublishButton() {
1968
+ const spinner = html`<svg
1969
+ class="animate-spin size-4"
1970
+ xmlns="http://www.w3.org/2000/svg"
1971
+ viewBox="0 0 24 24"
1972
+ fill="none"
1973
+ stroke="currentColor"
1974
+ stroke-width="2"
1975
+ stroke-linecap="round"
1976
+ stroke-linejoin="round"
1977
+ role="status"
1978
+ >
1979
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
1980
+ </svg>`;
1981
+
1982
+ // In edit mode or reply mode, show a simple button (no visibility split)
1983
+ if (this._editPostId || this._replyToId) {
1984
+ return html`
1985
+ <button
1986
+ type="button"
1987
+ class="compose-post-btn"
1988
+ ?disabled=${this._loading}
1989
+ @click=${() => this._submit("published")}
1990
+ >
1991
+ ${this._loading ? spinner : nothing} ${this._getSubmitLabel()}
1992
+ </button>
1993
+ `;
1994
+ }
1995
+
1996
+ return html`
1997
+ <div class="compose-publish-group">
1998
+ ${this._showVisibilityMenu
1999
+ ? html`<div
2000
+ class="compose-dropdown-backdrop"
2001
+ @click=${() => {
2002
+ this._showVisibilityMenu = false;
2003
+ }}
2004
+ ></div>`
2005
+ : nothing}
2006
+ <button
2007
+ type="button"
2008
+ class="compose-publish-main"
2009
+ ?disabled=${this._loading}
2010
+ @click=${() => this._submit("published")}
2011
+ >
2012
+ ${this._loading ? spinner : nothing} ${this._getSubmitLabel()}
2013
+ </button>
2014
+ <button
2015
+ type="button"
2016
+ class="compose-publish-toggle"
2017
+ ?disabled=${this._loading}
2018
+ aria-haspopup="menu"
2019
+ aria-expanded=${this._showVisibilityMenu}
2020
+ @click=${() => {
2021
+ this._showVisibilityMenu = !this._showVisibilityMenu;
2022
+ }}
2023
+ >
2024
+ <svg
2025
+ width="14"
2026
+ height="14"
2027
+ viewBox="0 0 24 24"
2028
+ fill="none"
2029
+ stroke="currentColor"
2030
+ stroke-width="2.5"
2031
+ stroke-linecap="round"
2032
+ stroke-linejoin="round"
2033
+ >
2034
+ <path d="m6 9 6 6 6-6" />
2035
+ </svg>
2036
+ </button>
2037
+ ${this._showVisibilityMenu
2038
+ ? html`
2039
+ <div class="compose-dropdown" role="menu">
2040
+ <button
2041
+ type="button"
2042
+ class="compose-dropdown-item"
2043
+ role="menuitem"
2044
+ @click=${() => {
2045
+ this._featured = true;
2046
+ this._showVisibilityMenu = false;
2047
+ this.updateComplete.then(() => this._submit("published"));
2048
+ }}
2049
+ >
2050
+ <svg
2051
+ width="16"
2052
+ height="16"
2053
+ viewBox="0 0 24 24"
2054
+ fill="none"
2055
+ stroke="currentColor"
2056
+ stroke-width="2"
2057
+ stroke-linecap="round"
2058
+ stroke-linejoin="round"
2059
+ >
2060
+ <path
2061
+ 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"
2062
+ />
2063
+ </svg>
2064
+ ${this.labels.publishFeatured}
2065
+ </button>
2066
+ <button
2067
+ type="button"
2068
+ class="compose-dropdown-item"
2069
+ role="menuitem"
2070
+ @click=${() => this._submitWithVisibility("unlisted")}
2071
+ >
2072
+ <svg
2073
+ width="16"
2074
+ height="16"
2075
+ viewBox="0 0 24 24"
2076
+ fill="none"
2077
+ stroke="currentColor"
2078
+ stroke-width="2"
2079
+ stroke-linecap="round"
2080
+ stroke-linejoin="round"
2081
+ >
2082
+ <path d="M9 17H7A5 5 0 0 1 7 7h2" />
2083
+ <path d="M15 7h2a5 5 0 1 1 0 10h-2" />
2084
+ <line x1="8" x2="16" y1="12" y2="12" />
2085
+ </svg>
2086
+ ${this.labels.publishUnlisted}
2087
+ </button>
2088
+ <button
2089
+ type="button"
2090
+ class="compose-dropdown-item"
2091
+ role="menuitem"
2092
+ @click=${() => this._submitWithVisibility("private")}
2093
+ >
2094
+ <svg
2095
+ width="16"
2096
+ height="16"
2097
+ viewBox="0 0 24 24"
2098
+ fill="none"
2099
+ stroke="currentColor"
2100
+ stroke-width="2"
2101
+ stroke-linecap="round"
2102
+ stroke-linejoin="round"
2103
+ >
2104
+ <path
2105
+ 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"
2106
+ />
2107
+ <path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
2108
+ <path
2109
+ 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"
2110
+ />
2111
+ <path d="m2 2 20 20" />
2112
+ </svg>
2113
+ ${this.labels.publishPrivate}
2114
+ </button>
2115
+ </div>
2116
+ `
2117
+ : nothing}
2118
+ </div>
2119
+ `;
2120
+ }
2121
+
2122
+ render() {
2123
+ const isReply = !!(this._replyToId && this._replyToData);
2124
+ const editor = html`<jant-compose-editor
2125
+ .format=${this._format}
2126
+ .labels=${this.labels}
2127
+ .uploadMaxFileSize=${this.uploadMaxFileSize}
2128
+ ></jant-compose-editor>`;
2129
+
2130
+ return html`
2131
+ <div
2132
+ class=${classMap({
2133
+ "compose-dialog-inner": true,
2134
+ "compose-dialog-inner-page": this.pageMode,
2135
+ })}
2136
+ >
2137
+ ${this._renderHeader()}
2138
+ ${isReply
2139
+ ? html`
2140
+ <div class="compose-thread-layout">
2141
+ ${this._renderReplyContext()}
2142
+ <div class="compose-editor-row">
2143
+ <div class="compose-thread-dot"></div>
2144
+ ${editor}
2145
+ </div>
2146
+ </div>
2147
+ `
2148
+ : editor}
2149
+
2150
+ <div class="compose-action-row">
2151
+ ${this._renderCollectionSelector()} ${this._renderPublishButton()}
2152
+ </div>
2153
+ ${this._renderAttachedPanel()} ${this._renderAltPanel()}
2154
+ ${this._renderDraftsPanel()} ${this._renderConfirmPanel()}
2155
+ </div>
2156
+ ${this._renderAddCollectionPanel()}
2157
+ `;
2158
+ }
2159
+ }
2160
+
2161
+ customElements.define("jant-compose-dialog", JantComposeDialog);