@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,11 +10,14 @@
10
10
 
11
11
  import { LitElement, html, nothing } from "lit";
12
12
  import { classMap } from "lit/directives/class-map.js";
13
+ import { unsafeSVG } from "lit/directives/unsafe-svg.js";
13
14
  import type { Editor, JSONContent } from "@tiptap/core";
15
+ import Sortable from "sortablejs";
14
16
  import type {
15
17
  ComposeFormat,
16
18
  ComposeLabels,
17
19
  ComposeAttachment,
20
+ AttachedTextItem,
18
21
  } from "./compose-types.js";
19
22
  import {
20
23
  UPLOAD_ACCEPT,
@@ -38,9 +41,9 @@ export class JantComposeEditor extends LitElement {
38
41
  _rating: { state: true },
39
42
  _showTitle: { state: true },
40
43
  _showRating: { state: true },
41
- _attachedText: { state: true },
42
- _showAttachedText: { state: true },
44
+ _attachedTexts: { state: true },
43
45
  _attachments: { state: true },
46
+ _attachmentOrder: { state: true },
44
47
  _showAltPanel: { state: true },
45
48
  _altPanelIndex: { state: true },
46
49
  _showEmojiPicker: { state: true },
@@ -57,9 +60,9 @@ export class JantComposeEditor extends LitElement {
57
60
  declare _rating: number;
58
61
  declare _showTitle: boolean;
59
62
  declare _showRating: boolean;
60
- declare _attachedText: string;
61
- declare _showAttachedText: boolean;
63
+ declare _attachedTexts: AttachedTextItem[];
62
64
  declare _attachments: ComposeAttachment[];
65
+ declare _attachmentOrder: string[];
63
66
  declare _showAltPanel: boolean;
64
67
  declare _altPanelIndex: number;
65
68
  declare _showEmojiPicker: boolean;
@@ -71,6 +74,10 @@ export class JantComposeEditor extends LitElement {
71
74
  private _emojiPickerEl: HTMLElement | null = null;
72
75
  private _emojiContainer: HTMLElement | null = null;
73
76
  private _onDocClickBound = this._onDocumentClick.bind(this);
77
+ private _scrollBufferApplied = false;
78
+ private _suppressAttachedTextOpenUntil = 0;
79
+ #sortable: { destroy(): void } | null = null;
80
+ #revertNextSibling: globalThis.Node | null = null;
74
81
 
75
82
  createRenderRoot() {
76
83
  return this;
@@ -89,9 +96,9 @@ export class JantComposeEditor extends LitElement {
89
96
  this._rating = 0;
90
97
  this._showTitle = false;
91
98
  this._showRating = false;
92
- this._attachedText = "";
93
- this._showAttachedText = false;
99
+ this._attachedTexts = [];
94
100
  this._attachments = [];
101
+ this._attachmentOrder = [];
95
102
  this._showAltPanel = false;
96
103
  this._altPanelIndex = 0;
97
104
  this._showEmojiPicker = false;
@@ -106,6 +113,8 @@ export class JantComposeEditor extends LitElement {
106
113
  super.disconnectedCallback();
107
114
  this._editor?.destroy();
108
115
  this._editor = null;
116
+ this.#sortable?.destroy();
117
+ this.#sortable = null;
109
118
  document.removeEventListener("jant:slash-image", this._onSlashImage);
110
119
  document.removeEventListener("click", this._onDocClickBound, true);
111
120
  this._emojiContainer?.remove();
@@ -191,12 +200,25 @@ export class JantComposeEditor extends LitElement {
191
200
  }
192
201
  }
193
202
 
203
+ private _isEmptyDoc(json: JSONContent): boolean {
204
+ if (!json.content || json.content.length === 0) return true;
205
+ return json.content.every(
206
+ (node) =>
207
+ node.type === "paragraph" &&
208
+ (!node.content || node.content.length === 0),
209
+ );
210
+ }
211
+
194
212
  getData() {
195
- const body = this._bodyJson ? JSON.stringify(this._bodyJson) : "";
213
+ const body =
214
+ this._bodyJson && !this._isEmptyDoc(this._bodyJson)
215
+ ? JSON.stringify(this._bodyJson)
216
+ : "";
196
217
  const shared = {
197
218
  rating: this._rating,
198
- attachedText: this._attachedText,
219
+ attachedTexts: this._attachedTexts,
199
220
  attachments: this._attachments,
221
+ attachmentOrder: this._attachmentOrder,
200
222
  };
201
223
 
202
224
  switch (this.format) {
@@ -240,13 +262,13 @@ export class JantComposeEditor extends LitElement {
240
262
  this._rating = 0;
241
263
  this._showTitle = false;
242
264
  this._showRating = false;
243
- this._attachedText = "";
244
- this._showAttachedText = false;
265
+ this._attachedTexts = [];
245
266
  // Revoke preview URLs before clearing
246
267
  for (const a of this._attachments) {
247
268
  URL.revokeObjectURL(a.previewUrl);
248
269
  }
249
270
  this._attachments = [];
271
+ this._attachmentOrder = [];
250
272
  this._showAltPanel = false;
251
273
  this._altPanelIndex = 0;
252
274
  this.closeEmojiPicker();
@@ -263,6 +285,20 @@ export class JantComposeEditor extends LitElement {
263
285
  );
264
286
  }
265
287
 
288
+ updateAttachmentPreview(clientId: string, file: File) {
289
+ this._attachments = this._attachments.map((a) => {
290
+ if (a.clientId !== clientId) return a;
291
+ URL.revokeObjectURL(a.previewUrl);
292
+ return { ...a, file, previewUrl: URL.createObjectURL(file) };
293
+ });
294
+ }
295
+
296
+ updateAttachmentProgress(clientId: string, progress: number) {
297
+ this._attachments = this._attachments.map((a) =>
298
+ a.clientId === clientId ? { ...a, progress } : a,
299
+ );
300
+ }
301
+
266
302
  focusInput() {
267
303
  if (this.format === "link") {
268
304
  this.querySelector<HTMLElement>('.compose-input[type="url"]')?.focus();
@@ -286,11 +322,38 @@ export class JantComposeEditor extends LitElement {
286
322
  content: this._bodyJson,
287
323
  onUpdate: (json) => {
288
324
  this._bodyJson = json;
325
+ this._ensureScrollBuffer();
289
326
  },
290
327
  onFocus: () => {
291
328
  this._lastFocusedField = null;
292
329
  },
293
330
  });
331
+
332
+ // Lock editor min-height once so new lines fill existing space
333
+ // instead of growing the dialog line-by-line.
334
+ this._scrollBufferApplied = false;
335
+ const dom = this._editor.view.dom as HTMLElement;
336
+ const last = dom.lastElementChild as HTMLElement | null;
337
+ const contentH = last ? last.offsetTop + last.offsetHeight : 0;
338
+ const buffer = this.format !== "note" ? 60 : 120;
339
+ dom.style.minHeight = `${contentH + buffer}px`;
340
+ }
341
+
342
+ /**
343
+ * One-time: adds bottom padding for scroll buffer once the
344
+ * compose-body starts scrolling. Since the dialog is already at
345
+ * max-height by that point, the extra padding doesn't grow it.
346
+ */
347
+ private _ensureScrollBuffer() {
348
+ if (this._scrollBufferApplied) return;
349
+ const dom = this._editor?.view?.dom as HTMLElement | undefined;
350
+ if (!dom) return;
351
+ const body = this.querySelector(".compose-body") as HTMLElement | null;
352
+ if (!body) return;
353
+ if (body.scrollHeight > body.clientHeight + 20) {
354
+ dom.style.paddingBottom = "80px";
355
+ this._scrollBufferApplied = true;
356
+ }
294
357
  }
295
358
 
296
359
  private _destroyEditor() {
@@ -298,6 +361,20 @@ export class JantComposeEditor extends LitElement {
298
361
  this._editor = null;
299
362
  }
300
363
 
364
+ /** Content-relevant properties that trigger a change event for draft auto-save */
365
+ private static _CONTENT_PROPS = new Set([
366
+ "_title",
367
+ "_bodyJson",
368
+ "_url",
369
+ "_quoteText",
370
+ "_quoteAuthor",
371
+ "_rating",
372
+ "_showTitle",
373
+ "_showRating",
374
+ "_attachedTexts",
375
+ "_attachmentOrder",
376
+ ]);
377
+
301
378
  protected updated(changed: Map<string, unknown>) {
302
379
  super.updated(changed);
303
380
 
@@ -312,6 +389,29 @@ export class JantComposeEditor extends LitElement {
312
389
  // Schedule init after Lit re-renders the new template
313
390
  this.updateComplete.then(() => this._initEditor());
314
391
  }
392
+
393
+ if (
394
+ changed.has("_attachmentOrder") ||
395
+ changed.has("_attachments") ||
396
+ changed.has("_attachedTexts")
397
+ ) {
398
+ if (this._attachmentOrder.length > 1) {
399
+ this.#initSortable();
400
+ } else {
401
+ this.#sortable?.destroy();
402
+ this.#sortable = null;
403
+ }
404
+ }
405
+
406
+ // Notify parent dialog of content changes for draft auto-save
407
+ for (const key of changed.keys()) {
408
+ if (JantComposeEditor._CONTENT_PROPS.has(key as string)) {
409
+ this.dispatchEvent(
410
+ new Event("jant:compose-content-changed", { bubbles: true }),
411
+ );
412
+ break;
413
+ }
414
+ }
315
415
  }
316
416
 
317
417
  /** Returns Tiptap editor content and title for fullscreen handoff */
@@ -323,6 +423,124 @@ export class JantComposeEditor extends LitElement {
323
423
  };
324
424
  }
325
425
 
426
+ /** Pre-fill all fields for edit mode or draft restore */
427
+ populate(data: {
428
+ format: string;
429
+ title?: string;
430
+ bodyJson?: string;
431
+ url?: string;
432
+ quoteText?: string;
433
+ quoteAuthor?: string;
434
+ rating?: number;
435
+ showTitle?: boolean;
436
+ showRating?: boolean;
437
+ media?: Array<{
438
+ id: string;
439
+ previewUrl: string;
440
+ alt?: string;
441
+ mimeType: string;
442
+ originalName?: string;
443
+ summary?: string;
444
+ chars?: number;
445
+ }>;
446
+ textAttachments?: Array<{
447
+ clientId?: string;
448
+ bodyJson: string;
449
+ bodyHtml?: string;
450
+ summary: string;
451
+ mediaId?: string;
452
+ }>;
453
+ attachmentOrder?: string[];
454
+ }) {
455
+ if (data.title) this._title = data.title;
456
+ if (data.url) this._url = data.url;
457
+ if (data.quoteText) this._quoteText = data.quoteText;
458
+ if (data.quoteAuthor) this._quoteAuthor = data.quoteAuthor;
459
+ if (data.rating && data.rating > 0) {
460
+ this._rating = data.rating;
461
+ this._showRating = true;
462
+ }
463
+ if (data.showTitle !== undefined) this._showTitle = data.showTitle;
464
+ else if (data.title && data.format === "note") this._showTitle = true;
465
+ if (data.showRating !== undefined) this._showRating = data.showRating;
466
+
467
+ // Parse body JSON and set editor content
468
+ if (data.bodyJson) {
469
+ try {
470
+ const parsed = JSON.parse(data.bodyJson) as JSONContent;
471
+ this._bodyJson = parsed;
472
+ if (this._editor) {
473
+ this._editor.commands.setContent(parsed);
474
+ }
475
+ } catch {
476
+ // Body is not valid JSON — ignore
477
+ }
478
+ }
479
+
480
+ // Convert media attachments to ComposeAttachment[] with status "done"
481
+ if (data.media?.length) {
482
+ const attachments = data.media.map((m) => ({
483
+ clientId: crypto.randomUUID(),
484
+ file: new File([], m.originalName ?? "existing", { type: m.mimeType }),
485
+ previewUrl: m.previewUrl,
486
+ status: "done" as const,
487
+ progress: null,
488
+ mediaId: m.id,
489
+ alt: m.alt ?? "",
490
+ error: null,
491
+ summary: m.summary ?? null,
492
+ chars: m.chars ?? null,
493
+ }));
494
+ this._attachments = attachments;
495
+ this._attachmentOrder = attachments.map((a) => a.clientId);
496
+ }
497
+
498
+ // Restore attached texts from server data
499
+ if (data.textAttachments?.length) {
500
+ const texts: AttachedTextItem[] = data.textAttachments.map((t) => {
501
+ let parsed: JSONContent | null = null;
502
+ try {
503
+ parsed = JSON.parse(t.bodyJson) as JSONContent;
504
+ } catch {
505
+ // Invalid JSON — leave as null
506
+ }
507
+ return {
508
+ clientId: t.clientId ?? crypto.randomUUID(),
509
+ bodyJson: parsed,
510
+ bodyHtml: t.bodyHtml ?? "",
511
+ summary: t.summary,
512
+ mediaId: t.mediaId,
513
+ };
514
+ });
515
+ this._attachedTexts = texts;
516
+ this._attachmentOrder = [
517
+ ...this._attachmentOrder,
518
+ ...texts.map((t) => t.clientId),
519
+ ];
520
+ }
521
+
522
+ if (data.attachmentOrder?.length) {
523
+ const orderedClientIds = data.attachmentOrder
524
+ .map((attachmentId) => {
525
+ const mediaClientId = this._attachments.find(
526
+ (item) =>
527
+ item.mediaId === attachmentId || item.clientId === attachmentId,
528
+ )?.clientId;
529
+ if (mediaClientId) return mediaClientId;
530
+ return this._attachedTexts.find(
531
+ (item) =>
532
+ item.mediaId === attachmentId || item.clientId === attachmentId,
533
+ )?.clientId;
534
+ })
535
+ .filter((clientId): clientId is string => clientId !== undefined);
536
+
537
+ const remainingClientIds = this._attachmentOrder.filter(
538
+ (clientId) => !orderedClientIds.includes(clientId),
539
+ );
540
+ this._attachmentOrder = [...orderedClientIds, ...remainingClientIds];
541
+ }
542
+ }
543
+
326
544
  /** Updates editor content and title from fullscreen close */
327
545
  setEditorState(json: JSONContent | null, title: string) {
328
546
  this._bodyJson = json;
@@ -336,28 +554,226 @@ export class JantComposeEditor extends LitElement {
336
554
  }
337
555
  }
338
556
 
557
+ private static SUMMARY_LENGTH = 100;
558
+
559
+ private _computeSummary(text: string): string {
560
+ const plain = text.replace(/\s+/g, " ").trim();
561
+ if (plain.length <= JantComposeEditor.SUMMARY_LENGTH) return plain;
562
+ return plain.slice(0, JantComposeEditor.SUMMARY_LENGTH) + "…";
563
+ }
564
+
339
565
  private _openAttachedText() {
340
- this._showAttachedText = true;
566
+ const item: AttachedTextItem = {
567
+ clientId: crypto.randomUUID(),
568
+ bodyJson: null,
569
+ bodyHtml: "",
570
+ summary: "",
571
+ };
572
+ this._attachedTexts = [...this._attachedTexts, item];
573
+ this._attachmentOrder = [...this._attachmentOrder, item.clientId];
574
+ const index = this._attachedTexts.length - 1;
341
575
  this.dispatchEvent(
342
- new CustomEvent("jant:attached-panel-open", { bubbles: true }),
576
+ new CustomEvent("jant:attached-panel-open", {
577
+ bubbles: true,
578
+ detail: { index },
579
+ }),
343
580
  );
344
581
  }
345
582
 
346
- updateAttachedText(value: string) {
347
- this._attachedText = value;
583
+ private _moveAttachment(clientId: string, direction: -1 | 1) {
584
+ const index = this._attachmentOrder.indexOf(clientId);
585
+ const nextIndex = index + direction;
586
+ if (
587
+ index === -1 ||
588
+ nextIndex < 0 ||
589
+ nextIndex >= this._attachmentOrder.length
590
+ ) {
591
+ return;
592
+ }
593
+
594
+ const nextOrder = [...this._attachmentOrder];
595
+ const [item] = nextOrder.splice(index, 1);
596
+ if (!item) return;
597
+ nextOrder.splice(nextIndex, 0, item);
598
+ this._attachmentOrder = nextOrder;
599
+ this.#scrollAttachmentIntoView(clientId);
348
600
  }
349
601
 
350
- closeAttachedPanel() {
351
- this._showAttachedText = false;
602
+ private _handleAttachmentKeydown(
603
+ clientId: string,
604
+ e: globalThis.KeyboardEvent,
605
+ onActivate?: () => void,
606
+ ) {
607
+ if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
608
+ e.preventDefault();
609
+ this._moveAttachment(clientId, -1);
610
+ return;
611
+ }
612
+
613
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
614
+ e.preventDefault();
615
+ this._moveAttachment(clientId, 1);
616
+ return;
617
+ }
618
+
619
+ if (onActivate && (e.key === "Enter" || e.key === " ")) {
620
+ e.preventDefault();
621
+ onActivate();
622
+ }
623
+ }
624
+
625
+ #initSortable() {
626
+ const list = this.querySelector<HTMLElement>("[data-attachment-list]");
627
+ if (!list || this.#sortable || this._attachmentOrder.length <= 1) return;
628
+
629
+ this.#sortable = Sortable.create(list, {
630
+ animation: 180,
631
+ bubbleScroll: false,
632
+ chosenClass: "compose-attachment-chosen",
633
+ direction: "horizontal",
634
+ dragClass: "compose-attachment-drag",
635
+ fallbackTolerance: 4,
636
+ filter:
637
+ "button, a, input, textarea, select, option, [contenteditable='true']",
638
+ forceAutoScrollFallback: true,
639
+ ghostClass: "compose-attachment-ghost",
640
+ handle: "[data-attachment-sortable]",
641
+ preventOnFilter: false,
642
+ scroll: list,
643
+ scrollSensitivity: 56,
644
+ scrollSpeed: 18,
645
+ onChoose: () => {
646
+ list.dataset.dragging = "true";
647
+ },
648
+ onStart: (evt) => {
649
+ this.#revertNextSibling = evt.item.nextSibling;
650
+ },
651
+ onUnchoose: () => {
652
+ delete list.dataset.dragging;
653
+ },
654
+ onEnd: (evt) => {
655
+ const els = [
656
+ ...list.querySelectorAll<HTMLElement>("[data-attachment-id]"),
657
+ ];
658
+ const orderedIds = els
659
+ .map((el) => el.dataset.attachmentId)
660
+ .filter((id): id is string => id !== undefined);
661
+
662
+ const { item, oldIndex, newIndex } = evt;
663
+ if (oldIndex != null && newIndex != null && oldIndex !== newIndex) {
664
+ item.parentNode?.removeChild(item);
665
+ if (this.#revertNextSibling) {
666
+ list.insertBefore(item, this.#revertNextSibling);
667
+ } else {
668
+ list.appendChild(item);
669
+ }
670
+ }
671
+ this.#revertNextSibling = null;
672
+ delete list.dataset.dragging;
673
+
674
+ this.#sortable?.destroy();
675
+ this.#sortable = null;
676
+
677
+ if (orderedIds.length === this._attachmentOrder.length) {
678
+ this._attachmentOrder = orderedIds;
679
+ const movedId =
680
+ evt.newIndex != null ? orderedIds[evt.newIndex] : undefined;
681
+ if (movedId) {
682
+ this._suppressAttachedTextOpenUntil = Date.now() + 250;
683
+ this.#scrollAttachmentIntoView(movedId);
684
+ }
685
+ }
686
+ },
687
+ });
688
+ }
689
+
690
+ #scrollAttachmentIntoView(clientId: string) {
691
+ void this.updateComplete.then(() => {
692
+ const target = this.querySelector<HTMLElement>(
693
+ `[data-attachment-id="${clientId}"]`,
694
+ );
695
+ target?.scrollIntoView({
696
+ behavior: "smooth",
697
+ block: "nearest",
698
+ inline: "nearest",
699
+ });
700
+ });
701
+ }
702
+
703
+ private _maybeEditAttachedText(index: number) {
704
+ if (Date.now() < this._suppressAttachedTextOpenUntil) {
705
+ return;
706
+ }
707
+ this._editAttachedText(index);
708
+ }
709
+
710
+ private _editAttachedText(index: number) {
711
+ this.dispatchEvent(
712
+ new CustomEvent("jant:attached-panel-open", {
713
+ bubbles: true,
714
+ detail: { index },
715
+ }),
716
+ );
717
+ }
718
+
719
+ private _removeAttachedText(index: number) {
720
+ const removed = this._attachedTexts[index];
721
+ this._attachedTexts = this._attachedTexts.filter((_, i) => i !== index);
722
+ if (removed) {
723
+ this._attachmentOrder = this._attachmentOrder.filter(
724
+ (id) => id !== removed.clientId,
725
+ );
726
+ }
727
+ }
728
+
729
+ updateAttachedText(
730
+ index: number,
731
+ bodyJson: JSONContent | null,
732
+ bodyHtml?: string,
733
+ ) {
734
+ const plainText = this._extractPlainText(bodyJson);
735
+ this._attachedTexts = this._attachedTexts.map((item, i) =>
736
+ i === index
737
+ ? {
738
+ ...item,
739
+ bodyJson,
740
+ bodyHtml: bodyHtml ?? "",
741
+ summary: this._computeSummary(plainText),
742
+ }
743
+ : item,
744
+ );
745
+ }
746
+
747
+ closeAttachedPanel(index: number) {
748
+ const item = this._attachedTexts[index];
749
+ if (item && !this._hasAttachedTextContent(item.bodyJson)) {
750
+ this._attachedTexts = this._attachedTexts.filter((_, i) => i !== index);
751
+ this._attachmentOrder = this._attachmentOrder.filter(
752
+ (id) => id !== item.clientId,
753
+ );
754
+ }
755
+ }
756
+
757
+ private _hasAttachedTextContent(bodyJson: JSONContent | null): boolean {
758
+ if (!bodyJson) return false;
759
+ return this._extractPlainText(bodyJson).trim().length > 0;
760
+ }
761
+
762
+ private _extractPlainText(json: JSONContent | null): string {
763
+ if (!json) return "";
764
+ let text = "";
765
+ const walk = (node: JSONContent) => {
766
+ if (node.text) text += node.text;
767
+ if (node.content) node.content.forEach(walk);
768
+ };
769
+ walk(json);
770
+ return text;
352
771
  }
353
772
 
354
773
  private _onInput(field: string, e: Event) {
355
774
  const target = e.target as HTMLInputElement | HTMLTextAreaElement;
356
775
  (this as Record<string, unknown>)[field] = target.value;
357
- if (
358
- target.tagName === "TEXTAREA" &&
359
- !target.classList.contains("compose-attached-textarea")
360
- ) {
776
+ if (target.tagName === "TEXTAREA") {
361
777
  this._autoResize(target as HTMLElement);
362
778
  }
363
779
  }
@@ -410,9 +826,12 @@ export class JantComposeEditor extends LitElement {
410
826
  file,
411
827
  previewUrl,
412
828
  status: "pending",
829
+ progress: null,
413
830
  mediaId: null,
414
831
  alt: "",
415
832
  error: null,
833
+ summary: null,
834
+ chars: null,
416
835
  });
417
836
  files.push({ file, clientId });
418
837
  }
@@ -420,6 +839,24 @@ export class JantComposeEditor extends LitElement {
420
839
  if (newAttachments.length === 0) return;
421
840
 
422
841
  this._attachments = [...this._attachments, ...newAttachments];
842
+ this._attachmentOrder = [
843
+ ...this._attachmentOrder,
844
+ ...newAttachments.map((a) => a.clientId),
845
+ ];
846
+
847
+ // Extract summaries and char counts for text-category files asynchronously
848
+ for (const att of newAttachments) {
849
+ const category = getMediaCategory(att.file.type);
850
+ if (category === "text") {
851
+ att.file.text().then((content) => {
852
+ const summary = this._computeSummary(content);
853
+ const chars = content.length;
854
+ this._attachments = this._attachments.map((a) =>
855
+ a.clientId === att.clientId ? { ...a, summary, chars } : a,
856
+ );
857
+ });
858
+ }
859
+ }
423
860
 
424
861
  this.dispatchEvent(
425
862
  new CustomEvent("jant:files-selected", {
@@ -429,6 +866,11 @@ export class JantComposeEditor extends LitElement {
429
866
  );
430
867
  }
431
868
 
869
+ removeAttachment(clientId: string) {
870
+ const index = this._attachments.findIndex((a) => a.clientId === clientId);
871
+ if (index !== -1) this._removeAttachment(index);
872
+ }
873
+
432
874
  private _removeAttachment(index: number) {
433
875
  const attachment = this._attachments[index];
434
876
  if (attachment) {
@@ -443,6 +885,11 @@ export class JantComposeEditor extends LitElement {
443
885
  }),
444
886
  );
445
887
  }
888
+ if (attachment) {
889
+ this._attachmentOrder = this._attachmentOrder.filter(
890
+ (id) => id !== attachment.clientId,
891
+ );
892
+ }
446
893
  this._attachments = this._attachments.filter((_, i) => i !== index);
447
894
  // Close alt panel if it was showing the removed item
448
895
  if (this._showAltPanel && this._altPanelIndex === index) {
@@ -462,7 +909,7 @@ export class JantComposeEditor extends LitElement {
462
909
  // Reset failed attachments to pending
463
910
  this._attachments = this._attachments.map((a) =>
464
911
  a.status === "error"
465
- ? { ...a, status: "pending" as const, error: null }
912
+ ? { ...a, status: "pending" as const, progress: null, error: null }
466
913
  : a,
467
914
  );
468
915
 
@@ -619,7 +1066,7 @@ export class JantComposeEditor extends LitElement {
619
1066
 
620
1067
  // ── Helpers ──────────────────────────────────────────────────────
621
1068
 
622
- private _getCategory(a: ComposeAttachment): MediaCategory | null {
1069
+ private _getCategory(a: ComposeAttachment): MediaCategory {
623
1070
  return getMediaCategory(a.file.type);
624
1071
  }
625
1072
 
@@ -629,6 +1076,47 @@ export class JantComposeEditor extends LitElement {
629
1076
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
630
1077
  }
631
1078
 
1079
+ private _formatChars(count: number): string {
1080
+ if (count < 1000) return `${count} chars`;
1081
+ if (count < 1_000_000) {
1082
+ return `${parseFloat((count / 1000).toFixed(1))}k chars`;
1083
+ }
1084
+ return `${parseFloat((count / 1_000_000).toFixed(1))}M chars`;
1085
+ }
1086
+
1087
+ private _renderFileIcon(mimeType: string, size: number) {
1088
+ const doc = `<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>`;
1089
+
1090
+ let inner: string;
1091
+ if (mimeType === "application/pdf") {
1092
+ inner = `<text x="12" y="16.5" text-anchor="middle" fill="currentColor" stroke="none" font-size="6" font-weight="700" font-family="system-ui, sans-serif">PDF</text>`;
1093
+ } else if (mimeType === "text/markdown") {
1094
+ inner = `<text x="12" y="16.5" text-anchor="middle" fill="currentColor" stroke="none" font-size="10" font-weight="700" font-family="system-ui, sans-serif">#</text>`;
1095
+ } else if (mimeType === "text/csv") {
1096
+ inner = `<line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="15" x2="16" y2="15"/><line x1="8" y1="18" x2="16" y2="18"/><line x1="10.7" y1="12" x2="10.7" y2="18"/><line x1="13.3" y1="12" x2="13.3" y2="18"/>`;
1097
+ } else if (getMediaCategory(mimeType) === "archive") {
1098
+ inner = `<line x1="12" y1="10" x2="12" y2="11.5"/><line x1="12" y1="13" x2="12" y2="14.5"/><line x1="12" y1="16" x2="12" y2="17.5"/>`;
1099
+ } else if (mimeType === "text/x-tiptap+json") {
1100
+ inner = `<line x1="16" y1="11" x2="8" y2="11"/><line x1="16" y1="14" x2="8" y2="14"/><line x1="12" y1="17" x2="8" y2="17"/>`;
1101
+ } else {
1102
+ // Plain text default — 3 text lines
1103
+ inner = `<line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>`;
1104
+ }
1105
+
1106
+ return html`<svg
1107
+ width="${size}"
1108
+ height="${size}"
1109
+ viewBox="0 0 24 24"
1110
+ fill="none"
1111
+ stroke="currentColor"
1112
+ stroke-width="1.5"
1113
+ stroke-linecap="round"
1114
+ stroke-linejoin="round"
1115
+ >
1116
+ ${unsafeSVG(doc + inner)}
1117
+ </svg>`;
1118
+ }
1119
+
632
1120
  // ── Render helpers ────────────────────────────────────────────────
633
1121
 
634
1122
  private _renderNoteFields() {
@@ -763,48 +1251,6 @@ export class JantComposeEditor extends LitElement {
763
1251
  `;
764
1252
  }
765
1253
 
766
- private _renderAttachedBadge() {
767
- if (this._attachedText.trim().length === 0 || this._showAttachedText)
768
- return nothing;
769
- return html`
770
- <div
771
- class="compose-attached-badge"
772
- @click=${() => this._openAttachedText()}
773
- >
774
- <svg
775
- width="14"
776
- height="14"
777
- viewBox="0 0 18 18"
778
- fill="none"
779
- stroke="currentColor"
780
- stroke-width="1.3"
781
- stroke-linecap="round"
782
- class="text-muted-foreground icon-fine"
783
- >
784
- <rect x="3" y="2" width="12" height="14" rx="2" />
785
- <line x1="6" y1="6" x2="12" y2="6" />
786
- <line x1="6" y1="9" x2="12" y2="9" />
787
- <line x1="6" y1="12" x2="9.5" y2="12" />
788
- </svg>
789
- <span class="text-xs font-medium">${this.labels.attachedText}</span>
790
- <span class="text-xs text-muted-foreground"
791
- >· ${this._attachedText.length.toLocaleString()} chars</span
792
- >
793
- <div class="flex-1"></div>
794
- <button
795
- type="button"
796
- class="compose-attached-badge-dismiss"
797
- @click=${(e: Event) => {
798
- e.stopPropagation();
799
- this._attachedText = "";
800
- }}
801
- >
802
-
803
- </button>
804
- </div>
805
- `;
806
- }
807
-
808
1254
  private _renderAttachmentPreview(a: ComposeAttachment) {
809
1255
  const category = this._getCategory(a);
810
1256
 
@@ -846,6 +1292,9 @@ export class JantComposeEditor extends LitElement {
846
1292
  </svg>
847
1293
  </div>
848
1294
  <span class="compose-attachment-file-name">${a.file.name}</span>
1295
+ <span class="compose-attachment-file-size"
1296
+ >${this._formatSize(a.file.size)}</span
1297
+ >
849
1298
  </div>
850
1299
  `;
851
1300
  }
@@ -854,23 +1303,43 @@ export class JantComposeEditor extends LitElement {
854
1303
  return html`
855
1304
  <div class="compose-attachment-file-card">
856
1305
  <div class="compose-attachment-file-icon">
857
- <svg
858
- width="20"
859
- height="20"
860
- viewBox="0 0 24 24"
861
- fill="none"
862
- stroke="currentColor"
863
- stroke-width="1.5"
864
- stroke-linecap="round"
865
- stroke-linejoin="round"
866
- >
867
- <path
868
- d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
869
- />
870
- <polyline points="14 2 14 8 20 8" />
871
- <line x1="16" y1="13" x2="8" y2="13" />
872
- <line x1="16" y1="17" x2="8" y2="17" />
873
- </svg>
1306
+ ${this._renderFileIcon(a.file.type, 20)}
1307
+ </div>
1308
+ <span class="compose-attachment-file-name">${a.file.name}</span>
1309
+ <span class="compose-attachment-file-size"
1310
+ >${this._formatSize(a.file.size)}</span
1311
+ >
1312
+ </div>
1313
+ `;
1314
+ }
1315
+
1316
+ if (category === "text") {
1317
+ return html`
1318
+ <div class="compose-attachment-file-card">
1319
+ <div class="compose-attachment-file-icon">
1320
+ ${this._renderFileIcon(a.file.type, 20)}
1321
+ </div>
1322
+ <span class="compose-attachment-file-name">${a.file.name}</span>
1323
+ ${a.summary
1324
+ ? html`<span class="compose-attachment-text-summary"
1325
+ >${a.summary}</span
1326
+ >`
1327
+ : nothing}
1328
+ ${typeof a.chars === "number" && a.chars > 0
1329
+ ? html`<span class="compose-attachment-file-size"
1330
+ >${this._formatChars(a.chars)}</span
1331
+ >`
1332
+ : nothing}
1333
+ </div>
1334
+ `;
1335
+ }
1336
+
1337
+ // Default for non-visual types: generic file card (archive, office, font, 3d, code, etc.)
1338
+ if (category !== "image") {
1339
+ return html`
1340
+ <div class="compose-attachment-file-card">
1341
+ <div class="compose-attachment-file-icon">
1342
+ ${this._renderFileIcon(a.file.type, 20)}
874
1343
  </div>
875
1344
  <span class="compose-attachment-file-name">${a.file.name}</span>
876
1345
  <span class="compose-attachment-file-size"
@@ -880,7 +1349,7 @@ export class JantComposeEditor extends LitElement {
880
1349
  `;
881
1350
  }
882
1351
 
883
- // Default: image
1352
+ // Image
884
1353
  return html`
885
1354
  <div class="compose-attachment-thumb">
886
1355
  <img src=${a.previewUrl} alt="" class="compose-attachment-img" />
@@ -890,48 +1359,38 @@ export class JantComposeEditor extends LitElement {
890
1359
 
891
1360
  private _renderAttachmentOverlay(a: ComposeAttachment, index: number) {
892
1361
  return html`
893
- ${a.status === "pending" || a.status === "uploading"
894
- ? html`
895
- <div class="compose-attachment-overlay">
896
- <svg
897
- class="animate-spin size-4"
898
- viewBox="0 0 24 24"
899
- fill="none"
900
- stroke="currentColor"
901
- style="stroke-width: 2.5"
902
- stroke-linecap="round"
903
- >
904
- <path d="M21 12a9 9 0 1 1-6.219-8.56" />
905
- </svg>
906
- </div>
907
- `
908
- : nothing}
909
1362
  ${a.status === "error"
910
1363
  ? html`
911
1364
  <button
912
1365
  type="button"
913
1366
  class="compose-attachment-overlay compose-attachment-retry"
914
- title="${a.error ?? "Upload failed"}. ${this.labels.retryAll}"
915
1367
  @click=${(e: Event) => {
916
1368
  e.stopPropagation();
917
1369
  this._retryAllFailed();
918
1370
  }}
919
1371
  >
920
- <svg
921
- width="20"
922
- height="20"
923
- viewBox="0 0 24 24"
924
- fill="none"
925
- stroke="currentColor"
926
- stroke-width="2"
927
- stroke-linecap="round"
928
- stroke-linejoin="round"
929
- >
930
- <path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
931
- <path d="M3 3v5h5" />
932
- <path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
933
- <path d="M16 16h5v5" />
934
- </svg>
1372
+ <span class="compose-retry-content">
1373
+ <svg
1374
+ width="20"
1375
+ height="20"
1376
+ viewBox="0 0 24 24"
1377
+ fill="none"
1378
+ stroke="currentColor"
1379
+ stroke-width="2"
1380
+ stroke-linecap="round"
1381
+ stroke-linejoin="round"
1382
+ >
1383
+ <path
1384
+ d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"
1385
+ />
1386
+ <path d="M3 3v5h5" />
1387
+ <path
1388
+ d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"
1389
+ />
1390
+ <path d="M16 16h5v5" />
1391
+ </svg>
1392
+ <span class="compose-retry-label">${this.labels.retryAll}</span>
1393
+ </span>
935
1394
  </button>
936
1395
  `
937
1396
  : nothing}
@@ -945,55 +1404,126 @@ export class JantComposeEditor extends LitElement {
945
1404
  `;
946
1405
  }
947
1406
 
948
- private _renderAttachments() {
949
- if (this._attachments.length === 0) return nothing;
1407
+ private _renderAttachedTextCard(item: AttachedTextItem, index: number) {
1408
+ return html`
1409
+ <div class="compose-attachment" data-attachment-id=${item.clientId}>
1410
+ <div
1411
+ class="compose-attachment-thumb compose-attachment-sortable"
1412
+ data-attachment-sortable
1413
+ tabindex="0"
1414
+ @click=${() => this._maybeEditAttachedText(index)}
1415
+ @keydown=${(e: globalThis.KeyboardEvent) =>
1416
+ this._handleAttachmentKeydown(item.clientId, e, () =>
1417
+ this._maybeEditAttachedText(index),
1418
+ )}
1419
+ >
1420
+ <div class="compose-attachment-text-card">
1421
+ <div class="compose-attachment-file-icon">
1422
+ ${this._renderFileIcon("text/x-tiptap+json", 20)}
1423
+ </div>
1424
+ <span class="compose-attachment-text-summary">${item.summary}</span>
1425
+ ${item.bodyJson
1426
+ ? html`<span class="compose-attachment-file-size"
1427
+ >${this._formatChars(
1428
+ this._extractPlainText(item.bodyJson).length,
1429
+ )}</span
1430
+ >`
1431
+ : nothing}
1432
+ </div>
1433
+ <button
1434
+ type="button"
1435
+ class="compose-attachment-remove"
1436
+ @click=${(e: Event) => {
1437
+ e.stopPropagation();
1438
+ this._removeAttachedText(index);
1439
+ }}
1440
+ >
1441
+
1442
+ </button>
1443
+ </div>
1444
+ </div>
1445
+ `;
1446
+ }
1447
+
1448
+ private _renderMediaAttachment(a: ComposeAttachment, i: number) {
1449
+ const category = this._getCategory(a);
1450
+ const isFileCard = category !== "image" && category !== "video";
950
1451
 
951
1452
  return html`
952
- <div class="compose-attachments">
953
- ${this._attachments.map((a, i) => {
954
- const category = this._getCategory(a);
955
- const isFileCard = category === "audio" || category === "document";
956
-
957
- return html`
958
- <div class="compose-attachment">
959
- ${isFileCard
960
- ? html`
961
- <div class="compose-attachment-thumb">
962
- ${this._renderAttachmentPreview(a)}
963
- ${this._renderAttachmentOverlay(a, i)}
964
- </div>
965
- `
966
- : html`
967
- <div class="compose-attachment-thumb">
968
- ${category === "video"
969
- ? html`
970
- <video
971
- src=${a.previewUrl}
972
- class="compose-attachment-img"
973
- preload="metadata"
974
- muted
975
- ></video>
976
- <div class="compose-attachment-play-icon">
977
- <svg
978
- width="24"
979
- height="24"
980
- viewBox="0 0 24 24"
981
- fill="white"
982
- >
983
- <path d="M8 5v14l11-7z" />
984
- </svg>
985
- </div>
986
- `
987
- : html`
988
- <img
989
- src=${a.previewUrl}
990
- alt=""
991
- class="compose-attachment-img"
992
- />
993
- `}
994
- ${this._renderAttachmentOverlay(a, i)}
995
- </div>
996
- `}
1453
+ <div class="compose-attachment" data-attachment-id=${a.clientId}>
1454
+ ${isFileCard
1455
+ ? html`
1456
+ <div class="compose-attachment-thumb">
1457
+ <div
1458
+ class="compose-attachment-sortable"
1459
+ data-attachment-sortable
1460
+ tabindex="0"
1461
+ @keydown=${(e: globalThis.KeyboardEvent) =>
1462
+ this._handleAttachmentKeydown(a.clientId, e)}
1463
+ >
1464
+ ${this._renderAttachmentPreview(a)}
1465
+ </div>
1466
+ ${this._renderAttachmentOverlay(a, i)}
1467
+ </div>
1468
+ `
1469
+ : html`
1470
+ <div class="compose-attachment-thumb">
1471
+ <div
1472
+ class="compose-attachment-sortable"
1473
+ data-attachment-sortable
1474
+ tabindex="0"
1475
+ @keydown=${(e: globalThis.KeyboardEvent) =>
1476
+ this._handleAttachmentKeydown(a.clientId, e)}
1477
+ >
1478
+ ${category === "video"
1479
+ ? html`
1480
+ <video
1481
+ src=${a.previewUrl}
1482
+ class="compose-attachment-img"
1483
+ preload="metadata"
1484
+ muted
1485
+ ></video>
1486
+ <div class="compose-attachment-play-icon">
1487
+ <svg
1488
+ width="24"
1489
+ height="24"
1490
+ viewBox="0 0 24 24"
1491
+ fill="white"
1492
+ >
1493
+ <path d="M8 5v14l11-7z" />
1494
+ </svg>
1495
+ </div>
1496
+ `
1497
+ : a.status === "processing"
1498
+ ? html`
1499
+ <div class="compose-attachment-processing">
1500
+ <svg
1501
+ class="animate-spin size-5"
1502
+ viewBox="0 0 24 24"
1503
+ fill="none"
1504
+ stroke="currentColor"
1505
+ stroke-width="2"
1506
+ >
1507
+ <path
1508
+ d="M12 2a10 10 0 1 0 10 10"
1509
+ stroke-linecap="round"
1510
+ />
1511
+ </svg>
1512
+ </div>
1513
+ `
1514
+ : html`
1515
+ <img
1516
+ src=${a.previewUrl}
1517
+ alt=""
1518
+ class="compose-attachment-img"
1519
+ />
1520
+ `}
1521
+ </div>
1522
+ ${this._renderAttachmentOverlay(a, i)}
1523
+ </div>
1524
+ `}
1525
+ ${category === "image"
1526
+ ? html`
997
1527
  <button
998
1528
  type="button"
999
1529
  class=${classMap({
@@ -1004,15 +1534,45 @@ export class JantComposeEditor extends LitElement {
1004
1534
  >
1005
1535
  ${a.alt.length > 0 ? "ALT" : "+ ALT"}
1006
1536
  </button>
1007
- </div>
1008
- `;
1537
+ `
1538
+ : nothing}
1539
+ </div>
1540
+ `;
1541
+ }
1542
+
1543
+ private _renderAttachments() {
1544
+ if (this._attachments.length === 0 && this._attachedTexts.length === 0)
1545
+ return nothing;
1546
+
1547
+ return html`
1548
+ <div class="compose-attachments" data-attachment-list>
1549
+ ${this._attachmentOrder.map((clientId) => {
1550
+ const mediaIndex = this._attachments.findIndex(
1551
+ (a) => a.clientId === clientId,
1552
+ );
1553
+ if (mediaIndex !== -1) {
1554
+ return this._renderMediaAttachment(
1555
+ this._attachments[mediaIndex],
1556
+ mediaIndex,
1557
+ );
1558
+ }
1559
+ const textIndex = this._attachedTexts.findIndex(
1560
+ (t) => t.clientId === clientId,
1561
+ );
1562
+ if (textIndex !== -1) {
1563
+ return this._renderAttachedTextCard(
1564
+ this._attachedTexts[textIndex],
1565
+ textIndex,
1566
+ );
1567
+ }
1568
+ return nothing;
1009
1569
  })}
1010
1570
  </div>
1011
1571
  `;
1012
1572
  }
1013
1573
 
1014
1574
  private _renderToolsRow() {
1015
- const hasAttached = this._attachedText.trim().length > 0;
1575
+ const hasAttached = this._attachedTexts.length > 0;
1016
1576
  return html`
1017
1577
  <div class="compose-tools-row">
1018
1578
  <!-- Media / Add -->
@@ -1053,9 +1613,9 @@ export class JantComposeEditor extends LitElement {
1053
1613
  type="button"
1054
1614
  class=${classMap({
1055
1615
  "compose-tool-btn": true,
1056
- "compose-tool-btn-active": hasAttached,
1616
+ "compose-tool-btn-add": hasAttached,
1057
1617
  })}
1058
- title=${this.labels.attachedText}
1618
+ title=${hasAttached ? "" : this.labels.attachedText}
1059
1619
  @click=${() => this._openAttachedText()}
1060
1620
  >
1061
1621
  <svg
@@ -1073,6 +1633,11 @@ export class JantComposeEditor extends LitElement {
1073
1633
  <line x1="6" y1="9" x2="12" y2="9" />
1074
1634
  <line x1="6" y1="12" x2="9.5" y2="12" />
1075
1635
  </svg>
1636
+ ${hasAttached
1637
+ ? html`<span class="compose-tool-label"
1638
+ >${this.labels.addMore}</span
1639
+ >`
1640
+ : nothing}
1076
1641
  </button>
1077
1642
 
1078
1643
  <!-- Rate -->
@@ -1238,8 +1803,7 @@ export class JantComposeEditor extends LitElement {
1238
1803
  : this.format === "link"
1239
1804
  ? this._renderLinkFields()
1240
1805
  : this._renderQuoteFields()}
1241
- ${this._renderStarRating()} ${this._renderAttachedBadge()}
1242
- ${this._renderAttachments()}
1806
+ ${this._renderAttachments()} ${this._renderStarRating()}
1243
1807
  </section>
1244
1808
  ${this._renderToolsRow()}
1245
1809
  `;