@jant/core 0.3.27 → 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 (313) 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 +111 -174
  9. package/src/client.ts +13 -0
  10. package/src/db/migrations/0007_post_collections_m2m.sql +94 -0
  11. package/src/db/migrations/0008_add_collection_dividers.sql +8 -0
  12. package/src/db/migrations/0009_drop_collection_show_divider.sql +2 -0
  13. package/src/db/migrations/0010_add_performance_indexes.sql +16 -0
  14. package/src/db/schema.ts +24 -4
  15. package/src/i18n/locales/en.po +810 -385
  16. package/src/i18n/locales/en.ts +1 -1
  17. package/src/i18n/locales/zh-Hans.po +733 -522
  18. package/src/i18n/locales/zh-Hans.ts +1 -1
  19. package/src/i18n/locales/zh-Hant.po +733 -522
  20. package/src/i18n/locales/zh-Hant.ts +1 -1
  21. package/src/i18n/middleware.ts +7 -11
  22. package/src/index.ts +1 -1
  23. package/src/lib/__tests__/icons.test.ts +178 -0
  24. package/src/lib/__tests__/resolve-config.test.ts +184 -0
  25. package/src/lib/__tests__/schemas.test.ts +12 -6
  26. package/src/lib/__tests__/theme.test.ts +62 -0
  27. package/src/lib/__tests__/timezones.test.ts +1 -1
  28. package/src/lib/__tests__/url.test.ts +12 -0
  29. package/src/lib/__tests__/view.test.ts +1 -5
  30. package/src/lib/avatar-upload.ts +18 -10
  31. package/src/lib/collection-form-bridge.ts +52 -0
  32. package/src/lib/collections-reorder.ts +28 -0
  33. package/src/lib/compose-bridge.ts +251 -0
  34. package/src/lib/errors.ts +116 -0
  35. package/src/lib/excerpt.ts +1 -1
  36. package/src/lib/favicon.ts +3 -5
  37. package/src/lib/html.ts +22 -0
  38. package/src/lib/icon-catalog.ts +181 -0
  39. package/src/lib/icons.ts +202 -0
  40. package/src/lib/navigation.ts +18 -33
  41. package/src/lib/pagination.ts +3 -2
  42. package/src/lib/post-form-bridge.ts +136 -0
  43. package/src/lib/render.tsx +11 -4
  44. package/src/lib/resolve-config.ts +157 -0
  45. package/src/lib/schemas.ts +76 -12
  46. package/src/lib/settings-bridge.ts +139 -0
  47. package/src/lib/storage.ts +37 -16
  48. package/src/lib/theme.ts +5 -7
  49. package/src/lib/timeline.ts +4 -8
  50. package/src/lib/toast.ts +134 -0
  51. package/src/lib/upload.ts +71 -0
  52. package/src/lib/url.ts +9 -1
  53. package/src/lib/version.ts +16 -0
  54. package/src/lib/view.ts +9 -10
  55. package/src/middleware/__tests__/auth.test.ts +6 -28
  56. package/src/middleware/__tests__/onboarding.test.ts +1 -1
  57. package/src/middleware/auth.ts +6 -12
  58. package/src/middleware/config.ts +51 -0
  59. package/src/middleware/error-handler.ts +56 -0
  60. package/src/middleware/onboarding.ts +1 -1
  61. package/src/preset.css +6 -0
  62. package/src/routes/__tests__/compose.test.ts +104 -17
  63. package/src/routes/api/__tests__/collections.test.ts +93 -2
  64. package/src/routes/api/__tests__/posts.test.ts +2 -1
  65. package/src/routes/api/__tests__/settings.test.ts +1 -1
  66. package/src/routes/api/collections.ts +64 -68
  67. package/src/routes/api/nav-items.ts +21 -59
  68. package/src/routes/api/pages.ts +18 -46
  69. package/src/routes/api/posts.ts +64 -86
  70. package/src/routes/api/search.ts +6 -4
  71. package/src/routes/api/settings.ts +8 -24
  72. package/src/routes/api/upload.ts +55 -53
  73. package/src/routes/auth/__tests__/setup.test.ts +118 -0
  74. package/src/routes/auth/reset.tsx +17 -66
  75. package/src/routes/auth/setup.tsx +67 -11
  76. package/src/routes/auth/signin.tsx +44 -8
  77. package/src/routes/compose.tsx +194 -0
  78. package/src/routes/dash/__tests__/font-theme.test.ts +110 -0
  79. package/src/routes/dash/__tests__/pages.test.ts +2 -2
  80. package/src/routes/dash/__tests__/settings-avatar.test.ts +23 -12
  81. package/src/routes/dash/appearance.tsx +173 -0
  82. package/src/routes/dash/collections.tsx +80 -14
  83. package/src/routes/dash/index.tsx +12 -14
  84. package/src/routes/dash/media.tsx +46 -49
  85. package/src/routes/dash/pages.tsx +85 -37
  86. package/src/routes/dash/posts.tsx +60 -23
  87. package/src/routes/dash/redirects.tsx +43 -33
  88. package/src/routes/dash/settings.tsx +234 -214
  89. package/src/routes/feed/__tests__/rss.test.ts +7 -3
  90. package/src/routes/feed/rss.ts +11 -16
  91. package/src/routes/feed/sitemap.ts +15 -9
  92. package/src/routes/pages/__tests__/collections.test.ts +9 -8
  93. package/src/routes/pages/archive.tsx +2 -2
  94. package/src/routes/pages/collection.tsx +76 -9
  95. package/src/routes/pages/collections.tsx +3 -1
  96. package/src/routes/pages/featured.tsx +2 -2
  97. package/src/routes/pages/home.tsx +3 -3
  98. package/src/routes/pages/latest.tsx +2 -2
  99. package/src/routes/pages/page.tsx +2 -2
  100. package/src/routes/pages/post.tsx +2 -2
  101. package/src/routes/pages/search.tsx +2 -2
  102. package/src/services/__tests__/collection.test.ts +324 -34
  103. package/src/services/__tests__/media.test.ts +1 -1
  104. package/src/services/__tests__/page.test.ts +116 -1
  105. package/src/services/auth.ts +88 -0
  106. package/src/services/collection.ts +169 -30
  107. package/src/services/index.ts +8 -3
  108. package/src/services/media.ts +39 -12
  109. package/src/services/navigation.ts +17 -5
  110. package/src/services/page.ts +24 -4
  111. package/src/services/post.ts +87 -19
  112. package/src/services/search.ts +0 -1
  113. package/src/services/settings.ts +21 -13
  114. package/src/style.css +3 -0
  115. package/src/styles/components.css +42 -1
  116. package/src/styles/tokens.css +4 -0
  117. package/src/styles/ui.css +902 -73
  118. package/src/types/app-context.ts +25 -0
  119. package/src/types/bindings.ts +1 -0
  120. package/src/types/config.ts +60 -23
  121. package/src/types/entities.ts +12 -2
  122. package/src/types/lingui-react-macro.d.ts +3 -3
  123. package/src/types/operations.ts +2 -4
  124. package/src/types/views.ts +1 -3
  125. package/src/ui/__tests__/font-themes.test.ts +27 -8
  126. package/src/ui/color-themes.ts +1 -1
  127. package/src/ui/components/__tests__/jant-collection-form.test.ts +153 -0
  128. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +512 -0
  129. package/src/ui/components/__tests__/jant-compose-editor.test.ts +272 -0
  130. package/src/ui/components/__tests__/jant-post-form.test.ts +172 -0
  131. package/src/ui/components/__tests__/jant-settings-avatar.test.ts +235 -0
  132. package/src/ui/components/__tests__/jant-settings-general.test.ts +319 -0
  133. package/src/ui/components/collection-types.ts +45 -0
  134. package/src/ui/components/compose-types.ts +75 -0
  135. package/src/ui/components/jant-collection-form.ts +512 -0
  136. package/src/ui/components/jant-compose-dialog.ts +494 -0
  137. package/src/ui/components/jant-compose-editor.ts +799 -0
  138. package/src/ui/components/jant-post-form.ts +290 -0
  139. package/src/ui/components/jant-settings-avatar.ts +231 -0
  140. package/src/ui/components/jant-settings-general.ts +436 -0
  141. package/src/ui/components/post-form-template.ts +260 -0
  142. package/src/ui/components/post-form-types.ts +87 -0
  143. package/src/ui/components/settings-types.ts +62 -0
  144. package/src/ui/compose/ComposeDialog.tsx +141 -385
  145. package/src/ui/compose/ComposePrompt.tsx +3 -3
  146. package/src/ui/dash/PostList.tsx +55 -61
  147. package/src/ui/dash/appearance/AdvancedContent.tsx +80 -0
  148. package/src/ui/dash/appearance/AppearanceNav.tsx +56 -0
  149. package/src/ui/dash/appearance/ColorThemeContent.tsx +129 -0
  150. package/src/ui/dash/appearance/FontThemeContent.tsx +98 -0
  151. package/src/ui/dash/collections/CollectionForm.tsx +130 -117
  152. package/src/ui/dash/collections/CollectionsListContent.tsx +102 -41
  153. package/src/ui/dash/collections/IconPickerGrid.tsx +50 -0
  154. package/src/ui/dash/collections/ViewCollectionContent.tsx +14 -3
  155. package/src/ui/dash/index.ts +1 -1
  156. package/src/ui/dash/posts/PostForm.tsx +248 -0
  157. package/src/ui/dash/settings/AccountContent.tsx +69 -80
  158. package/src/ui/dash/settings/GeneralContent.tsx +159 -478
  159. package/src/ui/dash/settings/SettingsNav.tsx +4 -4
  160. package/src/ui/font-themes.ts +115 -32
  161. package/src/ui/layouts/BaseLayout.tsx +49 -19
  162. package/src/ui/layouts/DashLayout.tsx +14 -9
  163. package/src/ui/layouts/SiteLayout.tsx +38 -23
  164. package/src/ui/pages/CollectionPage.tsx +12 -2
  165. package/src/ui/pages/CollectionsPage.tsx +27 -27
  166. package/src/ui/pages/HomePage.tsx +15 -6
  167. package/src/ui/pages/SearchPage.tsx +1 -2
  168. package/src/ui/shared/CollectionsSidebar.tsx +59 -0
  169. package/src/ui/shared/Pagination.tsx +2 -2
  170. package/dist/app.js +0 -267
  171. package/dist/auth.js +0 -39
  172. package/dist/client.js +0 -13
  173. package/dist/db/index.js +0 -10
  174. package/dist/db/schema.js +0 -224
  175. package/dist/i18n/Trans.js +0 -24
  176. package/dist/i18n/context.js +0 -58
  177. package/dist/i18n/detect.js +0 -26
  178. package/dist/i18n/i18n.js +0 -49
  179. package/dist/i18n/index.js +0 -44
  180. package/dist/i18n/locales/en.js +0 -1
  181. package/dist/i18n/locales/zh-Hans.js +0 -1
  182. package/dist/i18n/locales/zh-Hant.js +0 -1
  183. package/dist/i18n/locales.js +0 -13
  184. package/dist/i18n/middleware.js +0 -30
  185. package/dist/lib/avatar-upload.js +0 -134
  186. package/dist/lib/config.js +0 -143
  187. package/dist/lib/constants.js +0 -50
  188. package/dist/lib/excerpt.js +0 -76
  189. package/dist/lib/favicon.js +0 -102
  190. package/dist/lib/feed.js +0 -123
  191. package/dist/lib/image-processor.js +0 -187
  192. package/dist/lib/image.js +0 -97
  193. package/dist/lib/index.js +0 -7
  194. package/dist/lib/markdown.js +0 -83
  195. package/dist/lib/media-helpers.js +0 -49
  196. package/dist/lib/media-upload.js +0 -104
  197. package/dist/lib/nav-reorder.js +0 -27
  198. package/dist/lib/navigation.js +0 -79
  199. package/dist/lib/pagination.js +0 -44
  200. package/dist/lib/render.js +0 -53
  201. package/dist/lib/schemas.js +0 -174
  202. package/dist/lib/sqid.js +0 -72
  203. package/dist/lib/sse.js +0 -218
  204. package/dist/lib/storage.js +0 -164
  205. package/dist/lib/theme.js +0 -65
  206. package/dist/lib/time.js +0 -159
  207. package/dist/lib/timeline.js +0 -95
  208. package/dist/lib/timezones.js +0 -388
  209. package/dist/lib/url.js +0 -89
  210. package/dist/lib/view.js +0 -217
  211. package/dist/middleware/auth.js +0 -52
  212. package/dist/middleware/onboarding.js +0 -41
  213. package/dist/routes/api/collections.js +0 -124
  214. package/dist/routes/api/nav-items.js +0 -104
  215. package/dist/routes/api/pages.js +0 -91
  216. package/dist/routes/api/posts.js +0 -218
  217. package/dist/routes/api/search.js +0 -48
  218. package/dist/routes/api/settings.js +0 -68
  219. package/dist/routes/api/upload.js +0 -246
  220. package/dist/routes/auth/reset.js +0 -221
  221. package/dist/routes/auth/setup.js +0 -194
  222. package/dist/routes/auth/signin.js +0 -176
  223. package/dist/routes/compose.js +0 -48
  224. package/dist/routes/dash/collections.js +0 -115
  225. package/dist/routes/dash/index.js +0 -118
  226. package/dist/routes/dash/media.js +0 -106
  227. package/dist/routes/dash/pages.js +0 -294
  228. package/dist/routes/dash/posts.js +0 -244
  229. package/dist/routes/dash/redirects.js +0 -257
  230. package/dist/routes/dash/settings.js +0 -379
  231. package/dist/routes/feed/rss.js +0 -62
  232. package/dist/routes/feed/sitemap.js +0 -49
  233. package/dist/routes/pages/archive.js +0 -62
  234. package/dist/routes/pages/collection.js +0 -34
  235. package/dist/routes/pages/collections.js +0 -28
  236. package/dist/routes/pages/featured.js +0 -36
  237. package/dist/routes/pages/home.js +0 -64
  238. package/dist/routes/pages/latest.js +0 -45
  239. package/dist/routes/pages/page.js +0 -68
  240. package/dist/routes/pages/post.js +0 -44
  241. package/dist/routes/pages/search.js +0 -54
  242. package/dist/services/collection.js +0 -109
  243. package/dist/services/index.js +0 -24
  244. package/dist/services/media.js +0 -117
  245. package/dist/services/navigation.js +0 -91
  246. package/dist/services/page.js +0 -84
  247. package/dist/services/post.js +0 -229
  248. package/dist/services/redirect.js +0 -48
  249. package/dist/services/search.js +0 -67
  250. package/dist/services/settings.js +0 -68
  251. package/dist/types/bindings.js +0 -3
  252. package/dist/types/config.js +0 -147
  253. package/dist/types/constants.js +0 -27
  254. package/dist/types/entities.js +0 -3
  255. package/dist/types/lingui-react-macro.d.js +0 -9
  256. package/dist/types/operations.js +0 -3
  257. package/dist/types/props.js +0 -3
  258. package/dist/types/sortablejs.d.js +0 -5
  259. package/dist/types/views.js +0 -5
  260. package/dist/types.js +0 -11
  261. package/dist/ui/color-themes.js +0 -268
  262. package/dist/ui/compose/ComposeDialog.js +0 -467
  263. package/dist/ui/compose/ComposePrompt.js +0 -55
  264. package/dist/ui/dash/ActionButtons.js +0 -46
  265. package/dist/ui/dash/CrudPageHeader.js +0 -22
  266. package/dist/ui/dash/DangerZone.js +0 -36
  267. package/dist/ui/dash/FormatBadge.js +0 -27
  268. package/dist/ui/dash/ListItemRow.js +0 -21
  269. package/dist/ui/dash/PageForm.js +0 -195
  270. package/dist/ui/dash/PostForm.js +0 -395
  271. package/dist/ui/dash/PostList.js +0 -83
  272. package/dist/ui/dash/StatusBadge.js +0 -46
  273. package/dist/ui/dash/collections/CollectionForm.js +0 -152
  274. package/dist/ui/dash/collections/CollectionsListContent.js +0 -68
  275. package/dist/ui/dash/collections/ViewCollectionContent.js +0 -96
  276. package/dist/ui/dash/index.js +0 -10
  277. package/dist/ui/dash/media/MediaListContent.js +0 -166
  278. package/dist/ui/dash/media/ViewMediaContent.js +0 -212
  279. package/dist/ui/dash/pages/LinkFormContent.js +0 -130
  280. package/dist/ui/dash/pages/UnifiedPagesContent.js +0 -193
  281. package/dist/ui/dash/settings/AccountContent.js +0 -209
  282. package/dist/ui/dash/settings/AppearanceContent.js +0 -259
  283. package/dist/ui/dash/settings/GeneralContent.js +0 -536
  284. package/dist/ui/dash/settings/SettingsNav.js +0 -41
  285. package/dist/ui/feed/LinkCard.js +0 -72
  286. package/dist/ui/feed/NoteCard.js +0 -58
  287. package/dist/ui/feed/QuoteCard.js +0 -63
  288. package/dist/ui/feed/ThreadPreview.js +0 -48
  289. package/dist/ui/feed/TimelineFeed.js +0 -41
  290. package/dist/ui/feed/TimelineItem.js +0 -27
  291. package/dist/ui/font-themes.js +0 -36
  292. package/dist/ui/layouts/BaseLayout.js +0 -153
  293. package/dist/ui/layouts/DashLayout.js +0 -141
  294. package/dist/ui/layouts/SiteLayout.js +0 -169
  295. package/dist/ui/pages/ArchivePage.js +0 -143
  296. package/dist/ui/pages/CollectionPage.js +0 -70
  297. package/dist/ui/pages/CollectionsPage.js +0 -76
  298. package/dist/ui/pages/FeaturedPage.js +0 -24
  299. package/dist/ui/pages/HomePage.js +0 -24
  300. package/dist/ui/pages/PostPage.js +0 -55
  301. package/dist/ui/pages/SearchPage.js +0 -122
  302. package/dist/ui/pages/SinglePage.js +0 -23
  303. package/dist/ui/shared/EmptyState.js +0 -27
  304. package/dist/ui/shared/MediaGallery.js +0 -35
  305. package/dist/ui/shared/Pagination.js +0 -195
  306. package/dist/ui/shared/ThreadView.js +0 -108
  307. package/dist/ui/shared/index.js +0 -5
  308. package/dist/vendor/datastar.js +0 -1606
  309. package/src/lib/__tests__/config.test.ts +0 -192
  310. package/src/lib/config.ts +0 -167
  311. package/src/routes/compose.ts +0 -63
  312. package/src/ui/dash/PostForm.tsx +0 -360
  313. 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);