@jant/core 0.3.26 → 0.3.28

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 (314) hide show
  1. package/dist/client/client.css +1 -0
  2. package/dist/client/client.js +31561 -0
  3. package/dist/index.js +15209 -15
  4. package/package.json +21 -15
  5. package/src/__tests__/helpers/app.ts +19 -3
  6. package/src/__tests__/helpers/db.ts +44 -0
  7. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  8. package/src/app.tsx +112 -173
  9. package/src/auth.ts +4 -1
  10. package/src/client.ts +13 -0
  11. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  12. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  13. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  14. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  15. package/src/db/schema.ts +24 -4
  16. package/src/i18n/locales/en.po +810 -385
  17. package/src/i18n/locales/en.ts +1 -1
  18. package/src/i18n/locales/zh-Hans.po +733 -522
  19. package/src/i18n/locales/zh-Hans.ts +1 -1
  20. package/src/i18n/locales/zh-Hant.po +733 -522
  21. package/src/i18n/locales/zh-Hant.ts +1 -1
  22. package/src/i18n/middleware.ts +7 -11
  23. package/src/index.ts +1 -1
  24. package/src/lib/__tests__/icons.test.ts +178 -0
  25. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  26. package/src/lib/__tests__/schemas.test.ts +12 -6
  27. package/src/lib/__tests__/theme.test.ts +62 -0
  28. package/src/lib/__tests__/timezones.test.ts +1 -1
  29. package/src/lib/__tests__/url.test.ts +12 -0
  30. package/src/lib/__tests__/view.test.ts +1 -5
  31. package/src/lib/avatar-upload.ts +18 -10
  32. package/src/lib/collection-form-bridge.ts +52 -0
  33. package/src/lib/collections-reorder.ts +28 -0
  34. package/src/lib/compose-bridge.ts +251 -0
  35. package/src/lib/errors.ts +116 -0
  36. package/src/lib/excerpt.ts +1 -1
  37. package/src/lib/favicon.ts +3 -5
  38. package/src/lib/html.ts +22 -0
  39. package/src/lib/icon-catalog.ts +181 -0
  40. package/src/lib/icons.ts +202 -0
  41. package/src/lib/navigation.ts +18 -33
  42. package/src/lib/pagination.ts +3 -2
  43. package/src/lib/post-form-bridge.ts +136 -0
  44. package/src/lib/render.tsx +11 -4
  45. package/src/lib/resolve-config.ts +157 -0
  46. package/src/lib/schemas.ts +76 -12
  47. package/src/lib/settings-bridge.ts +139 -0
  48. package/src/lib/storage.ts +37 -16
  49. package/src/lib/theme.ts +5 -7
  50. package/src/lib/timeline.ts +4 -8
  51. package/src/lib/toast.ts +134 -0
  52. package/src/lib/upload.ts +71 -0
  53. package/src/lib/url.ts +9 -1
  54. package/src/lib/version.ts +16 -0
  55. package/src/lib/view.ts +9 -10
  56. package/src/middleware/__tests__/auth.test.ts +6 -28
  57. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  58. package/src/middleware/auth.ts +6 -12
  59. package/src/middleware/config.ts +51 -0
  60. package/src/middleware/error-handler.ts +56 -0
  61. package/src/middleware/onboarding.ts +1 -1
  62. package/src/preset.css +6 -0
  63. package/src/routes/__tests__/compose.test.ts +104 -17
  64. package/src/routes/api/__tests__/collections.test.ts +93 -2
  65. package/src/routes/api/__tests__/posts.test.ts +2 -1
  66. package/src/routes/api/__tests__/settings.test.ts +1 -1
  67. package/src/routes/api/collections.ts +64 -68
  68. package/src/routes/api/nav-items.ts +21 -59
  69. package/src/routes/api/pages.ts +18 -46
  70. package/src/routes/api/posts.ts +64 -86
  71. package/src/routes/api/search.ts +6 -4
  72. package/src/routes/api/settings.ts +8 -24
  73. package/src/routes/api/upload.ts +55 -53
  74. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  75. package/src/routes/auth/reset.tsx +17 -66
  76. package/src/routes/auth/setup.tsx +67 -11
  77. package/src/routes/auth/signin.tsx +44 -8
  78. package/src/routes/compose.tsx +194 -0
  79. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  80. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  81. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  82. package/src/routes/dash/appearance.tsx +173 -0
  83. package/src/routes/dash/collections.tsx +80 -14
  84. package/src/routes/dash/index.tsx +12 -14
  85. package/src/routes/dash/media.tsx +46 -49
  86. package/src/routes/dash/pages.tsx +85 -37
  87. package/src/routes/dash/posts.tsx +60 -23
  88. package/src/routes/dash/redirects.tsx +43 -33
  89. package/src/routes/dash/settings.tsx +234 -214
  90. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  91. package/src/routes/feed/rss.ts +11 -16
  92. package/src/routes/feed/sitemap.ts +15 -9
  93. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  94. package/src/routes/pages/archive.tsx +2 -2
  95. package/src/routes/pages/collection.tsx +76 -9
  96. package/src/routes/pages/collections.tsx +3 -1
  97. package/src/routes/pages/featured.tsx +2 -2
  98. package/src/routes/pages/home.tsx +3 -3
  99. package/src/routes/pages/latest.tsx +2 -2
  100. package/src/routes/pages/page.tsx +2 -2
  101. package/src/routes/pages/post.tsx +2 -2
  102. package/src/routes/pages/search.tsx +2 -2
  103. package/src/services/__tests__/collection.test.ts +324 -34
  104. package/src/services/__tests__/media.test.ts +1 -1
  105. package/src/services/__tests__/page.test.ts +116 -1
  106. package/src/services/auth.ts +88 -0
  107. package/src/services/collection.ts +169 -30
  108. package/src/services/index.ts +8 -3
  109. package/src/services/media.ts +39 -12
  110. package/src/services/navigation.ts +17 -5
  111. package/src/services/page.ts +24 -4
  112. package/src/services/post.ts +87 -19
  113. package/src/services/search.ts +0 -1
  114. package/src/services/settings.ts +21 -13
  115. package/src/style.css +3 -0
  116. package/src/styles/components.css +42 -1
  117. package/src/styles/tokens.css +4 -0
  118. package/src/styles/ui.css +902 -73
  119. package/src/types/app-context.ts +25 -0
  120. package/src/types/bindings.ts +1 -0
  121. package/src/types/config.ts +60 -23
  122. package/src/types/entities.ts +12 -2
  123. package/src/types/lingui-react-macro.d.ts +3 -3
  124. package/src/types/operations.ts +2 -4
  125. package/src/types/views.ts +1 -3
  126. package/src/ui/__tests__/font-themes.test.ts +27 -8
  127. package/src/ui/color-themes.ts +1 -1
  128. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  129. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  130. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  131. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  132. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  133. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  134. package/src/ui/components/collection-types.ts +45 -0
  135. package/src/ui/components/compose-types.ts +75 -0
  136. package/src/ui/components/jant-collection-form.ts +512 -0
  137. package/src/ui/components/jant-compose-dialog.ts +494 -0
  138. package/src/ui/components/jant-compose-editor.ts +799 -0
  139. package/src/ui/components/jant-post-form.ts +290 -0
  140. package/src/ui/components/jant-settings-avatar.ts +231 -0
  141. package/src/ui/components/jant-settings-general.ts +436 -0
  142. package/src/ui/components/post-form-template.ts +260 -0
  143. package/src/ui/components/post-form-types.ts +87 -0
  144. package/src/ui/components/settings-types.ts +62 -0
  145. package/src/ui/compose/ComposeDialog.tsx +141 -385
  146. package/src/ui/compose/ComposePrompt.tsx +3 -3
  147. package/src/ui/dash/PostList.tsx +55 -61
  148. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  149. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  150. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  151. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  152. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  153. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  154. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  155. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  156. package/src/ui/dash/index.ts +1 -1
  157. package/src/ui/dash/posts/PostForm.tsx +248 -0
  158. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  159. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  160. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  161. package/src/ui/font-themes.ts +115 -32
  162. package/src/ui/layouts/BaseLayout.tsx +49 -19
  163. package/src/ui/layouts/DashLayout.tsx +14 -9
  164. package/src/ui/layouts/SiteLayout.tsx +38 -23
  165. package/src/ui/pages/CollectionPage.tsx +12 -2
  166. package/src/ui/pages/CollectionsPage.tsx +27 -27
  167. package/src/ui/pages/HomePage.tsx +15 -6
  168. package/src/ui/pages/SearchPage.tsx +1 -2
  169. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  170. package/src/ui/shared/Pagination.tsx +2 -2
  171. package/dist/app.js +0 -265
  172. package/dist/auth.js +0 -36
  173. package/dist/client.js +0 -13
  174. package/dist/db/index.js +0 -10
  175. package/dist/db/schema.js +0 -224
  176. package/dist/i18n/Trans.js +0 -24
  177. package/dist/i18n/context.js +0 -58
  178. package/dist/i18n/detect.js +0 -26
  179. package/dist/i18n/i18n.js +0 -49
  180. package/dist/i18n/index.js +0 -44
  181. package/dist/i18n/locales/en.js +0 -1
  182. package/dist/i18n/locales/zh-Hans.js +0 -1
  183. package/dist/i18n/locales/zh-Hant.js +0 -1
  184. package/dist/i18n/locales.js +0 -13
  185. package/dist/i18n/middleware.js +0 -30
  186. package/dist/lib/avatar-upload.js +0 -134
  187. package/dist/lib/config.js +0 -143
  188. package/dist/lib/constants.js +0 -50
  189. package/dist/lib/excerpt.js +0 -76
  190. package/dist/lib/favicon.js +0 -102
  191. package/dist/lib/feed.js +0 -123
  192. package/dist/lib/image-processor.js +0 -187
  193. package/dist/lib/image.js +0 -97
  194. package/dist/lib/index.js +0 -7
  195. package/dist/lib/markdown.js +0 -83
  196. package/dist/lib/media-helpers.js +0 -49
  197. package/dist/lib/media-upload.js +0 -104
  198. package/dist/lib/nav-reorder.js +0 -27
  199. package/dist/lib/navigation.js +0 -79
  200. package/dist/lib/pagination.js +0 -44
  201. package/dist/lib/render.js +0 -53
  202. package/dist/lib/schemas.js +0 -174
  203. package/dist/lib/sqid.js +0 -72
  204. package/dist/lib/sse.js +0 -218
  205. package/dist/lib/storage.js +0 -164
  206. package/dist/lib/theme.js +0 -65
  207. package/dist/lib/time.js +0 -159
  208. package/dist/lib/timeline.js +0 -95
  209. package/dist/lib/timezones.js +0 -388
  210. package/dist/lib/url.js +0 -89
  211. package/dist/lib/view.js +0 -217
  212. package/dist/middleware/auth.js +0 -52
  213. package/dist/middleware/onboarding.js +0 -41
  214. package/dist/routes/api/collections.js +0 -124
  215. package/dist/routes/api/nav-items.js +0 -104
  216. package/dist/routes/api/pages.js +0 -91
  217. package/dist/routes/api/posts.js +0 -218
  218. package/dist/routes/api/search.js +0 -48
  219. package/dist/routes/api/settings.js +0 -68
  220. package/dist/routes/api/upload.js +0 -246
  221. package/dist/routes/auth/reset.js +0 -221
  222. package/dist/routes/auth/setup.js +0 -194
  223. package/dist/routes/auth/signin.js +0 -176
  224. package/dist/routes/compose.js +0 -48
  225. package/dist/routes/dash/collections.js +0 -115
  226. package/dist/routes/dash/index.js +0 -118
  227. package/dist/routes/dash/media.js +0 -106
  228. package/dist/routes/dash/pages.js +0 -294
  229. package/dist/routes/dash/posts.js +0 -244
  230. package/dist/routes/dash/redirects.js +0 -257
  231. package/dist/routes/dash/settings.js +0 -379
  232. package/dist/routes/feed/rss.js +0 -62
  233. package/dist/routes/feed/sitemap.js +0 -49
  234. package/dist/routes/pages/archive.js +0 -62
  235. package/dist/routes/pages/collection.js +0 -34
  236. package/dist/routes/pages/collections.js +0 -28
  237. package/dist/routes/pages/featured.js +0 -36
  238. package/dist/routes/pages/home.js +0 -64
  239. package/dist/routes/pages/latest.js +0 -45
  240. package/dist/routes/pages/page.js +0 -68
  241. package/dist/routes/pages/post.js +0 -44
  242. package/dist/routes/pages/search.js +0 -54
  243. package/dist/services/collection.js +0 -109
  244. package/dist/services/index.js +0 -24
  245. package/dist/services/media.js +0 -117
  246. package/dist/services/navigation.js +0 -91
  247. package/dist/services/page.js +0 -84
  248. package/dist/services/post.js +0 -229
  249. package/dist/services/redirect.js +0 -48
  250. package/dist/services/search.js +0 -67
  251. package/dist/services/settings.js +0 -68
  252. package/dist/types/bindings.js +0 -3
  253. package/dist/types/config.js +0 -147
  254. package/dist/types/constants.js +0 -27
  255. package/dist/types/entities.js +0 -3
  256. package/dist/types/lingui-react-macro.d.js +0 -9
  257. package/dist/types/operations.js +0 -3
  258. package/dist/types/props.js +0 -3
  259. package/dist/types/sortablejs.d.js +0 -5
  260. package/dist/types/views.js +0 -5
  261. package/dist/types.js +0 -11
  262. package/dist/ui/color-themes.js +0 -268
  263. package/dist/ui/compose/ComposeDialog.js +0 -467
  264. package/dist/ui/compose/ComposePrompt.js +0 -55
  265. package/dist/ui/dash/ActionButtons.js +0 -46
  266. package/dist/ui/dash/CrudPageHeader.js +0 -22
  267. package/dist/ui/dash/DangerZone.js +0 -36
  268. package/dist/ui/dash/FormatBadge.js +0 -27
  269. package/dist/ui/dash/ListItemRow.js +0 -21
  270. package/dist/ui/dash/PageForm.js +0 -195
  271. package/dist/ui/dash/PostForm.js +0 -395
  272. package/dist/ui/dash/PostList.js +0 -83
  273. package/dist/ui/dash/StatusBadge.js +0 -46
  274. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  275. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  276. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  277. package/dist/ui/dash/index.js +0 -10
  278. package/dist/ui/dash/media/MediaListContent.js +0 -166
  279. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  280. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  281. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  282. package/dist/ui/dash/settings/AccountContent.js +0 -209
  283. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  284. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  285. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  286. package/dist/ui/feed/LinkCard.js +0 -72
  287. package/dist/ui/feed/NoteCard.js +0 -58
  288. package/dist/ui/feed/QuoteCard.js +0 -63
  289. package/dist/ui/feed/ThreadPreview.js +0 -48
  290. package/dist/ui/feed/TimelineFeed.js +0 -41
  291. package/dist/ui/feed/TimelineItem.js +0 -27
  292. package/dist/ui/font-themes.js +0 -36
  293. package/dist/ui/layouts/BaseLayout.js +0 -153
  294. package/dist/ui/layouts/DashLayout.js +0 -141
  295. package/dist/ui/layouts/SiteLayout.js +0 -169
  296. package/dist/ui/pages/ArchivePage.js +0 -143
  297. package/dist/ui/pages/CollectionPage.js +0 -70
  298. package/dist/ui/pages/CollectionsPage.js +0 -76
  299. package/dist/ui/pages/FeaturedPage.js +0 -24
  300. package/dist/ui/pages/HomePage.js +0 -24
  301. package/dist/ui/pages/PostPage.js +0 -55
  302. package/dist/ui/pages/SearchPage.js +0 -122
  303. package/dist/ui/pages/SinglePage.js +0 -23
  304. package/dist/ui/shared/EmptyState.js +0 -27
  305. package/dist/ui/shared/MediaGallery.js +0 -35
  306. package/dist/ui/shared/Pagination.js +0 -195
  307. package/dist/ui/shared/ThreadView.js +0 -108
  308. package/dist/ui/shared/index.js +0 -5
  309. package/dist/vendor/datastar.js +0 -1606
  310. package/src/lib/__tests__/config.test.ts +0 -192
  311. package/src/lib/config.ts +0 -167
  312. package/src/routes/compose.ts +0 -63
  313. package/src/ui/dash/PostForm.tsx +0 -360
  314. package/src/ui/dash/settings/AppearanceContent.tsx +0 -254
@@ -0,0 +1,799 @@
1
+ /**
2
+ * Compose Editor
3
+ *
4
+ * Format-specific content editing sub-component for the compose dialog.
5
+ * Handles note/link/quote fields, star rating, attached text panel,
6
+ * file attachments with thumbnail strip, and alt text editing.
7
+ *
8
+ * Light DOM only — BaseCoat and Tailwind classes apply directly.
9
+ */
10
+
11
+ import { LitElement, html, nothing } from "lit";
12
+ import { classMap } from "lit/directives/class-map.js";
13
+ import type {
14
+ ComposeFormat,
15
+ ComposeLabels,
16
+ ComposeAttachment,
17
+ } from "./compose-types.js";
18
+
19
+ export class JantComposeEditor extends LitElement {
20
+ static properties = {
21
+ format: { type: String },
22
+ labels: { type: Object },
23
+ _title: { state: true },
24
+ _body: { state: true },
25
+ _url: { state: true },
26
+ _quoteText: { state: true },
27
+ _quoteAuthor: { state: true },
28
+ _rating: { state: true },
29
+ _showTitle: { state: true },
30
+ _showRating: { state: true },
31
+ _attachedText: { state: true },
32
+ _showAttachedText: { state: true },
33
+ _attachments: { state: true },
34
+ _showAltPanel: { state: true },
35
+ _altPanelIndex: { state: true },
36
+ };
37
+
38
+ declare format: ComposeFormat;
39
+ declare labels: ComposeLabels;
40
+ declare _title: string;
41
+ declare _body: string;
42
+ declare _url: string;
43
+ declare _quoteText: string;
44
+ declare _quoteAuthor: string;
45
+ declare _rating: number;
46
+ declare _showTitle: boolean;
47
+ declare _showRating: boolean;
48
+ declare _attachedText: string;
49
+ declare _showAttachedText: boolean;
50
+ declare _attachments: ComposeAttachment[];
51
+ declare _showAltPanel: boolean;
52
+ declare _altPanelIndex: number;
53
+
54
+ private _fileInput: HTMLInputElement | null = null;
55
+
56
+ createRenderRoot() {
57
+ return this;
58
+ }
59
+
60
+ constructor() {
61
+ super();
62
+ this.format = "note";
63
+ this.labels = {} as ComposeLabels;
64
+ this._title = "";
65
+ this._body = "";
66
+ this._url = "";
67
+ this._quoteText = "";
68
+ this._quoteAuthor = "";
69
+ this._rating = 0;
70
+ this._showTitle = false;
71
+ this._showRating = false;
72
+ this._attachedText = "";
73
+ this._showAttachedText = false;
74
+ this._attachments = [];
75
+ this._showAltPanel = false;
76
+ this._altPanelIndex = 0;
77
+ }
78
+
79
+ getData() {
80
+ const shared = {
81
+ rating: this._rating,
82
+ attachedText: this._attachedText,
83
+ attachments: this._attachments,
84
+ };
85
+
86
+ switch (this.format) {
87
+ case "link":
88
+ return {
89
+ ...shared,
90
+ title: this._title,
91
+ body: this._body,
92
+ url: this._url,
93
+ quoteText: "",
94
+ quoteAuthor: "",
95
+ };
96
+ case "quote":
97
+ return {
98
+ ...shared,
99
+ title: "",
100
+ body: this._body,
101
+ url: this._url,
102
+ quoteText: this._quoteText,
103
+ quoteAuthor: this._quoteAuthor,
104
+ };
105
+ default:
106
+ return {
107
+ ...shared,
108
+ title: this._title,
109
+ body: this._body,
110
+ url: "",
111
+ quoteText: "",
112
+ quoteAuthor: "",
113
+ };
114
+ }
115
+ }
116
+
117
+ reset() {
118
+ this._title = "";
119
+ this._body = "";
120
+ this._url = "";
121
+ this._quoteText = "";
122
+ this._quoteAuthor = "";
123
+ this._rating = 0;
124
+ this._showTitle = false;
125
+ this._showRating = false;
126
+ this._attachedText = "";
127
+ this._showAttachedText = false;
128
+ // Revoke preview URLs before clearing
129
+ for (const a of this._attachments) {
130
+ URL.revokeObjectURL(a.previewUrl);
131
+ }
132
+ this._attachments = [];
133
+ this._showAltPanel = false;
134
+ this._altPanelIndex = 0;
135
+ }
136
+
137
+ updateAttachmentStatus(
138
+ clientId: string,
139
+ status: ComposeAttachment["status"],
140
+ mediaId: string | null,
141
+ error: string | null,
142
+ ) {
143
+ this._attachments = this._attachments.map((a) =>
144
+ a.clientId === clientId ? { ...a, status, mediaId, error } : a,
145
+ );
146
+ }
147
+
148
+ focusInput() {
149
+ const selector =
150
+ this.format === "link"
151
+ ? '.compose-input[type="url"]'
152
+ : this.format === "quote"
153
+ ? ".compose-quote-text"
154
+ : ".compose-body-input";
155
+ this.querySelector<HTMLElement>(selector)?.focus();
156
+ }
157
+
158
+ private _openAttachedText() {
159
+ this._showAttachedText = true;
160
+ this.updateComplete.then(() => {
161
+ this.querySelector<HTMLTextAreaElement>(
162
+ ".compose-attached-textarea",
163
+ )?.focus();
164
+ });
165
+ }
166
+
167
+ private _onInput(field: string, e: Event) {
168
+ const target = e.target as HTMLInputElement | HTMLTextAreaElement;
169
+ (this as Record<string, unknown>)[field] = target.value;
170
+ if (
171
+ target.tagName === "TEXTAREA" &&
172
+ !target.classList.contains("compose-attached-textarea")
173
+ ) {
174
+ this._autoResize(target as HTMLElement);
175
+ }
176
+ }
177
+
178
+ private _autoResize(el: HTMLElement) {
179
+ el.style.height = "auto";
180
+ el.style.height = `${el.scrollHeight}px`;
181
+ }
182
+
183
+ private _setRating(star: number) {
184
+ this._rating = this._rating === star ? 0 : star;
185
+ }
186
+
187
+ private _openFilePicker() {
188
+ if (!this._fileInput) {
189
+ this._fileInput = document.createElement("input");
190
+ this._fileInput.type = "file";
191
+ this._fileInput.accept = "image/*";
192
+ this._fileInput.multiple = true;
193
+ this._fileInput.style.display = "none";
194
+ this._fileInput.addEventListener("change", () =>
195
+ this._handleFilesSelected(),
196
+ );
197
+ this.appendChild(this._fileInput);
198
+ }
199
+ this._fileInput.value = "";
200
+ this._fileInput.click();
201
+ }
202
+
203
+ private _handleFilesSelected() {
204
+ if (!this._fileInput?.files?.length) return;
205
+
206
+ const newAttachments: ComposeAttachment[] = [];
207
+ const files: { file: File; clientId: string }[] = [];
208
+
209
+ for (const file of Array.from(this._fileInput.files)) {
210
+ const clientId = crypto.randomUUID();
211
+ const previewUrl = URL.createObjectURL(file);
212
+ newAttachments.push({
213
+ clientId,
214
+ file,
215
+ previewUrl,
216
+ status: "pending",
217
+ mediaId: null,
218
+ alt: "",
219
+ error: null,
220
+ });
221
+ files.push({ file, clientId });
222
+ }
223
+
224
+ this._attachments = [...this._attachments, ...newAttachments];
225
+
226
+ this.dispatchEvent(
227
+ new CustomEvent("jant:files-selected", {
228
+ bubbles: true,
229
+ detail: { files },
230
+ }),
231
+ );
232
+ }
233
+
234
+ private _removeAttachment(index: number) {
235
+ const attachment = this._attachments[index];
236
+ if (attachment) {
237
+ URL.revokeObjectURL(attachment.previewUrl);
238
+ }
239
+ this._attachments = this._attachments.filter((_, i) => i !== index);
240
+ // Close alt panel if it was showing the removed item
241
+ if (this._showAltPanel && this._altPanelIndex === index) {
242
+ this._showAltPanel = false;
243
+ } else if (this._showAltPanel && this._altPanelIndex > index) {
244
+ this._altPanelIndex = this._altPanelIndex - 1;
245
+ }
246
+ }
247
+
248
+ private _openAltPanel(index: number) {
249
+ this._altPanelIndex = index;
250
+ this._showAltPanel = true;
251
+ this.updateComplete.then(() => {
252
+ this.querySelector<HTMLTextAreaElement>(".compose-alt-textarea")?.focus();
253
+ });
254
+ }
255
+
256
+ private _closeAltPanel() {
257
+ this._showAltPanel = false;
258
+ }
259
+
260
+ private _onAltInput(e: Event) {
261
+ const value = (e.target as HTMLTextAreaElement).value;
262
+ this._attachments = this._attachments.map((a, i) =>
263
+ i === this._altPanelIndex ? { ...a, alt: value } : a,
264
+ );
265
+ }
266
+
267
+ // ── Render helpers ────────────────────────────────────────────────
268
+
269
+ private _renderNoteFields() {
270
+ return html`
271
+ <div class="compose-field-enter">
272
+ ${this._showTitle
273
+ ? html`
274
+ <div class="compose-note-title-row">
275
+ <input
276
+ type="text"
277
+ .value=${this._title}
278
+ @input=${(e: Event) => this._onInput("_title", e)}
279
+ class="compose-input compose-note-title"
280
+ placeholder=${this.labels.titlePlaceholder}
281
+ />
282
+ <button
283
+ type="button"
284
+ class="compose-note-title-dismiss"
285
+ @click=${() => {
286
+ this._showTitle = false;
287
+ }}
288
+ >
289
+
290
+ </button>
291
+ </div>
292
+ `
293
+ : nothing}
294
+ <textarea
295
+ .value=${this._body}
296
+ @input=${(e: Event) => this._onInput("_body", e)}
297
+ class="compose-input compose-body-input"
298
+ placeholder=${this.labels.bodyPlaceholder}
299
+ rows="4"
300
+ ></textarea>
301
+ </div>
302
+ `;
303
+ }
304
+
305
+ private _renderLinkFields() {
306
+ return html`
307
+ <div class="compose-field-enter">
308
+ <div class="compose-link-url-wrap">
309
+ <span class="text-base opacity-50 shrink-0">🔗</span>
310
+ <input
311
+ type="url"
312
+ .value=${this._url}
313
+ @input=${(e: Event) => this._onInput("_url", e)}
314
+ class="compose-input text-[0.9rem]"
315
+ placeholder=${this.labels.urlPlaceholder}
316
+ />
317
+ </div>
318
+ <input
319
+ type="text"
320
+ .value=${this._title}
321
+ @input=${(e: Event) => this._onInput("_title", e)}
322
+ class="compose-input compose-link-title"
323
+ placeholder=${this.labels.linkTitlePlaceholder}
324
+ />
325
+ <div class="compose-divider"></div>
326
+ <textarea
327
+ .value=${this._body}
328
+ @input=${(e: Event) => this._onInput("_body", e)}
329
+ class="compose-input compose-thoughts"
330
+ placeholder=${this.labels.thoughtsPlaceholder}
331
+ rows="3"
332
+ ></textarea>
333
+ </div>
334
+ `;
335
+ }
336
+
337
+ private _renderQuoteFields() {
338
+ return html`
339
+ <div class="compose-field-enter">
340
+ <div class="compose-quote-wrap">
341
+ <span class="compose-quote-mark">"</span>
342
+ <textarea
343
+ .value=${this._quoteText}
344
+ @input=${(e: Event) => this._onInput("_quoteText", e)}
345
+ class="compose-input compose-quote-text"
346
+ placeholder=${this.labels.quotePlaceholder}
347
+ rows="3"
348
+ ></textarea>
349
+ </div>
350
+ <div class="compose-quote-author-row">
351
+ <span class="compose-quote-dash">—</span>
352
+ <input
353
+ type="text"
354
+ .value=${this._quoteAuthor}
355
+ @input=${(e: Event) => this._onInput("_quoteAuthor", e)}
356
+ class="compose-input compose-quote-author"
357
+ placeholder=${this.labels.authorPlaceholder}
358
+ />
359
+ </div>
360
+ <div class="compose-quote-source">
361
+ <input
362
+ type="url"
363
+ .value=${this._url}
364
+ @input=${(e: Event) => this._onInput("_url", e)}
365
+ class="compose-input text-[0.78rem]"
366
+ placeholder=${this.labels.sourcePlaceholder}
367
+ />
368
+ </div>
369
+ <div class="compose-divider"></div>
370
+ <textarea
371
+ .value=${this._body}
372
+ @input=${(e: Event) => this._onInput("_body", e)}
373
+ class="compose-input compose-thoughts"
374
+ placeholder=${this.labels.thoughtsPlaceholder}
375
+ rows="2"
376
+ ></textarea>
377
+ </div>
378
+ `;
379
+ }
380
+
381
+ private _renderStarRating() {
382
+ if (!this._showRating) return nothing;
383
+ const stars = [1, 2, 3, 4, 5];
384
+ return html`
385
+ <div class="compose-star-rating">
386
+ ${stars.map(
387
+ (n) => html`
388
+ <button
389
+ type="button"
390
+ class=${classMap({
391
+ "compose-star": true,
392
+ "compose-star-filled": this._rating >= n,
393
+ })}
394
+ @click=${() => this._setRating(n)}
395
+ >
396
+
397
+ </button>
398
+ `,
399
+ )}
400
+ ${this._rating > 0
401
+ ? html`<span class="compose-star-label">${this._rating}/5</span>`
402
+ : nothing}
403
+ </div>
404
+ `;
405
+ }
406
+
407
+ private _renderAttachedBadge() {
408
+ if (this._attachedText.trim().length === 0 || this._showAttachedText)
409
+ return nothing;
410
+ return html`
411
+ <div
412
+ class="compose-attached-badge"
413
+ @click=${() => this._openAttachedText()}
414
+ >
415
+ <svg
416
+ width="14"
417
+ height="14"
418
+ viewBox="0 0 18 18"
419
+ fill="none"
420
+ stroke="currentColor"
421
+ stroke-width="1.3"
422
+ stroke-linecap="round"
423
+ class="text-muted-foreground"
424
+ >
425
+ <rect x="3" y="2" width="12" height="14" rx="2" />
426
+ <line x1="6" y1="6" x2="12" y2="6" />
427
+ <line x1="6" y1="9" x2="12" y2="9" />
428
+ <line x1="6" y1="12" x2="9.5" y2="12" />
429
+ </svg>
430
+ <span class="text-xs font-medium">${this.labels.attachedText}</span>
431
+ <span class="text-xs text-muted-foreground"
432
+ >· ${this._attachedText.length.toLocaleString()} chars</span
433
+ >
434
+ <div class="flex-1"></div>
435
+ <button
436
+ type="button"
437
+ class="compose-attached-badge-dismiss"
438
+ @click=${(e: Event) => {
439
+ e.stopPropagation();
440
+ this._attachedText = "";
441
+ }}
442
+ >
443
+
444
+ </button>
445
+ </div>
446
+ `;
447
+ }
448
+
449
+ private _renderAttachedPanel() {
450
+ if (!this._showAttachedText) return nothing;
451
+ return html`
452
+ <div class="compose-attached-panel">
453
+ <div
454
+ class="flex items-center gap-2.5 px-3 py-2.5 border-b border-border"
455
+ >
456
+ <button
457
+ type="button"
458
+ class="compose-attached-panel-back"
459
+ @click=${() => {
460
+ this._showAttachedText = false;
461
+ }}
462
+ >
463
+ <svg
464
+ width="16"
465
+ height="16"
466
+ viewBox="0 0 16 16"
467
+ fill="none"
468
+ stroke="currentColor"
469
+ stroke-width="1.5"
470
+ stroke-linecap="round"
471
+ stroke-linejoin="round"
472
+ >
473
+ <path d="M11 3L6 8l5 5" />
474
+ </svg>
475
+ </button>
476
+ <span class="text-sm font-medium tracking-tight"
477
+ >${this.labels.attachedText}</span
478
+ >
479
+ <div class="flex-1"></div>
480
+ ${this._attachedText.length > 0
481
+ ? html`<span class="text-xs text-muted-foreground tracking-wide"
482
+ >${this._attachedText.length.toLocaleString()} chars</span
483
+ >`
484
+ : nothing}
485
+ </div>
486
+ <div class="flex-1 p-4 overflow-hidden flex flex-col">
487
+ <textarea
488
+ .value=${this._attachedText}
489
+ @input=${(e: Event) => this._onInput("_attachedText", e)}
490
+ class="compose-input compose-attached-textarea"
491
+ placeholder=${this.labels.attachedTextPlaceholder}
492
+ ></textarea>
493
+ </div>
494
+ <div
495
+ class="flex items-center justify-between px-3 py-2 border-t border-border"
496
+ >
497
+ <span class="text-xs text-muted-foreground"
498
+ >${this.labels.attachedTextHint}</span
499
+ >
500
+ <button
501
+ type="button"
502
+ class="compose-post-btn"
503
+ @click=${() => {
504
+ this._showAttachedText = false;
505
+ }}
506
+ >
507
+ ${this.labels.done}
508
+ </button>
509
+ </div>
510
+ </div>
511
+ `;
512
+ }
513
+
514
+ private _renderAltPanel() {
515
+ if (!this._showAltPanel) return nothing;
516
+ const attachment = this._attachments[this._altPanelIndex];
517
+ if (!attachment) return nothing;
518
+
519
+ return html`
520
+ <div class="compose-alt-panel">
521
+ <div
522
+ class="flex items-center gap-2.5 px-3 py-2.5 border-b border-border"
523
+ >
524
+ <button
525
+ type="button"
526
+ class="compose-attached-panel-back"
527
+ @click=${() => this._closeAltPanel()}
528
+ >
529
+ <svg
530
+ width="16"
531
+ height="16"
532
+ viewBox="0 0 16 16"
533
+ fill="none"
534
+ stroke="currentColor"
535
+ stroke-width="1.5"
536
+ stroke-linecap="round"
537
+ stroke-linejoin="round"
538
+ >
539
+ <path d="M11 3L6 8l5 5" />
540
+ </svg>
541
+ </button>
542
+ <span class="text-sm font-medium tracking-tight"
543
+ >${this.labels.addAltTitle}</span
544
+ >
545
+ </div>
546
+ <div class="compose-alt-preview">
547
+ <img
548
+ src=${attachment.previewUrl}
549
+ alt=""
550
+ class="compose-alt-preview-img"
551
+ />
552
+ </div>
553
+ <div class="flex-1 p-4 overflow-hidden flex flex-col">
554
+ <textarea
555
+ .value=${attachment.alt}
556
+ @input=${(e: Event) => this._onAltInput(e)}
557
+ class="compose-input compose-alt-textarea"
558
+ placeholder=${this.labels.altPlaceholder}
559
+ rows="3"
560
+ ></textarea>
561
+ </div>
562
+ <div
563
+ class="flex items-center justify-between px-3 py-2 border-t border-border"
564
+ >
565
+ <span class="text-xs text-muted-foreground"
566
+ >${this.labels.altHint}</span
567
+ >
568
+ <button
569
+ type="button"
570
+ class="compose-post-btn"
571
+ @click=${() => this._closeAltPanel()}
572
+ >
573
+ ${this.labels.done}
574
+ </button>
575
+ </div>
576
+ </div>
577
+ `;
578
+ }
579
+
580
+ private _renderAttachments() {
581
+ if (this._attachments.length === 0) return nothing;
582
+
583
+ return html`
584
+ <div class="compose-attachments">
585
+ ${this._attachments.map(
586
+ (a, i) => html`
587
+ <div class="compose-attachment">
588
+ <div class="compose-attachment-thumb">
589
+ <img
590
+ src=${a.previewUrl}
591
+ alt=""
592
+ class="compose-attachment-img"
593
+ />
594
+ ${a.status === "pending" || a.status === "uploading"
595
+ ? html`
596
+ <div class="compose-attachment-overlay">
597
+ <svg
598
+ class="animate-spin size-4"
599
+ viewBox="0 0 24 24"
600
+ fill="none"
601
+ stroke="currentColor"
602
+ stroke-width="2.5"
603
+ stroke-linecap="round"
604
+ >
605
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
606
+ </svg>
607
+ </div>
608
+ `
609
+ : nothing}
610
+ ${a.status === "error"
611
+ ? html`
612
+ <div
613
+ class="compose-attachment-overlay compose-attachment-error"
614
+ >
615
+ <svg
616
+ width="16"
617
+ height="16"
618
+ viewBox="0 0 16 16"
619
+ fill="none"
620
+ stroke="currentColor"
621
+ stroke-width="1.5"
622
+ stroke-linecap="round"
623
+ >
624
+ <circle cx="8" cy="8" r="6" />
625
+ <path d="M10 6L6 10M6 6l4 4" />
626
+ </svg>
627
+ </div>
628
+ `
629
+ : nothing}
630
+ <button
631
+ type="button"
632
+ class="compose-attachment-remove"
633
+ @click=${() => this._removeAttachment(i)}
634
+ >
635
+
636
+ </button>
637
+ </div>
638
+ <button
639
+ type="button"
640
+ class=${classMap({
641
+ "compose-attachment-alt": true,
642
+ "compose-attachment-alt-set": a.alt.length > 0,
643
+ })}
644
+ @click=${() => this._openAltPanel(i)}
645
+ >
646
+ ${a.alt.length > 0 ? "ALT" : "+ ALT"}
647
+ </button>
648
+ </div>
649
+ `,
650
+ )}
651
+ </div>
652
+ `;
653
+ }
654
+
655
+ private _renderToolsRow() {
656
+ const hasAttached = this._attachedText.trim().length > 0;
657
+ return html`
658
+ <div class="compose-tools-row">
659
+ <!-- Media / Add -->
660
+ <button
661
+ type="button"
662
+ class=${classMap({
663
+ "compose-tool-btn": true,
664
+ "compose-tool-btn-active": this._attachments.length > 0,
665
+ })}
666
+ @click=${() => this._openFilePicker()}
667
+ >
668
+ <svg
669
+ width="18"
670
+ height="18"
671
+ viewBox="0 0 18 18"
672
+ fill="none"
673
+ stroke="currentColor"
674
+ stroke-width="1.4"
675
+ stroke-linecap="round"
676
+ stroke-linejoin="round"
677
+ >
678
+ <rect x="2" y="3" width="14" height="12" rx="2.5" />
679
+ <circle cx="6.5" cy="7.5" r="1.5" />
680
+ <path d="M2 13l4-4c.6-.6 1.4-.6 2 0l4 4" />
681
+ <path d="M11 11l1.5-1.5c.6-.6 1.4-.6 2 0L16 11" />
682
+ </svg>
683
+ <span class="compose-tool-tip"
684
+ >${this._attachments.length > 0
685
+ ? this.labels.addMore
686
+ : this.labels.media}</span
687
+ >
688
+ </button>
689
+
690
+ <!-- Attached Text -->
691
+ <button
692
+ type="button"
693
+ class=${classMap({
694
+ "compose-tool-btn": true,
695
+ "compose-tool-btn-active": hasAttached,
696
+ })}
697
+ @click=${() => this._openAttachedText()}
698
+ >
699
+ <svg
700
+ width="18"
701
+ height="18"
702
+ viewBox="0 0 18 18"
703
+ fill="none"
704
+ stroke="currentColor"
705
+ stroke-width="1.3"
706
+ stroke-linecap="round"
707
+ >
708
+ <rect x="3" y="2" width="12" height="14" rx="2" />
709
+ <line x1="6" y1="6" x2="12" y2="6" />
710
+ <line x1="6" y1="9" x2="12" y2="9" />
711
+ <line x1="6" y1="12" x2="9.5" y2="12" />
712
+ </svg>
713
+ <span class="compose-tool-tip">${this.labels.attachedText}</span>
714
+ </button>
715
+
716
+ <!-- Score -->
717
+ <button
718
+ type="button"
719
+ class=${classMap({
720
+ "compose-tool-btn": true,
721
+ "compose-tool-btn-active": this._showRating,
722
+ })}
723
+ @click=${() => {
724
+ this._showRating = !this._showRating;
725
+ }}
726
+ >
727
+ <svg
728
+ width="18"
729
+ height="18"
730
+ viewBox="0 0 18 18"
731
+ fill="none"
732
+ stroke="currentColor"
733
+ stroke-width="1.4"
734
+ stroke-linecap="round"
735
+ stroke-linejoin="round"
736
+ >
737
+ <rect x="3" y="12" width="2.8" height="3" rx="0.7" />
738
+ <rect x="7.6" y="8.5" width="2.8" height="6.5" rx="0.7" />
739
+ <rect x="12.2" y="5" width="2.8" height="10" rx="0.7" />
740
+ </svg>
741
+ <span class="compose-tool-tip">${this.labels.score}</span>
742
+ </button>
743
+
744
+ <!-- Title toggle (Note only) -->
745
+ ${this.format === "note"
746
+ ? html`
747
+ <div class="flex items-center gap-0.5">
748
+ <div class="compose-tool-sep"></div>
749
+ <button
750
+ type="button"
751
+ class=${classMap({
752
+ "compose-tool-btn": true,
753
+ "compose-tool-btn-active": this._showTitle,
754
+ })}
755
+ @click=${() => {
756
+ this._showTitle = !this._showTitle;
757
+ }}
758
+ >
759
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none">
760
+ <text
761
+ x="3.5"
762
+ y="14"
763
+ font-family="serif"
764
+ font-size="14"
765
+ font-weight="400"
766
+ fill="currentColor"
767
+ >
768
+ T
769
+ </text>
770
+ </svg>
771
+ <span class="compose-tool-tip">${this.labels.title}</span>
772
+ </button>
773
+ </div>
774
+ `
775
+ : nothing}
776
+
777
+ <div class="flex-1"></div>
778
+ </div>
779
+ `;
780
+ }
781
+
782
+ render() {
783
+ return html`
784
+ ${this._renderAttachedPanel()} ${this._renderAltPanel()}
785
+ <section class="compose-body">
786
+ ${this.format === "note"
787
+ ? this._renderNoteFields()
788
+ : this.format === "link"
789
+ ? this._renderLinkFields()
790
+ : this._renderQuoteFields()}
791
+ ${this._renderStarRating()} ${this._renderAttachedBadge()}
792
+ ${this._renderAttachments()}
793
+ </section>
794
+ ${this._renderToolsRow()}
795
+ `;
796
+ }
797
+ }
798
+
799
+ customElements.define("jant-compose-editor", JantComposeEditor);