@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
@@ -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
+ }