@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,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
+ });