@jant/core 0.3.27 → 0.3.29

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/bin/reset-password.js +22 -0
  2. package/dist/client/client.css +1 -0
  3. package/dist/client/client.js +31561 -0
  4. package/dist/index.js +15209 -15
  5. package/package.json +25 -15
  6. package/src/__tests__/helpers/app.ts +19 -3
  7. package/src/__tests__/helpers/db.ts +44 -0
  8. package/src/__tests__/helpers/lingui-core-macro-mock.ts +33 -0
  9. package/src/app.tsx +111 -174
  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 -267
  172. package/dist/auth.js +0 -39
  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
@@ -51,13 +51,10 @@ function resizeToSquarePng(img: HTMLImageElement, size: number): Promise<Blob> {
51
51
  ctx.drawImage(img, sx, sy, sw, sh, 0, 0, size, size);
52
52
 
53
53
  return new Promise((resolve, reject) => {
54
- canvas.toBlob(
55
- (blob) => {
56
- if (blob) resolve(blob);
57
- else reject(new Error("Failed to create PNG blob"));
58
- },
59
- "image/png",
60
- );
54
+ canvas.toBlob((blob) => {
55
+ if (blob) resolve(blob);
56
+ else reject(new Error("Failed to create PNG blob"));
57
+ }, "image/png");
61
58
  });
62
59
  }
63
60
 
@@ -84,7 +81,18 @@ async function handleAvatarUpload(
84
81
  // Load the image
85
82
  const img = await loadImage(file);
86
83
 
87
- // Generate variants in parallel
84
+ // Resize avatar to 512x512 PNG (skip for SVG — scalable and already small)
85
+ let avatarFile: File | Blob = file;
86
+ let avatarFilename = file.name;
87
+ if (file.type !== "image/svg+xml") {
88
+ const png512 = await resizeToSquarePng(img, 512);
89
+ avatarFile = new File([png512], file.name.replace(/\.[^.]+$/, ".png"), {
90
+ type: "image/png",
91
+ });
92
+ avatarFilename = (avatarFile as File).name;
93
+ }
94
+
95
+ // Generate favicon variants in parallel
88
96
  const [png16, png32, png180] = await Promise.all([
89
97
  resizeToSquarePng(img, 16),
90
98
  resizeToSquarePng(img, 32),
@@ -105,9 +113,9 @@ async function handleAvatarUpload(
105
113
  if (label)
106
114
  label.textContent = input.dataset.textUploading || "Uploading...";
107
115
 
108
- // Build FormData with original + variants
116
+ // Build FormData with resized avatar + variants
109
117
  const formData = new FormData();
110
- formData.append("file", file);
118
+ formData.append("file", avatarFile, avatarFilename);
111
119
  formData.append("favicon", icoBlob, "favicon.ico");
112
120
  formData.append("appleTouch", png180, "apple-touch-icon.png");
113
121
 
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Collection Form Bridge
3
+ *
4
+ * Handles communication between <jant-collection-form> and the server.
5
+ * Listens for `jant:collection-submit`, POSTs JSON to the endpoint, and
6
+ * redirects on success. Displays toasts on failure.
7
+ */
8
+
9
+ import type { CollectionSubmitDetail } from "../ui/components/collection-types.js";
10
+ import type { JantCollectionForm } from "../ui/components/jant-collection-form.js";
11
+ import { showToast } from "./toast.js";
12
+
13
+ document.addEventListener("jant:collection-submit", async (event: Event) => {
14
+ const customEvent = event as CustomEvent<CollectionSubmitDetail>;
15
+ const detail = customEvent.detail;
16
+ const formEl =
17
+ customEvent.target instanceof HTMLElement
18
+ ? (customEvent.target as JantCollectionForm)
19
+ : document.querySelector<JantCollectionForm>("jant-collection-form");
20
+
21
+ if (!detail?.endpoint || !formEl) return;
22
+
23
+ formEl.loading = true;
24
+
25
+ try {
26
+ const res = await fetch(detail.endpoint, {
27
+ method: "POST",
28
+ headers: {
29
+ "Content-Type": "application/json",
30
+ Accept: "application/json",
31
+ },
32
+ body: JSON.stringify(detail.data),
33
+ });
34
+
35
+ if (!res.ok) {
36
+ throw new Error(`HTTP ${res.status}`);
37
+ }
38
+
39
+ const json = await res.json();
40
+
41
+ if (json?.status === "redirect" && typeof json.url === "string") {
42
+ window.location.href = json.url;
43
+ return;
44
+ }
45
+
46
+ showToast("Saved successfully.");
47
+ } catch {
48
+ showToast("Failed to save collection. Please try again.", "error");
49
+ } finally {
50
+ formEl.loading = false;
51
+ }
52
+ });
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Collection Reorder
3
+ *
4
+ * Initializes SortableJS on the collections list in the dashboard.
5
+ * Auto-detects the list element and only activates when present.
6
+ * Sends prefixed string IDs (e.g. "c-1", "d-2") to support mixed
7
+ * collections and dividers in a unified sort order.
8
+ */
9
+
10
+ import Sortable from "sortablejs";
11
+
12
+ const list = document.getElementById("collections-list");
13
+ if (list) {
14
+ Sortable.create(list, {
15
+ animation: 150,
16
+ handle: "[data-id]",
17
+ onEnd() {
18
+ const items = [...list.querySelectorAll<HTMLElement>("[data-id]")]
19
+ .map((el) => el.dataset.id)
20
+ .filter((id): id is string => id !== undefined);
21
+ fetch("/dash/collections/reorder", {
22
+ method: "POST",
23
+ headers: { "Content-Type": "application/json" },
24
+ body: JSON.stringify({ items }),
25
+ });
26
+ },
27
+ });
28
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Compose Bridge
3
+ *
4
+ * Handles server communication between the Lit compose dialog and the server.
5
+ * Manages file uploads, deferred submit flow, and toast notifications.
6
+ */
7
+
8
+ import type { ComposeSubmitDetail } from "../ui/components/compose-types.js";
9
+ import type { ComposeAttachment } from "../ui/components/compose-types.js";
10
+ import type { JantComposeDialog } from "../ui/components/jant-compose-dialog.js";
11
+ import type { JantComposeEditor } from "../ui/components/jant-compose-editor.js";
12
+ import { ImageProcessor } from "./image-processor.js";
13
+ import {
14
+ showToast,
15
+ showPersistentToast,
16
+ replaceWithAutoClose,
17
+ } from "./toast.js";
18
+
19
+ // ── Upload manager ──────────────────────────────────────────────────
20
+
21
+ /** Track in-flight upload promises keyed by clientId */
22
+ const uploadPromises = new Map<string, Promise<string | null>>();
23
+
24
+ /**
25
+ * Upload a single file: process with ImageProcessor, then POST to /api/upload.
26
+ * Returns the mediaId on success, null on failure.
27
+ */
28
+ async function uploadFile(
29
+ file: File,
30
+ clientId: string,
31
+ editor: JantComposeEditor | null,
32
+ ): Promise<string | null> {
33
+ try {
34
+ // Update status to uploading
35
+ editor?.updateAttachmentStatus(clientId, "uploading", null, null);
36
+
37
+ // Process image (resize, convert to WebP)
38
+ const processed = await ImageProcessor.processToFile(file);
39
+
40
+ // Upload to server
41
+ const formData = new FormData();
42
+ formData.append("file", processed);
43
+
44
+ const res = await fetch("/api/upload", {
45
+ method: "POST",
46
+ body: formData,
47
+ });
48
+
49
+ if (!res.ok) {
50
+ const data = await res.json();
51
+ const error = data.error ?? "Upload failed";
52
+ editor?.updateAttachmentStatus(clientId, "error", null, error);
53
+ return null;
54
+ }
55
+
56
+ const data = await res.json();
57
+ const mediaId = data.id as string;
58
+ editor?.updateAttachmentStatus(clientId, "done", mediaId, null);
59
+ return mediaId;
60
+ } catch {
61
+ editor?.updateAttachmentStatus(clientId, "error", null, "Upload failed");
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function getEditor(): JantComposeEditor | null {
67
+ return document.querySelector("jant-compose-editor");
68
+ }
69
+
70
+ // ── File selection handler ──────────────────────────────────────────
71
+
72
+ document.addEventListener("jant:files-selected", (e: Event) => {
73
+ const event = e as CustomEvent<{
74
+ files: { file: File; clientId: string }[];
75
+ }>;
76
+ const editor = getEditor();
77
+
78
+ for (const { file, clientId } of event.detail.files) {
79
+ const promise = uploadFile(file, clientId, editor);
80
+ uploadPromises.set(clientId, promise);
81
+ promise.finally(() => uploadPromises.delete(clientId));
82
+ }
83
+ });
84
+
85
+ // ── Submit handler ──────────────────────────────────────────────────
86
+
87
+ document.addEventListener("jant:compose-submit", async (e: Event) => {
88
+ const event = e as CustomEvent<ComposeSubmitDetail>;
89
+ const detail = event.detail;
90
+ const dialog = document.getElementById(
91
+ "compose-dialog",
92
+ ) as HTMLDialogElement | null;
93
+ const composeEl = document.querySelector(
94
+ "jant-compose-dialog",
95
+ ) as JantComposeDialog | null;
96
+
97
+ if (!composeEl) return;
98
+ composeEl.loading = true;
99
+
100
+ try {
101
+ const res = await fetch("/compose", {
102
+ method: "POST",
103
+ headers: {
104
+ "Content-Type": "application/json",
105
+ Accept: "application/json",
106
+ },
107
+ body: JSON.stringify({
108
+ format: detail.format,
109
+ title: detail.title || undefined,
110
+ body: detail.body || undefined,
111
+ url: detail.url || undefined,
112
+ quoteText: detail.quoteText || undefined,
113
+ status: detail.status,
114
+ rating: detail.rating || undefined,
115
+ collectionIds:
116
+ detail.collectionIds.length > 0 ? detail.collectionIds : undefined,
117
+ mediaIds: detail.mediaIds.length > 0 ? detail.mediaIds : undefined,
118
+ mediaAlts:
119
+ Object.keys(detail.mediaAlts).length > 0
120
+ ? detail.mediaAlts
121
+ : undefined,
122
+ }),
123
+ });
124
+
125
+ if (!res.ok) {
126
+ const data = await res.json();
127
+ showToast(data.error ?? "Something went wrong", "error");
128
+ return;
129
+ }
130
+
131
+ const data = await res.json();
132
+
133
+ if (data.status === "draft") {
134
+ showToast(data.toast ?? "Draft saved.");
135
+ } else if (data.status === "published" && data.cardHtml) {
136
+ const timeline = document.getElementById("timeline-items");
137
+ if (timeline) {
138
+ document.getElementById("empty-timeline")?.remove();
139
+ timeline.insertAdjacentHTML("afterbegin", data.cardHtml);
140
+ }
141
+ }
142
+
143
+ dialog?.close();
144
+ // Prevent browser from restoring focus to the trigger button
145
+ (document.activeElement as HTMLElement)?.blur();
146
+ composeEl.reset();
147
+ } catch {
148
+ showToast("Something went wrong", "error");
149
+ } finally {
150
+ composeEl.loading = false;
151
+ }
152
+ });
153
+
154
+ // ── Deferred submit handler ─────────────────────────────────────────
155
+
156
+ interface DeferredDetail extends ComposeSubmitDetail {
157
+ pendingAttachments: ComposeAttachment[];
158
+ }
159
+
160
+ document.addEventListener("jant:compose-submit-deferred", async (e: Event) => {
161
+ const event = e as CustomEvent<DeferredDetail>;
162
+ const detail = event.detail;
163
+ const composeEl = document.querySelector(
164
+ "jant-compose-dialog",
165
+ ) as JantComposeDialog | null;
166
+
167
+ // Get labels for toast messages
168
+ const labels = composeEl?.labels;
169
+ const uploadingMsg = labels?.uploading ?? "Uploading...";
170
+ const publishedMsg = labels?.published ?? "Published!";
171
+
172
+ // Show persistent toast
173
+ showPersistentToast("compose-deferred", uploadingMsg);
174
+
175
+ try {
176
+ // Wait for all pending uploads to complete
177
+ const pendingClientIds = detail.pendingAttachments.map((a) => a.clientId);
178
+ const pendingPromises = pendingClientIds
179
+ .map((id) => uploadPromises.get(id))
180
+ .filter((p): p is Promise<string | null> => p !== undefined);
181
+
182
+ const results = await Promise.all(pendingPromises);
183
+
184
+ // Merge newly completed mediaIds with already-done ones
185
+ const newMediaIds = results.filter((id): id is string => id !== null);
186
+ const allMediaIds = [...detail.mediaIds, ...newMediaIds];
187
+
188
+ // Merge alt text: for pending attachments that just uploaded,
189
+ // map their clientId → mediaId and include their alt text
190
+ const mediaAlts = { ...detail.mediaAlts };
191
+ for (const att of detail.pendingAttachments) {
192
+ if (att.alt) {
193
+ // Find the mediaId from the upload result by matching clientId position
194
+ const idx = pendingClientIds.indexOf(att.clientId);
195
+ const mediaId = results[idx];
196
+ if (mediaId) {
197
+ mediaAlts[mediaId] = att.alt;
198
+ }
199
+ }
200
+ }
201
+
202
+ // POST to /compose
203
+ const res = await fetch("/compose", {
204
+ method: "POST",
205
+ headers: {
206
+ "Content-Type": "application/json",
207
+ Accept: "application/json",
208
+ },
209
+ body: JSON.stringify({
210
+ format: detail.format,
211
+ title: detail.title || undefined,
212
+ body: detail.body || undefined,
213
+ url: detail.url || undefined,
214
+ quoteText: detail.quoteText || undefined,
215
+ status: detail.status,
216
+ rating: detail.rating || undefined,
217
+ collectionIds:
218
+ detail.collectionIds.length > 0 ? detail.collectionIds : undefined,
219
+ mediaIds: allMediaIds.length > 0 ? allMediaIds : undefined,
220
+ mediaAlts: Object.keys(mediaAlts).length > 0 ? mediaAlts : undefined,
221
+ }),
222
+ });
223
+
224
+ if (!res.ok) {
225
+ const data = await res.json();
226
+ replaceWithAutoClose(
227
+ "compose-deferred",
228
+ data.error ?? "Something went wrong",
229
+ "error",
230
+ );
231
+ return;
232
+ }
233
+
234
+ const data = await res.json();
235
+
236
+ if (data.status === "published" && data.cardHtml) {
237
+ const timeline = document.getElementById("timeline-items");
238
+ if (timeline) {
239
+ document.getElementById("empty-timeline")?.remove();
240
+ timeline.insertAdjacentHTML("afterbegin", data.cardHtml);
241
+ }
242
+ }
243
+
244
+ replaceWithAutoClose(
245
+ "compose-deferred",
246
+ data.status === "draft" ? (data.toast ?? "Draft saved.") : publishedMsg,
247
+ );
248
+ } catch {
249
+ replaceWithAutoClose("compose-deferred", "Something went wrong", "error");
250
+ }
251
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Domain Error Classes
3
+ *
4
+ * Typed errors per coding-standards.md error taxonomy.
5
+ * Services throw these; the error handler middleware maps them to HTTP responses.
6
+ */
7
+
8
+ /**
9
+ * Base class for all domain errors.
10
+ * Each subclass maps to a specific HTTP status code.
11
+ */
12
+ export class DomainError extends Error {
13
+ constructor(
14
+ message: string,
15
+ public readonly statusCode: number,
16
+ public readonly code: string,
17
+ ) {
18
+ super(message);
19
+ this.name = this.constructor.name;
20
+ }
21
+ }
22
+
23
+ /** Invalid input — 400 */
24
+ export class ValidationError extends DomainError {
25
+ constructor(
26
+ message: string,
27
+ public readonly details?: unknown,
28
+ ) {
29
+ super(message, 400, "VALIDATION_ERROR");
30
+ }
31
+ }
32
+
33
+ /** Not authenticated — 401 */
34
+ export class UnauthorizedError extends DomainError {
35
+ constructor(message = "Unauthorized") {
36
+ super(message, 401, "UNAUTHORIZED");
37
+ }
38
+ }
39
+
40
+ /** Authenticated but not allowed — 403 */
41
+ export class ForbiddenError extends DomainError {
42
+ constructor(message = "Forbidden") {
43
+ super(message, 403, "FORBIDDEN");
44
+ }
45
+ }
46
+
47
+ /** Resource doesn't exist — 404 */
48
+ export class NotFoundError extends DomainError {
49
+ constructor(resource = "Resource") {
50
+ super(`${resource} not found`, 404, "NOT_FOUND");
51
+ }
52
+ }
53
+
54
+ /** State conflict (e.g. duplicate) — 409 */
55
+ export class ConflictError extends DomainError {
56
+ constructor(message: string) {
57
+ super(message, 409, "CONFLICT");
58
+ }
59
+ }
60
+
61
+ /** Too many requests — 429 */
62
+ export class RateLimitError extends DomainError {
63
+ constructor(message = "Too many requests") {
64
+ super(message, 429, "RATE_LIMIT");
65
+ }
66
+ }
67
+
68
+ /** Third-party failure — 500 */
69
+ export class ExternalServiceError extends DomainError {
70
+ constructor(message: string) {
71
+ super(message, 500, "EXTERNAL_SERVICE_ERROR");
72
+ }
73
+ }
74
+
75
+ // =============================================================================
76
+ // Route Helpers
77
+ // =============================================================================
78
+
79
+ /**
80
+ * Asserts a value is not null/undefined, throwing NotFoundError if it is.
81
+ *
82
+ * @param value - The value to check
83
+ * @param resource - Resource name for the error message
84
+ * @returns The non-null value
85
+ * @example
86
+ * ```ts
87
+ * const post = assertFound(await services.posts.getById(id), "Post");
88
+ * ```
89
+ */
90
+ export function assertFound<T>(
91
+ value: T | null | undefined,
92
+ resource: string,
93
+ ): T {
94
+ if (value == null) {
95
+ throw new NotFoundError(resource);
96
+ }
97
+ return value;
98
+ }
99
+
100
+ /**
101
+ * Parse a route parameter as a positive integer, throwing ValidationError if invalid.
102
+ *
103
+ * @param value - Raw string parameter from the route
104
+ * @returns Parsed integer
105
+ * @example
106
+ * ```ts
107
+ * const id = parseIntParam(c.req.param("id"));
108
+ * ```
109
+ */
110
+ export function parseIntParam(value: string): number {
111
+ const id = parseInt(value, 10);
112
+ if (isNaN(id) || id < 1) {
113
+ throw new ValidationError("Invalid ID");
114
+ }
115
+ return id;
116
+ }
@@ -61,7 +61,7 @@ export interface HtmlExcerpt {
61
61
  export function getHtmlExcerpt(bodyHtml: string): HtmlExcerpt {
62
62
  // Honor manual <!--more--> marker
63
63
  if (bodyHtml.includes("<!--more-->")) {
64
- const excerpt = bodyHtml.split("<!--more-->")[0]!;
64
+ const excerpt = bodyHtml.split("<!--more-->")[0] ?? "";
65
65
  return { excerpt, hasMore: true };
66
66
  }
67
67
 
@@ -34,9 +34,7 @@ export const FAVICON_SIZES = {
34
34
  * ]);
35
35
  * ```
36
36
  */
37
- export function encodeIco(
38
- entries: { size: number; png: ArrayBuffer }[],
39
- ): Blob {
37
+ export function encodeIco(entries: { size: number; png: ArrayBuffer }[]): Blob {
40
38
  const headerSize = 6;
41
39
  const dirEntrySize = 16;
42
40
  const dirSize = entries.length * dirEntrySize;
@@ -54,7 +52,7 @@ export function encodeIco(
54
52
 
55
53
  const pngBuffers: ArrayBuffer[] = [];
56
54
  for (let i = 0; i < entries.length; i++) {
57
- const entry = entries[i]!;
55
+ const entry = entries[i] as (typeof entries)[number];
58
56
  const offset = headerSize + i * dirEntrySize;
59
57
 
60
58
  // Width/height: 0 means 256
@@ -89,7 +87,7 @@ export function arrayBufferToBase64(buffer: ArrayBuffer): string {
89
87
  const bytes = new Uint8Array(buffer);
90
88
  let binary = "";
91
89
  for (let i = 0; i < bytes.byteLength; i++) {
92
- binary += String.fromCharCode(bytes[i]!);
90
+ binary += String.fromCharCode(bytes[i] as number);
93
91
  }
94
92
  return btoa(binary);
95
93
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * HTML Utilities
3
+ */
4
+
5
+ /**
6
+ * Escape HTML special characters for safe insertion into HTML strings.
7
+ *
8
+ * @param str - The string to escape
9
+ * @returns The escaped string
10
+ * @example
11
+ * ```ts
12
+ * escapeHtml('<script>alert("xss")</script>')
13
+ * // '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;'
14
+ * ```
15
+ */
16
+ export function escapeHtml(str: string): string {
17
+ return str
18
+ .replace(/&/g, "&amp;")
19
+ .replace(/</g, "&lt;")
20
+ .replace(/>/g, "&gt;")
21
+ .replace(/"/g, "&quot;");
22
+ }