@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,272 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, it, expect, beforeEach } from "vitest";
4
+ import type { ComposeLabels } from "../compose-types.js";
5
+ import "../jant-compose-editor.js";
6
+ import type { JantComposeEditor } from "../jant-compose-editor.js";
7
+
8
+ function requireElement<T extends globalThis.Element>(
9
+ element: T | null,
10
+ message: string,
11
+ ): T {
12
+ if (!element) {
13
+ throw new Error(message);
14
+ }
15
+ return element;
16
+ }
17
+
18
+ function requireItem<T extends globalThis.Element>(
19
+ collection: globalThis.NodeListOf<T>,
20
+ index: number,
21
+ message: string,
22
+ ): T {
23
+ const item = collection.item(index);
24
+ if (!item) {
25
+ throw new Error(message);
26
+ }
27
+ return item;
28
+ }
29
+
30
+ const labels: ComposeLabels = {
31
+ cancel: "Cancel",
32
+ note: "Note",
33
+ link: "Link",
34
+ quote: "Quote",
35
+ saveDraft: "Save as Draft",
36
+ saveAsDraft: "Save as draft",
37
+ discard: "Discard",
38
+ titlePlaceholder: "Title",
39
+ bodyPlaceholder: "What's on your mind...",
40
+ urlPlaceholder: "Paste a URL...",
41
+ linkTitlePlaceholder: "Give it a title...",
42
+ thoughtsPlaceholder: "Your thoughts (optional)",
43
+ quotePlaceholder: "Type the quote...",
44
+ authorPlaceholder: "Author (optional)",
45
+ sourcePlaceholder: "Source link (optional)",
46
+ attachedText: "Attached Text",
47
+ attachedTextPlaceholder: "Paste text...",
48
+ attachedTextHint: "Supplementary content",
49
+ done: "Done",
50
+ media: "Media",
51
+ score: "Score",
52
+ title: "Title",
53
+ collection: "Collection",
54
+ searchCollections: "Search...",
55
+ noCollections: "No collections found.",
56
+ post: "Post",
57
+ addAlt: "+ ALT",
58
+ addAltTitle: "Add alt text",
59
+ altPlaceholder: "Describe this...",
60
+ altHint: "Alt text improves accessibility",
61
+ addMore: "Add",
62
+ uploading: "Uploading...",
63
+ published: "Published!",
64
+ };
65
+
66
+ async function createElement(
67
+ format: string = "note",
68
+ ): Promise<JantComposeEditor> {
69
+ const el = document.createElement("jant-compose-editor") as JantComposeEditor;
70
+ el.format = format as "note" | "link" | "quote";
71
+ el.labels = labels;
72
+ document.body.appendChild(el);
73
+ await el.updateComplete;
74
+ return el;
75
+ }
76
+
77
+ describe("JantComposeEditor", () => {
78
+ beforeEach(() => {
79
+ document.body.innerHTML = "";
80
+ });
81
+
82
+ it("renders note fields by default", async () => {
83
+ const el = await createElement("note");
84
+ const textarea = requireElement(
85
+ el.querySelector<HTMLTextAreaElement>(".compose-body-input"),
86
+ "expected compose body textarea",
87
+ );
88
+ expect(textarea.placeholder).toBe("What's on your mind...");
89
+ });
90
+
91
+ it("renders link fields when format is link", async () => {
92
+ const el = await createElement("link");
93
+ const urlInput = requireElement(
94
+ el.querySelector<HTMLInputElement>('input[type="url"]'),
95
+ "expected url input",
96
+ );
97
+ expect(urlInput.placeholder).toBe("Paste a URL...");
98
+
99
+ const titleInput = el.querySelector<HTMLInputElement>(
100
+ ".compose-link-title",
101
+ );
102
+ expect(titleInput).not.toBeNull();
103
+ });
104
+
105
+ it("renders quote fields when format is quote", async () => {
106
+ const el = await createElement("quote");
107
+ const quoteTextarea = el.querySelector<HTMLTextAreaElement>(
108
+ ".compose-quote-text",
109
+ );
110
+ expect(quoteTextarea).not.toBeNull();
111
+
112
+ const authorInput = el.querySelector<HTMLInputElement>(
113
+ ".compose-quote-author",
114
+ );
115
+ expect(authorInput).not.toBeNull();
116
+ });
117
+
118
+ it("toggles star rating visibility", async () => {
119
+ const el = await createElement("note");
120
+
121
+ // Rating not visible initially
122
+ expect(el.querySelector(".compose-star-rating")).toBeNull();
123
+
124
+ // Click score button to show rating
125
+ const toolButtons =
126
+ el.querySelectorAll<HTMLButtonElement>(".compose-tool-btn");
127
+ const scoreBtnEl = requireItem(
128
+ toolButtons,
129
+ 2,
130
+ "expected score tool button",
131
+ );
132
+ scoreBtnEl.click();
133
+ await el.updateComplete;
134
+
135
+ expect(el.querySelector(".compose-star-rating")).not.toBeNull();
136
+ });
137
+
138
+ it("sets rating on star click and deselects on same star", async () => {
139
+ const el = await createElement("note");
140
+ el._showRating = true;
141
+ await el.updateComplete;
142
+
143
+ const stars = el.querySelectorAll<HTMLButtonElement>(".compose-star");
144
+ expect(stars.length).toBe(5);
145
+
146
+ // Click third star
147
+ stars[2].click();
148
+ await el.updateComplete;
149
+ expect(el._rating).toBe(3);
150
+
151
+ // Rating label shows
152
+ const label = el.querySelector(".compose-star-label");
153
+ expect(label?.textContent).toContain("3/5");
154
+
155
+ // Click third star again to deselect
156
+ stars[2].click();
157
+ await el.updateComplete;
158
+ expect(el._rating).toBe(0);
159
+ });
160
+
161
+ it("toggles attached text panel", async () => {
162
+ const el = await createElement("note");
163
+
164
+ // Click attached text tool button
165
+ const toolBtns =
166
+ el.querySelectorAll<HTMLButtonElement>(".compose-tool-btn");
167
+ const attachedBtn = requireItem(
168
+ toolBtns,
169
+ 1,
170
+ "expected attached text button",
171
+ );
172
+ attachedBtn.click();
173
+ await el.updateComplete;
174
+
175
+ expect(el.querySelector(".compose-attached-panel")).not.toBeNull();
176
+
177
+ // Click done button to close
178
+ const doneBtn = el.querySelector<HTMLButtonElement>(
179
+ ".compose-attached-panel .compose-post-btn",
180
+ );
181
+ doneBtn?.click();
182
+ await el.updateComplete;
183
+
184
+ expect(el.querySelector(".compose-attached-panel")).toBeNull();
185
+ });
186
+
187
+ it("shows title toggle only in note mode", async () => {
188
+ const el = await createElement("note");
189
+ const toolSep = el.querySelector(".compose-tool-sep");
190
+ expect(toolSep).not.toBeNull();
191
+
192
+ el.format = "link";
193
+ await el.updateComplete;
194
+ expect(el.querySelector(".compose-tool-sep")).toBeNull();
195
+ });
196
+
197
+ it("getData returns current field values", async () => {
198
+ const el = await createElement("note");
199
+ el._title = "Test Title";
200
+ el._body = "Test Body";
201
+ el._rating = 4;
202
+ el._attachedText = "Some attached text";
203
+
204
+ const data = el.getData();
205
+ expect(data.title).toBe("Test Title");
206
+ expect(data.body).toBe("Test Body");
207
+ expect(data.rating).toBe(4);
208
+ expect(data.attachedText).toBe("Some attached text");
209
+ expect(data.url).toBe("");
210
+ expect(data.quoteText).toBe("");
211
+ expect(data.quoteAuthor).toBe("");
212
+ });
213
+
214
+ it("reset clears all fields", async () => {
215
+ const el = await createElement("note");
216
+ el._title = "Test";
217
+ el._body = "Body";
218
+ el._rating = 3;
219
+ el._showRating = true;
220
+ el._attachedText = "text";
221
+ el._showAttachedText = true;
222
+
223
+ el.reset();
224
+
225
+ expect(el._title).toBe("");
226
+ expect(el._body).toBe("");
227
+ expect(el._rating).toBe(0);
228
+ expect(el._showRating).toBe(false);
229
+ expect(el._attachedText).toBe("");
230
+ expect(el._showAttachedText).toBe(false);
231
+ });
232
+
233
+ it("shows attached text badge when text is present", async () => {
234
+ const el = await createElement("note");
235
+ el._attachedText = "Some content here";
236
+ await el.updateComplete;
237
+
238
+ const badge = el.querySelector(".compose-attached-badge");
239
+ expect(badge).not.toBeNull();
240
+ expect(badge?.textContent).toContain("chars");
241
+ });
242
+
243
+ it("media button highlights when attachments are present", async () => {
244
+ const el = await createElement("note");
245
+
246
+ // Media button should not be active initially
247
+ const mediaBtn = el.querySelector<HTMLButtonElement>(".compose-tool-btn");
248
+ expect(mediaBtn?.classList.contains("compose-tool-btn-active")).toBe(false);
249
+
250
+ // Add an attachment
251
+ const blob = new Blob(["fake"], { type: "image/png" });
252
+ const file = new File([blob], "test.png", { type: "image/png" });
253
+ el._attachments = [
254
+ {
255
+ clientId: "test-1",
256
+ file,
257
+ previewUrl: URL.createObjectURL(blob),
258
+ status: "done",
259
+ mediaId: "m1",
260
+ alt: "",
261
+ error: null,
262
+ },
263
+ ];
264
+ await el.updateComplete;
265
+
266
+ const mediaBtnAfter =
267
+ el.querySelector<HTMLButtonElement>(".compose-tool-btn");
268
+ expect(mediaBtnAfter?.classList.contains("compose-tool-btn-active")).toBe(
269
+ true,
270
+ );
271
+ });
272
+ });
@@ -0,0 +1,172 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, it, expect, beforeEach } from "vitest";
4
+ import type {
5
+ PostFormLabels,
6
+ PostFormInitial,
7
+ PostCollectionOption,
8
+ PostMediaItem,
9
+ PostSubmitDetail,
10
+ } from "../post-form-types.js";
11
+ import "../jant-post-form.js";
12
+ import type { JantPostForm } from "../jant-post-form.js";
13
+
14
+ const labels: PostFormLabels = {
15
+ formatLabel: "Format",
16
+ noteOption: "Note",
17
+ linkOption: "Link",
18
+ quoteOption: "Quote",
19
+ titleLabel: "Title",
20
+ titlePlaceholder: "Title...",
21
+ bodyLabel: "Body",
22
+ bodyPlaceholder: "Body...",
23
+ urlLabel: "URL",
24
+ urlPlaceholder: "https://example.com",
25
+ quoteTextLabel: "Quote Text",
26
+ quoteTextPlaceholder: "Quote...",
27
+ mediaLabel: "Media",
28
+ mediaAddButton: "Add Media",
29
+ mediaRemoveButton: "Remove",
30
+ mediaEmptyLabel: "No media",
31
+ statusLabel: "Status",
32
+ statusPublished: "Published",
33
+ statusDraft: "Draft",
34
+ featuredLabel: "Featured",
35
+ pinnedLabel: "Pinned",
36
+ collectionsLabel: "Collections",
37
+ submitLabel: "Publish",
38
+ cancelLabel: "Cancel",
39
+ mediaDialogTitle: "Select Media",
40
+ mediaDialogDone: "Done",
41
+ mediaDialogLoading: "Loading...",
42
+ submitSuccessMessage: "Saved!",
43
+ submitErrorMessage: "Failed.",
44
+ };
45
+
46
+ const initial: PostFormInitial = {
47
+ format: "note",
48
+ title: "",
49
+ body: "",
50
+ url: "",
51
+ quoteText: "",
52
+ status: "published",
53
+ featured: false,
54
+ pinned: false,
55
+ rating: 0,
56
+ collectionIds: [],
57
+ mediaIds: [],
58
+ };
59
+
60
+ const collections: PostCollectionOption[] = [
61
+ { id: 1, title: "General", icon: null },
62
+ { id: 2, title: "Favorites", icon: "★" },
63
+ ];
64
+
65
+ const media: PostMediaItem[] = [
66
+ { id: "m1", thumbUrl: "https://cdn.example.com/m1.jpg", alt: "Media 1" },
67
+ ];
68
+
69
+ async function createElement(
70
+ overrides: Partial<JantPostForm> = {},
71
+ ): Promise<JantPostForm> {
72
+ const el = document.createElement("jant-post-form") as JantPostForm;
73
+ el.labels = { ...labels };
74
+ el.initial = { ...initial };
75
+ el.collections = [...collections];
76
+ el.media = [...media];
77
+ el.action = "/dash/posts";
78
+ Object.assign(el, overrides);
79
+ document.body.appendChild(el);
80
+ await el.updateComplete;
81
+ return el;
82
+ }
83
+
84
+ describe("JantPostForm", () => {
85
+ beforeEach(() => {
86
+ document.body.innerHTML = "";
87
+ });
88
+
89
+ it("renders base fields and labels", async () => {
90
+ const el = await createElement();
91
+ const select = el.querySelector("select.select");
92
+ expect(select).not.toBeNull();
93
+ const label = el.querySelector(".field .label");
94
+ expect(label?.textContent?.trim()).toBe("Format");
95
+ const submit = el.querySelector<HTMLButtonElement>("button[type=submit]");
96
+ expect(submit?.textContent?.trim()).toContain("Publish");
97
+ });
98
+
99
+ it("shows quote textarea when format set to quote", async () => {
100
+ const el = await createElement({
101
+ initial: { ...initial, format: "quote" },
102
+ });
103
+ await el.updateComplete;
104
+ const textarea = el.querySelector<HTMLTextAreaElement>(
105
+ "textarea[placeholder='Quote...']",
106
+ );
107
+ expect(textarea).not.toBeNull();
108
+ });
109
+
110
+ it("dispatches jant:post-submit with form data", async () => {
111
+ const el = await createElement();
112
+ const form = el.querySelector("form");
113
+ expect(form).not.toBeNull();
114
+ if (!form) throw new Error("Form element not found");
115
+
116
+ const titleInput = el.querySelector<HTMLInputElement>("input.input");
117
+ expect(titleInput).not.toBeNull();
118
+ if (!titleInput) throw new Error("Title input not found");
119
+ titleInput.value = "Sample Post";
120
+ titleInput.dispatchEvent(new Event("input", { bubbles: true }));
121
+
122
+ const bodyTextarea =
123
+ el.querySelector<HTMLTextAreaElement>("textarea.textarea");
124
+ expect(bodyTextarea).not.toBeNull();
125
+ if (!bodyTextarea) throw new Error("Body textarea not found");
126
+ bodyTextarea.value = "Hello world";
127
+ bodyTextarea.dispatchEvent(new Event("input", { bubbles: true }));
128
+
129
+ const checkboxList =
130
+ el.querySelectorAll<HTMLInputElement>("input.checkbox");
131
+ expect(checkboxList.length).toBeGreaterThan(0);
132
+ const checkbox = checkboxList[0];
133
+ checkbox.checked = true;
134
+ checkbox.dispatchEvent(new Event("change", { bubbles: true }));
135
+
136
+ const collectionCheckbox = checkboxList.item(2);
137
+ expect(collectionCheckbox).not.toBeNull();
138
+ if (!collectionCheckbox) throw new Error("Collection checkbox missing");
139
+ collectionCheckbox.checked = true;
140
+ collectionCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
141
+
142
+ el.mediaIds = ["m1"];
143
+
144
+ let detail: PostSubmitDetail | null = null;
145
+ el.addEventListener("jant:post-submit", (event) => {
146
+ detail = (event as CustomEvent<PostSubmitDetail>).detail;
147
+ });
148
+
149
+ form.dispatchEvent(
150
+ new Event("submit", { bubbles: true, cancelable: true }),
151
+ );
152
+
153
+ expect(detail).not.toBeNull();
154
+ const d = detail as unknown as PostSubmitDetail;
155
+ expect(d.endpoint).toBe("/dash/posts");
156
+ expect(d.data.title).toBe("Sample Post");
157
+ expect(d.data.body).toBe("Hello world");
158
+ expect(d.data.featured).toBe(true);
159
+ expect(d.data.collectionIds).toEqual([collections[0].id]);
160
+ expect(d.data.mediaIds).toEqual(["m1"]);
161
+ });
162
+
163
+ it("updates mediaIds when setter called", async () => {
164
+ const el = await createElement();
165
+ el.mediaIds = ["m1"];
166
+ await el.updateComplete;
167
+
168
+ const items = el.querySelectorAll("[data-media-id]");
169
+ expect(items.length).toBe(1);
170
+ expect(items[0].getAttribute("data-media-id")).toBe("m1");
171
+ });
172
+ });
@@ -0,0 +1,235 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { describe, it, expect, beforeEach } from "vitest";
4
+ import type {
5
+ AvatarRemoveDetail,
6
+ SettingsLabels,
7
+ SettingsSaveDetail,
8
+ } from "../settings-types.js";
9
+ import "../jant-settings-avatar.js";
10
+ import type { JantSettingsAvatar } from "../jant-settings-avatar.js";
11
+
12
+ function requireElement<T extends globalThis.Element>(
13
+ element: T | null,
14
+ message: string,
15
+ ): T {
16
+ if (!element) {
17
+ throw new Error(message);
18
+ }
19
+ return element;
20
+ }
21
+
22
+ const labels: SettingsLabels = {
23
+ blogAvatar: "Blog Avatar",
24
+ uploadAvatar: "Upload Avatar",
25
+ remove: "Remove",
26
+ avatarHelp: "For best results, upload a square image.",
27
+ displayInHeader: "Display avatar in my site header",
28
+ processing: "Processing...",
29
+ uploading: "Uploading...",
30
+ uploadError: "Upload failed.",
31
+ general: "General",
32
+ siteName: "Site Name",
33
+ aboutBlog: "About this blog",
34
+ aboutBlogHelp: "Displayed above your blog posts.",
35
+ language: "Language",
36
+ defaultHomepageView: "Default Homepage View",
37
+ latest: "Latest",
38
+ featured: "Featured",
39
+ timeZone: "Time Zone",
40
+ siteFooter: "Site Footer",
41
+ markdownSupported: "Markdown supported",
42
+ footerHelp: "Displayed at the bottom of posts.",
43
+ seo: "SEO",
44
+ allowIndexing: "It's OK for search engines to index my site",
45
+ save: "Save",
46
+ cancel: "Cancel",
47
+ };
48
+
49
+ async function createElement(
50
+ avatarUrl = "",
51
+ showInHeader = false,
52
+ ): Promise<JantSettingsAvatar> {
53
+ const el = document.createElement(
54
+ "jant-settings-avatar",
55
+ ) as JantSettingsAvatar;
56
+ el.avatarUrl = avatarUrl;
57
+ el.showInHeader = showInHeader;
58
+ el.labels = labels;
59
+ document.body.appendChild(el);
60
+ await el.updateComplete;
61
+ return el;
62
+ }
63
+
64
+ /** Toggle a checkbox reliably in happy-dom by explicitly dispatching change */
65
+ function toggleCheckbox(checkbox: HTMLInputElement) {
66
+ checkbox.checked = !checkbox.checked;
67
+ checkbox.dispatchEvent(new Event("change", { bubbles: true }));
68
+ }
69
+
70
+ /** Find the save button (not the upload label which also has .btn) */
71
+ function findSaveBtn(el: HTMLElement): HTMLButtonElement | null {
72
+ return el.querySelector<HTMLButtonElement>("button.btn:not(.btn-outline)");
73
+ }
74
+
75
+ /** Find the cancel button */
76
+ function findCancelBtn(el: HTMLElement): HTMLButtonElement | null {
77
+ return el.querySelector<HTMLButtonElement>("button.btn-outline");
78
+ }
79
+
80
+ describe("JantSettingsAvatar", () => {
81
+ beforeEach(() => {
82
+ document.body.innerHTML = "";
83
+ });
84
+
85
+ it("renders card with Blog Avatar heading", async () => {
86
+ const el = await createElement();
87
+ const heading = el.querySelector("h2");
88
+ expect(heading?.textContent).toBe("Blog Avatar");
89
+ });
90
+
91
+ it("renders placeholder when no avatar URL", async () => {
92
+ const el = await createElement();
93
+ const img = el.querySelector("img");
94
+ expect(img).toBeNull();
95
+ const placeholder = el.querySelector(".bg-muted");
96
+ expect(placeholder).not.toBeNull();
97
+ });
98
+
99
+ it("renders avatar image when URL provided", async () => {
100
+ const el = await createElement("https://example.com/avatar.png");
101
+ const img = el.querySelector("img");
102
+ expect(img).not.toBeNull();
103
+ expect(img?.src).toBe("https://example.com/avatar.png");
104
+ });
105
+
106
+ it("shows Remove button when avatar exists", async () => {
107
+ const el = await createElement("https://example.com/avatar.png");
108
+ const buttons = el.querySelectorAll("button");
109
+ const removeBtn = Array.from(buttons).find((b) =>
110
+ b.textContent?.includes("Remove"),
111
+ );
112
+ expect(removeBtn).not.toBeNull();
113
+ });
114
+
115
+ it("hides Remove button when no avatar", async () => {
116
+ const el = await createElement();
117
+ const buttons = el.querySelectorAll("button");
118
+ const removeBtn = Array.from(buttons).find((b) =>
119
+ b.textContent?.includes("Remove"),
120
+ );
121
+ expect(removeBtn).toBeUndefined();
122
+ });
123
+
124
+ it("renders display-in-header checkbox with correct state", async () => {
125
+ const el = await createElement("", true);
126
+ const checkbox = el.querySelector<HTMLInputElement>(
127
+ 'input[type="checkbox"]',
128
+ );
129
+ expect(checkbox?.checked).toBe(true);
130
+ });
131
+
132
+ it("toggling checkbox marks form as dirty", async () => {
133
+ const el = await createElement("", false);
134
+ const checkbox = requireElement(
135
+ el.querySelector<HTMLInputElement>('input[type="checkbox"]'),
136
+ "expected header display checkbox",
137
+ );
138
+
139
+ toggleCheckbox(checkbox);
140
+ await el.updateComplete;
141
+
142
+ const saveBtn = findSaveBtn(el);
143
+ expect(saveBtn?.disabled).toBe(false);
144
+ });
145
+
146
+ it("cancel reverts checkbox to original state", async () => {
147
+ const el = await createElement("", false);
148
+ const checkbox = requireElement(
149
+ el.querySelector<HTMLInputElement>('input[type="checkbox"]'),
150
+ "expected header display checkbox",
151
+ );
152
+
153
+ toggleCheckbox(checkbox);
154
+ await el.updateComplete;
155
+
156
+ const cancelBtn = findCancelBtn(el);
157
+ cancelBtn?.click();
158
+ await el.updateComplete;
159
+
160
+ expect(checkbox.checked).toBe(false);
161
+ });
162
+
163
+ it("dispatches jant:settings-save on save click", async () => {
164
+ const el = await createElement("", false);
165
+ const checkbox = requireElement(
166
+ el.querySelector<HTMLInputElement>('input[type="checkbox"]'),
167
+ "expected header display checkbox",
168
+ );
169
+
170
+ toggleCheckbox(checkbox);
171
+ await el.updateComplete;
172
+
173
+ let detail: SettingsSaveDetail | null = null;
174
+ el.addEventListener("jant:settings-save", (event) => {
175
+ const customEvent = event as CustomEvent<SettingsSaveDetail>;
176
+ detail = customEvent.detail;
177
+ });
178
+
179
+ const saveBtn = findSaveBtn(el);
180
+ saveBtn?.click();
181
+ await el.updateComplete;
182
+
183
+ expect(detail).not.toBeNull();
184
+ const d = detail as unknown as SettingsSaveDetail;
185
+ expect(d.endpoint).toBe("/dash/settings/avatar/display");
186
+ expect(d.section).toBe("avatar-display");
187
+ expect(d.data.showHeaderAvatar).toBe("true");
188
+ });
189
+
190
+ it("dispatches jant:avatar-remove on remove click", async () => {
191
+ const el = await createElement("https://example.com/avatar.png");
192
+
193
+ let detail: AvatarRemoveDetail | null = null;
194
+ el.addEventListener("jant:avatar-remove", (event) => {
195
+ const customEvent = event as CustomEvent<AvatarRemoveDetail>;
196
+ detail = customEvent.detail;
197
+ });
198
+
199
+ const buttons = el.querySelectorAll("button");
200
+ const removeBtn = Array.from(buttons).find((b) =>
201
+ b.textContent?.includes("Remove"),
202
+ );
203
+ removeBtn?.click();
204
+
205
+ expect(detail).not.toBeNull();
206
+ const d = detail as unknown as AvatarRemoveDetail;
207
+ expect(d.endpoint).toBe("/dash/settings/avatar/remove");
208
+ });
209
+
210
+ it("saved() resets dirty state", async () => {
211
+ const el = await createElement("", false);
212
+ const checkbox = requireElement(
213
+ el.querySelector<HTMLInputElement>('input[type="checkbox"]'),
214
+ "expected header display checkbox",
215
+ );
216
+
217
+ toggleCheckbox(checkbox);
218
+ await el.updateComplete;
219
+
220
+ el.saved();
221
+ await el.updateComplete;
222
+
223
+ const saveBtn = findSaveBtn(el);
224
+ expect(saveBtn?.disabled).toBe(true);
225
+ });
226
+
227
+ it("renders file input with data-avatar-upload attribute", async () => {
228
+ const el = await createElement();
229
+ const fileInput = el.querySelector<HTMLInputElement>(
230
+ "input[data-avatar-upload]",
231
+ );
232
+ expect(fileInput).not.toBeNull();
233
+ expect(fileInput?.type).toBe("file");
234
+ });
235
+ });