@jant/core 0.3.36 → 0.3.38

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 (271) 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/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -10,21 +10,68 @@
10
10
  import { LitElement, html, nothing } from "lit";
11
11
  import { classMap } from "lit/directives/class-map.js";
12
12
  import { unsafeHTML } from "lit/directives/unsafe-html.js";
13
+ import type { Editor, JSONContent } from "@tiptap/core";
13
14
  import type {
14
15
  ComposeFormat,
16
+ ComposeVisibility,
15
17
  ComposeLabels,
16
18
  ComposeCollection,
17
19
  ComposeSubmitDetail,
18
20
  ComposeAttachment,
21
+ DraftItem,
22
+ LocalDraft,
19
23
  } from "./compose-types.js";
24
+ import type { CollectionSubmitDetail } from "./collection-types.js";
25
+ import { showToast } from "../toast.js";
20
26
  import type { JantComposeEditor } from "./jant-compose-editor.js";
21
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
+ }
22
66
 
23
67
  export class JantComposeDialog extends LitElement {
24
68
  static properties = {
25
69
  collections: { type: Array },
26
70
  labels: { type: Object },
27
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" },
28
75
  _format: { state: true },
29
76
  _status: { state: true },
30
77
  _loading: { state: true },
@@ -35,21 +82,66 @@ export class JantComposeDialog extends LitElement {
35
82
  _altPanelOpen: { state: true },
36
83
  _altPanelIndex: { state: true },
37
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 },
38
101
  };
39
102
 
40
103
  declare collections: ComposeCollection[];
41
104
  declare labels: ComposeLabels;
42
105
  declare uploadMaxFileSize: number;
106
+ declare pageMode: boolean;
107
+ declare closeHref: string;
108
+ declare autoRestoreDraft: boolean;
43
109
  declare _format: ComposeFormat;
44
110
  declare _status: "published" | "draft";
45
111
  declare _loading: boolean;
46
- declare _collectionIds: number[];
112
+ declare _collectionIds: string[];
47
113
  declare _showCollection: boolean;
48
114
  declare _showMoreMenu: boolean;
49
115
  declare _collectionSearch: string;
50
116
  declare _altPanelOpen: boolean;
51
117
  declare _altPanelIndex: number;
52
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;
53
145
 
54
146
  createRenderRoot() {
55
147
  this.innerHTML = "";
@@ -61,6 +153,9 @@ export class JantComposeDialog extends LitElement {
61
153
  this.collections = [];
62
154
  this.labels = {} as ComposeLabels;
63
155
  this.uploadMaxFileSize = 500;
156
+ this.pageMode = false;
157
+ this.closeHref = "/";
158
+ this.autoRestoreDraft = false;
64
159
  this._format = "note";
65
160
  this._status = "published";
66
161
  this._loading = false;
@@ -71,12 +166,41 @@ export class JantComposeDialog extends LitElement {
71
166
  this._altPanelOpen = false;
72
167
  this._altPanelIndex = 0;
73
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;
74
185
  }
75
186
 
76
187
  private get _editor(): JantComposeEditor | null {
77
188
  return this.querySelector("jant-compose-editor");
78
189
  }
79
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
+
80
204
  reset() {
81
205
  this._format = "note";
82
206
  this._status = "published";
@@ -88,7 +212,164 @@ export class JantComposeDialog extends LitElement {
88
212
  this._altPanelOpen = false;
89
213
  this._altPanelIndex = 0;
90
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();
91
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
+ }
92
373
  }
93
374
 
94
375
  set loading(v: boolean) {
@@ -96,7 +377,192 @@ export class JantComposeDialog extends LitElement {
96
377
  }
97
378
 
98
379
  private _closeDialog() {
99
- this.closest("dialog")?.close();
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
+ }
100
566
  }
101
567
 
102
568
  private _buildSubmitDetail(
@@ -121,6 +587,15 @@ export class JantComposeDialog extends LitElement {
121
587
  }
122
588
  }
123
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
+
124
599
  return {
125
600
  format: this._format,
126
601
  title: editorData.title,
@@ -129,55 +604,59 @@ export class JantComposeDialog extends LitElement {
129
604
  quoteText: editorData.quoteText,
130
605
  quoteAuthor: editorData.quoteAuthor,
131
606
  status,
607
+ visibility: this._visibility,
608
+ featured: this._featured || undefined,
132
609
  rating: editorData.rating,
133
610
  collectionIds: [...this._collectionIds],
134
611
  mediaIds,
135
612
  mediaAlts,
136
- attachedText: editorData.attachedText,
613
+ attachedTexts: editorData.attachedTexts,
614
+ attachmentOrder: editorData.attachmentOrder ?? [],
615
+ mediaClientMap,
616
+ editPostId: this._editPostId ?? this._draftSourceId ?? undefined,
617
+ replyToId: this._replyToId ?? undefined,
137
618
  };
138
619
  }
139
620
 
140
- private _submit(status: "published" | "draft") {
141
- if (this._loading) return;
621
+ private _dispatchSubmit(status: "published" | "draft"): boolean {
622
+ if (this._loading) return false;
142
623
  const editor = this._editor;
143
- if (!editor) return;
624
+ if (!editor) return false;
625
+
626
+ const detail = this._buildSubmitDetail(status);
627
+ if (!detail) return false;
144
628
 
145
629
  const attachments = editor._attachments ?? [];
146
- const hasPending = attachments.some(
147
- (a) => a.status === "pending" || a.status === "uploading",
630
+ const pendingAttachments = attachments.filter(
631
+ (a) =>
632
+ a.status === "pending" ||
633
+ a.status === "processing" ||
634
+ a.status === "uploading",
148
635
  );
149
636
 
150
- const detail = this._buildSubmitDetail(status);
151
- if (!detail) return;
637
+ this.dispatchEvent(
638
+ new CustomEvent("jant:compose-submit-deferred", {
639
+ bubbles: true,
640
+ detail: { ...detail, pendingAttachments },
641
+ }),
642
+ );
643
+ return true;
644
+ }
152
645
 
153
- if (hasPending) {
154
- // Deferred submit: close dialog, let bridge finish uploads and post
155
- this.dispatchEvent(
156
- new CustomEvent("jant:compose-submit-deferred", {
157
- bubbles: true,
158
- detail: {
159
- ...detail,
160
- pendingAttachments: attachments.filter(
161
- (a) => a.status === "pending" || a.status === "uploading",
162
- ),
163
- },
164
- }),
165
- );
166
- this._closeDialog();
167
- // Prevent browser from restoring focus to the trigger button
168
- (document.activeElement as HTMLElement)?.blur();
169
- this.reset();
170
- } else {
171
- this.dispatchEvent(
172
- new CustomEvent("jant:compose-submit", {
173
- bubbles: true,
174
- detail,
175
- }),
176
- );
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;
177
652
  }
653
+ this._closeDialog();
654
+ // Prevent browser from restoring focus to the trigger button
655
+ (document.activeElement as HTMLElement)?.blur();
656
+ this.reset();
178
657
  }
179
658
 
180
- private _toggleCollection(id: number) {
659
+ private _toggleCollection(id: string) {
181
660
  if (this._collectionIds.includes(id)) {
182
661
  this._collectionIds = this._collectionIds.filter((cid) => cid !== id);
183
662
  } else {
@@ -185,6 +664,16 @@ export class JantComposeDialog extends LitElement {
185
664
  }
186
665
  }
187
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
+
188
677
  connectedCallback() {
189
678
  super.connectedCallback();
190
679
  this.addEventListener("keydown", this._handleKeydown);
@@ -194,11 +683,28 @@ export class JantComposeDialog extends LitElement {
194
683
  "jant:attached-panel-open",
195
684
  this._handleAttachedPanelOpen,
196
685
  );
686
+ this.addEventListener(
687
+ "jant:compose-content-changed",
688
+ this._onContentChanged,
689
+ );
197
690
  // Listen on document — fullscreen element lives on document.body, outside the dialog
198
691
  document.addEventListener(
199
692
  "jant:fullscreen-close",
200
693
  this._handleFullscreenClose as EventListener,
201
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
+ }
202
708
  }
203
709
 
204
710
  disconnectedCallback() {
@@ -210,15 +716,56 @@ export class JantComposeDialog extends LitElement {
210
716
  "jant:attached-panel-open",
211
717
  this._handleAttachedPanelOpen,
212
718
  );
719
+ this.removeEventListener(
720
+ "jant:compose-content-changed",
721
+ this._onContentChanged,
722
+ );
213
723
  document.removeEventListener(
214
724
  "jant:fullscreen-close",
215
725
  this._handleFullscreenClose as EventListener,
216
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
+ }
217
735
  }
218
736
 
737
+ private _handleDialogCancel = (e: Event) => {
738
+ e.preventDefault();
739
+ this.requestClose();
740
+ };
741
+
219
742
  private _handleKeydown = (e: Event) => {
220
743
  const ke = e as globalThis.KeyboardEvent;
221
- if ((ke.metaKey || ke.ctrlKey) && ke.key === "Enter") {
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") {
222
769
  e.preventDefault();
223
770
  this._submit("published");
224
771
  }
@@ -250,35 +797,610 @@ export class JantComposeDialog extends LitElement {
250
797
  this._altPanelOpen = false;
251
798
  }
252
799
 
253
- private _handleFullscreenClose = (
254
- e: CustomEvent<{ json: unknown; title: string }>,
255
- ) => {
256
- const editor = this._editor;
257
- if (editor) {
258
- editor.setEditorState(
259
- e.detail.json as import("@tiptap/core").JSONContent,
260
- e.detail.title,
261
- );
262
- }
263
- };
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 ──────────────────────────────────────
264
1367
 
265
- private _handleAttachedPanelOpen = () => {
266
- this._attachedPanelOpen = true;
267
- this.updateComplete.then(() => {
268
- this.querySelector<HTMLTextAreaElement>(
269
- ".compose-attached-textarea",
270
- )?.focus();
271
- });
272
- };
1368
+ private _renderReplyContext() {
1369
+ if (!this._replyToId || !this._replyToData) return nothing;
273
1370
 
274
- private _onAttachedTextInput(e: Event) {
275
- const value = (e.target as HTMLTextAreaElement).value;
276
- this._editor?.updateAttachedText(value);
277
- }
1371
+ const { contentHtml, dateText } = this._replyToData;
1372
+ const isExpanded = this._replyExpanded;
278
1373
 
279
- private _closeAttachedPanel() {
280
- this._attachedPanelOpen = false;
281
- this._editor?.closeAttachedPanel();
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
+ `;
282
1404
  }
283
1405
 
284
1406
  // ── Render helpers ────────────────────────────────────────────────
@@ -296,66 +1418,73 @@ export class JantComposeDialog extends LitElement {
296
1418
  <button
297
1419
  type="button"
298
1420
  class="compose-dialog-cancel"
299
- @click=${() => this._closeDialog()}
1421
+ @click=${() => this.requestClose()}
300
1422
  >
301
1423
  ${this.labels.cancel}
302
1424
  </button>
303
1425
 
304
1426
  <div class="compose-dialog-header-center">
305
- <div class="compose-segmented">
306
- <div
307
- class=${classMap({
308
- "compose-format-pill": true,
309
- "compose-format-pill-link": this._format === "link",
310
- "compose-format-pill-quote": this._format === "quote",
311
- })}
312
- ></div>
313
- ${formats.map(
314
- (f) => html`
315
- <button
316
- type="button"
317
- class=${classMap({
318
- "compose-segmented-item": true,
319
- "compose-segmented-item-active": this._format === f,
320
- })}
321
- @click=${() => {
322
- this._format = f;
323
- globalThis.requestAnimationFrame(() =>
324
- this._editor?.focusInput(),
325
- );
326
- }}
327
- >
328
- ${formatLabels[f]}
329
- </button>
330
- `,
331
- )}
332
- </div>
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
+ `}
333
1461
  </div>
334
1462
 
335
1463
  <div class="flex items-center gap-0.5 shrink-0">
336
- <button
337
- type="button"
338
- class="compose-dialog-header-btn"
339
- title=${this.labels.saveDraft}
340
- ?disabled=${this._loading}
341
- @click=${() => this._submit("draft")}
342
- >
343
- <svg
344
- class="icon-fine"
345
- width="18"
346
- height="18"
347
- viewBox="0 0 18 18"
348
- fill="none"
349
- stroke="currentColor"
350
- stroke-width="1.3"
351
- stroke-linecap="round"
352
- stroke-linejoin="round"
353
- >
354
- <path d="M14 2.5L15.5 4 7 12.5l-3 .5.5-3L14 2.5z" />
355
- <path d="M4 15h10" />
356
- </svg>
357
- </button>
358
-
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>`}
359
1488
  ${this._renderMoreMenu()}
360
1489
  </div>
361
1490
  </header>
@@ -404,8 +1533,8 @@ export class JantComposeDialog extends LitElement {
404
1533
  type="button"
405
1534
  class="compose-dropdown-item compose-dropdown-item-danger"
406
1535
  @click=${() => {
407
- this._closeDialog();
408
1536
  this._showMoreMenu = false;
1537
+ this._discardAndClose();
409
1538
  }}
410
1539
  >
411
1540
  ${this.labels.discard}
@@ -418,14 +1547,11 @@ export class JantComposeDialog extends LitElement {
418
1547
  }
419
1548
 
420
1549
  private _renderCollectionSelector() {
421
- if (!this.collections || this.collections.length === 0) {
422
- return html`<div class="flex-1"></div>`;
423
- }
424
-
1550
+ const collections = this.collections ?? [];
425
1551
  const search = this._collectionSearch.toLowerCase();
426
1552
  const filtered = search
427
- ? this.collections.filter((c) => c.title.toLowerCase().includes(search))
428
- : this.collections;
1553
+ ? collections.filter((c) => c.title.toLowerCase().includes(search))
1554
+ : collections;
429
1555
  const selectedCount = this._collectionIds.length;
430
1556
 
431
1557
  return html`
@@ -465,8 +1591,8 @@ export class JantComposeDialog extends LitElement {
465
1591
  <path d="M6 5V4a1 1 0 011-1h4a1 1 0 011 1v1" />
466
1592
  </svg>
467
1593
  ${selectedCount > 0
468
- ? html`<span class="badge compose-collection-badge"
469
- >${selectedCount}</span
1594
+ ? html`<span class="compose-collection-label"
1595
+ >${this._selectedCollectionLabel(collections)}</span
470
1596
  >`
471
1597
  : html`<span>${this.labels.collection}</span>`}
472
1598
  <svg
@@ -488,37 +1614,45 @@ export class JantComposeDialog extends LitElement {
488
1614
  data-side="top"
489
1615
  aria-hidden=${this._showCollection ? "false" : "true"}
490
1616
  >
491
- <header>
492
- <svg
493
- width="16"
494
- height="16"
495
- viewBox="0 0 24 24"
496
- fill="none"
497
- stroke="currentColor"
498
- stroke-width="2"
499
- stroke-linecap="round"
500
- stroke-linejoin="round"
501
- >
502
- <circle cx="11" cy="11" r="8" />
503
- <path d="m21 21-4.3-4.3" />
504
- </svg>
505
- <input
506
- type="text"
507
- role="combobox"
508
- placeholder=${this.labels.searchCollections}
509
- autocomplete="off"
510
- autocorrect="off"
511
- spellcheck="false"
512
- .value=${this._collectionSearch}
513
- @input=${(e: Event) => {
514
- this._collectionSearch = (e.target as HTMLInputElement).value;
515
- }}
516
- />
517
- </header>
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}
518
1648
  <div
519
1649
  role="listbox"
520
1650
  aria-multiselectable="true"
521
- data-empty=${this.labels.noCollections}
1651
+ data-empty=${filtered.length === 0
1652
+ ? search
1653
+ ? this.labels.noCollections
1654
+ : this.labels.emptyCollections
1655
+ : nothing}
522
1656
  >
523
1657
  ${filtered.map(
524
1658
  (col) => html`
@@ -541,67 +1675,154 @@ export class JantComposeDialog extends LitElement {
541
1675
  `,
542
1676
  )}
543
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>
544
1700
  </div>
545
1701
  </div>
546
1702
  </div>
547
1703
  `;
548
1704
  }
549
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
+
550
1801
  private _renderAttachedPanel() {
551
1802
  if (!this._attachedPanelOpen) return nothing;
552
- const editor = this._editor;
553
- const attachedText = editor?._attachedText ?? "";
554
1803
 
555
1804
  return html`
556
1805
  <div class="compose-attached-panel">
557
1806
  <div class="compose-alt-header">
558
1807
  <button
559
1808
  type="button"
560
- class="compose-attached-panel-back"
561
- @click=${() => this._closeAttachedPanel()}
1809
+ class="compose-attached-cancel"
1810
+ @click=${() => this._cancelAttachedPanel()}
562
1811
  >
563
- <svg
564
- class="icon-fine"
565
- width="16"
566
- height="16"
567
- viewBox="0 0 16 16"
568
- fill="none"
569
- stroke="currentColor"
570
- stroke-width="1.5"
571
- stroke-linecap="round"
572
- stroke-linejoin="round"
573
- >
574
- <path d="M11 3L6 8l5 5" />
575
- </svg>
1812
+ ${this.labels.cancel}
576
1813
  </button>
577
1814
  <span class="compose-alt-title">${this.labels.attachedText}</span>
578
- ${attachedText.length > 0
579
- ? html`<span
580
- class="compose-attached-charcount text-xs text-muted-foreground tracking-wide"
581
- >${attachedText.length.toLocaleString()} chars</span
582
- >`
583
- : nothing}
584
- </div>
585
- <div class="flex-1 p-4 overflow-hidden flex flex-col">
586
- <textarea
587
- .value=${attachedText}
588
- @input=${(e: Event) => this._onAttachedTextInput(e)}
589
- class="compose-input compose-attached-textarea"
590
- placeholder=${this.labels.attachedTextPlaceholder}
591
- ></textarea>
592
- </div>
593
- <div class="compose-alt-footer">
594
- <span class="text-xs text-muted-foreground"
595
- >${this.labels.attachedTextHint}</span
596
- >
597
1815
  <button
598
1816
  type="button"
599
- class="compose-post-btn"
600
- @click=${() => this._closeAttachedPanel()}
1817
+ class="compose-post-btn ml-auto"
1818
+ @click=${() => this._doneAttachedPanel()}
601
1819
  >
602
1820
  ${this.labels.done}
603
1821
  </button>
604
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>
605
1826
  </div>
606
1827
  `;
607
1828
  }
@@ -680,44 +1901,259 @@ export class JantComposeDialog extends LitElement {
680
1901
  `;
681
1902
  }
682
1903
 
683
- render() {
684
- return html`
685
- <div class="compose-dialog-inner">
686
- ${this._renderHeader()}
687
- <jant-compose-editor
688
- .format=${this._format}
689
- .labels=${this.labels}
690
- .uploadMaxFileSize=${this.uploadMaxFileSize}
691
- ></jant-compose-editor>
1904
+ private _renderConfirmPanel() {
1905
+ if (!this._confirmPanelOpen) return nothing;
692
1906
 
693
- <div class="compose-action-row">
694
- ${this._renderCollectionSelector()}
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>
695
1928
  <button
696
1929
  type="button"
697
- class="compose-post-btn"
698
- ?disabled=${this._loading}
699
- @click=${() => this._submit("published")}
1930
+ class="compose-confirm-action compose-confirm-save"
1931
+ @click=${() => this._handleConfirmSave()}
700
1932
  >
701
- ${this._loading
702
- ? html`<svg
703
- class="animate-spin size-4"
704
- xmlns="http://www.w3.org/2000/svg"
705
- viewBox="0 0 24 24"
706
- fill="none"
707
- stroke="currentColor"
708
- stroke-width="2"
709
- stroke-linecap="round"
710
- stroke-linejoin="round"
711
- role="status"
712
- >
713
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
714
- </svg>`
715
- : nothing}
716
- ${this.labels.post}
1933
+ ${saveLabel}
717
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()}
718
2152
  </div>
719
2153
  ${this._renderAttachedPanel()} ${this._renderAltPanel()}
2154
+ ${this._renderDraftsPanel()} ${this._renderConfirmPanel()}
720
2155
  </div>
2156
+ ${this._renderAddCollectionPanel()}
721
2157
  `;
722
2158
  }
723
2159
  }