@btst/stack 2.6.2 → 2.8.0

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 (309) hide show
  1. package/README.md +1 -0
  2. package/dist/api/index.d.cts +2 -2
  3. package/dist/api/index.d.mts +2 -2
  4. package/dist/api/index.d.ts +2 -2
  5. package/dist/client/index.d.cts +2 -2
  6. package/dist/client/index.d.mts +2 -2
  7. package/dist/client/index.d.ts +2 -2
  8. package/dist/components/auto-form/index.d.cts +2 -2
  9. package/dist/components/auto-form/index.d.mts +2 -2
  10. package/dist/components/auto-form/index.d.ts +2 -2
  11. package/dist/components/form-builder/index.d.cts +1 -1
  12. package/dist/components/form-builder/index.d.mts +1 -1
  13. package/dist/components/form-builder/index.d.ts +1 -1
  14. package/dist/components/stepped-auto-form/index.d.cts +1 -1
  15. package/dist/components/stepped-auto-form/index.d.mts +1 -1
  16. package/dist/components/stepped-auto-form/index.d.ts +1 -1
  17. package/dist/index.d.cts +1 -1
  18. package/dist/index.d.mts +1 -1
  19. package/dist/index.d.ts +1 -1
  20. package/dist/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.cjs +13 -0
  21. package/dist/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.mjs +11 -0
  22. package/dist/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.cjs +17 -0
  23. package/dist/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.mjs +15 -0
  24. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -7
  25. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -7
  26. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-navigation.cjs +48 -52
  27. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-navigation.mjs +49 -53
  28. package/dist/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.cjs +34 -37
  29. package/dist/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.mjs +35 -38
  30. package/dist/packages/stack/src/plugins/blog/client/hooks/blog-hooks.cjs +4 -21
  31. package/dist/packages/stack/src/plugins/blog/client/hooks/blog-hooks.mjs +4 -21
  32. package/dist/packages/stack/src/plugins/comments/api/getters.cjs +284 -0
  33. package/dist/packages/stack/src/plugins/comments/api/getters.mjs +280 -0
  34. package/dist/packages/stack/src/plugins/comments/api/mutations.cjs +118 -0
  35. package/dist/packages/stack/src/plugins/comments/api/mutations.mjs +112 -0
  36. package/dist/packages/stack/src/plugins/comments/api/plugin.cjs +335 -0
  37. package/dist/packages/stack/src/plugins/comments/api/plugin.mjs +333 -0
  38. package/dist/packages/stack/src/plugins/comments/api/query-key-defs.cjs +60 -0
  39. package/dist/packages/stack/src/plugins/comments/api/query-key-defs.mjs +55 -0
  40. package/dist/packages/stack/src/plugins/comments/api/serializers.cjs +23 -0
  41. package/dist/packages/stack/src/plugins/comments/api/serializers.mjs +21 -0
  42. package/dist/packages/stack/src/plugins/comments/client/components/comment-count.cjs +46 -0
  43. package/dist/packages/stack/src/plugins/comments/client/components/comment-count.mjs +44 -0
  44. package/dist/packages/stack/src/plugins/comments/client/components/comment-form.cjs +86 -0
  45. package/dist/packages/stack/src/plugins/comments/client/components/comment-form.mjs +84 -0
  46. package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.cjs +540 -0
  47. package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.mjs +538 -0
  48. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.cjs +64 -0
  49. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.cjs +426 -0
  50. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.mjs +424 -0
  51. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.mjs +62 -0
  52. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.cjs +66 -0
  53. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.cjs +256 -0
  54. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.mjs +254 -0
  55. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.mjs +64 -0
  56. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.cjs +86 -0
  57. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.cjs +191 -0
  58. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.mjs +189 -0
  59. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.mjs +84 -0
  60. package/dist/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.cjs +27 -0
  61. package/dist/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.mjs +25 -0
  62. package/dist/packages/stack/src/plugins/comments/client/components/shared/pagination.cjs +37 -0
  63. package/dist/packages/stack/src/plugins/comments/client/components/shared/pagination.mjs +35 -0
  64. package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.cjs +476 -0
  65. package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.mjs +464 -0
  66. package/dist/packages/stack/src/plugins/comments/client/localization/comments-moderation.cjs +67 -0
  67. package/dist/packages/stack/src/plugins/comments/client/localization/comments-moderation.mjs +65 -0
  68. package/dist/packages/stack/src/plugins/comments/client/localization/comments-my.cjs +27 -0
  69. package/dist/packages/stack/src/plugins/comments/client/localization/comments-my.mjs +25 -0
  70. package/dist/packages/stack/src/plugins/comments/client/localization/comments-thread.cjs +30 -0
  71. package/dist/packages/stack/src/plugins/comments/client/localization/comments-thread.mjs +28 -0
  72. package/dist/packages/stack/src/plugins/comments/client/localization/index.cjs +13 -0
  73. package/dist/packages/stack/src/plugins/comments/client/localization/index.mjs +11 -0
  74. package/dist/packages/stack/src/plugins/comments/client/plugin.cjs +116 -0
  75. package/dist/packages/stack/src/plugins/comments/client/plugin.mjs +114 -0
  76. package/dist/packages/stack/src/plugins/comments/client/utils.cjs +41 -0
  77. package/dist/packages/stack/src/plugins/comments/client/utils.mjs +37 -0
  78. package/dist/packages/stack/src/plugins/comments/db.cjs +75 -0
  79. package/dist/packages/stack/src/plugins/comments/db.mjs +73 -0
  80. package/dist/packages/stack/src/plugins/comments/schemas.cjs +45 -0
  81. package/dist/packages/stack/src/plugins/comments/schemas.mjs +38 -0
  82. package/dist/packages/stack/src/plugins/kanban/api/plugin.cjs +5 -4
  83. package/dist/packages/stack/src/plugins/kanban/api/plugin.mjs +5 -4
  84. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +0 -1
  85. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +0 -1
  86. package/dist/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.cjs +39 -22
  87. package/dist/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.mjs +40 -23
  88. package/dist/packages/ui/src/components/avatar.mjs +1 -1
  89. package/dist/packages/ui/src/components/pagination-controls.cjs +64 -0
  90. package/dist/packages/ui/src/components/pagination-controls.mjs +62 -0
  91. package/dist/packages/ui/src/components/when-visible.cjs +39 -0
  92. package/dist/packages/ui/src/components/when-visible.mjs +37 -0
  93. package/dist/plugins/ai-chat/api/index.d.cts +4 -6
  94. package/dist/plugins/ai-chat/api/index.d.mts +4 -6
  95. package/dist/plugins/ai-chat/api/index.d.ts +4 -6
  96. package/dist/plugins/ai-chat/client/hooks/index.d.cts +1 -3
  97. package/dist/plugins/ai-chat/client/hooks/index.d.mts +1 -3
  98. package/dist/plugins/ai-chat/client/hooks/index.d.ts +1 -3
  99. package/dist/plugins/ai-chat/query-keys.d.cts +1 -3
  100. package/dist/plugins/ai-chat/query-keys.d.mts +1 -3
  101. package/dist/plugins/ai-chat/query-keys.d.ts +1 -3
  102. package/dist/plugins/api/index.d.cts +3 -3
  103. package/dist/plugins/api/index.d.mts +3 -3
  104. package/dist/plugins/api/index.d.ts +3 -3
  105. package/dist/plugins/blog/api/index.d.cts +3 -3
  106. package/dist/plugins/blog/api/index.d.mts +3 -3
  107. package/dist/plugins/blog/api/index.d.ts +3 -3
  108. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  109. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  110. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  111. package/dist/plugins/blog/client/index.d.cts +25 -3
  112. package/dist/plugins/blog/client/index.d.mts +25 -3
  113. package/dist/plugins/blog/client/index.d.ts +25 -3
  114. package/dist/plugins/blog/query-keys.d.cts +3 -3
  115. package/dist/plugins/blog/query-keys.d.mts +3 -3
  116. package/dist/plugins/blog/query-keys.d.ts +3 -3
  117. package/dist/plugins/client/index.d.cts +2 -2
  118. package/dist/plugins/client/index.d.mts +2 -2
  119. package/dist/plugins/client/index.d.ts +2 -2
  120. package/dist/plugins/cms/api/index.d.cts +1 -1
  121. package/dist/plugins/cms/api/index.d.mts +1 -1
  122. package/dist/plugins/cms/api/index.d.ts +1 -1
  123. package/dist/plugins/cms/client/index.d.cts +1 -1
  124. package/dist/plugins/cms/client/index.d.mts +1 -1
  125. package/dist/plugins/cms/client/index.d.ts +1 -1
  126. package/dist/plugins/cms/query-keys.d.cts +1 -1
  127. package/dist/plugins/cms/query-keys.d.mts +1 -1
  128. package/dist/plugins/cms/query-keys.d.ts +1 -1
  129. package/dist/plugins/comments/api/index.cjs +21 -0
  130. package/dist/plugins/comments/api/index.d.cts +126 -0
  131. package/dist/plugins/comments/api/index.d.mts +126 -0
  132. package/dist/plugins/comments/api/index.d.ts +126 -0
  133. package/dist/plugins/comments/api/index.mjs +5 -0
  134. package/dist/plugins/comments/client/components/index.cjs +15 -0
  135. package/dist/plugins/comments/client/components/index.d.cts +125 -0
  136. package/dist/plugins/comments/client/components/index.d.mts +125 -0
  137. package/dist/plugins/comments/client/components/index.d.ts +125 -0
  138. package/dist/plugins/comments/client/components/index.mjs +5 -0
  139. package/dist/plugins/comments/client/hooks/index.cjs +17 -0
  140. package/dist/plugins/comments/client/hooks/index.d.cts +200 -0
  141. package/dist/plugins/comments/client/hooks/index.d.mts +200 -0
  142. package/dist/plugins/comments/client/hooks/index.d.ts +200 -0
  143. package/dist/plugins/comments/client/hooks/index.mjs +1 -0
  144. package/dist/plugins/comments/client/index.cjs +9 -0
  145. package/dist/plugins/comments/client/index.d.cts +262 -0
  146. package/dist/plugins/comments/client/index.d.mts +262 -0
  147. package/dist/plugins/comments/client/index.d.ts +262 -0
  148. package/dist/plugins/comments/client/index.mjs +2 -0
  149. package/dist/plugins/comments/client.css +2 -0
  150. package/dist/plugins/comments/query-keys.cjs +113 -0
  151. package/dist/plugins/comments/query-keys.d.cts +71 -0
  152. package/dist/plugins/comments/query-keys.d.mts +71 -0
  153. package/dist/plugins/comments/query-keys.d.ts +71 -0
  154. package/dist/plugins/comments/query-keys.mjs +111 -0
  155. package/dist/plugins/comments/style.css +15 -0
  156. package/dist/plugins/form-builder/api/index.d.cts +2 -2
  157. package/dist/plugins/form-builder/api/index.d.mts +2 -2
  158. package/dist/plugins/form-builder/api/index.d.ts +2 -2
  159. package/dist/plugins/form-builder/client/components/index.d.cts +1 -1
  160. package/dist/plugins/form-builder/client/components/index.d.mts +1 -1
  161. package/dist/plugins/form-builder/client/components/index.d.ts +1 -1
  162. package/dist/plugins/form-builder/client/index.d.cts +1 -1
  163. package/dist/plugins/form-builder/client/index.d.mts +1 -1
  164. package/dist/plugins/form-builder/client/index.d.ts +1 -1
  165. package/dist/plugins/form-builder/query-keys.d.cts +1 -1
  166. package/dist/plugins/form-builder/query-keys.d.mts +1 -1
  167. package/dist/plugins/form-builder/query-keys.d.ts +1 -1
  168. package/dist/plugins/kanban/api/index.d.cts +2 -2
  169. package/dist/plugins/kanban/api/index.d.mts +2 -2
  170. package/dist/plugins/kanban/api/index.d.ts +2 -2
  171. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  172. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  173. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  174. package/dist/plugins/kanban/client/index.d.cts +1 -1
  175. package/dist/plugins/kanban/client/index.d.mts +1 -1
  176. package/dist/plugins/kanban/client/index.d.ts +1 -1
  177. package/dist/plugins/kanban/query-keys.d.cts +2 -2
  178. package/dist/plugins/kanban/query-keys.d.mts +2 -2
  179. package/dist/plugins/kanban/query-keys.d.ts +2 -2
  180. package/dist/plugins/open-api/api/index.d.cts +3 -3
  181. package/dist/plugins/open-api/api/index.d.mts +3 -3
  182. package/dist/plugins/open-api/api/index.d.ts +3 -3
  183. package/dist/plugins/route-docs/client/index.d.cts +1 -1
  184. package/dist/plugins/route-docs/client/index.d.mts +1 -1
  185. package/dist/plugins/route-docs/client/index.d.ts +1 -1
  186. package/dist/plugins/ui-builder/client/components/index.d.cts +2 -2
  187. package/dist/plugins/ui-builder/client/components/index.d.mts +2 -2
  188. package/dist/plugins/ui-builder/client/components/index.d.ts +2 -2
  189. package/dist/plugins/ui-builder/client/hooks/index.d.cts +3 -3
  190. package/dist/plugins/ui-builder/client/hooks/index.d.mts +3 -3
  191. package/dist/plugins/ui-builder/client/hooks/index.d.ts +3 -3
  192. package/dist/plugins/ui-builder/client/index.d.cts +3 -3
  193. package/dist/plugins/ui-builder/client/index.d.mts +3 -3
  194. package/dist/plugins/ui-builder/client/index.d.ts +3 -3
  195. package/dist/plugins/ui-builder/index.d.cts +3 -3
  196. package/dist/plugins/ui-builder/index.d.mts +3 -3
  197. package/dist/plugins/ui-builder/index.d.ts +3 -3
  198. package/dist/shared/{stack.B1srlBud.d.mts → stack.BFoBvGML.d.mts} +1 -1
  199. package/dist/shared/{stack.DmpPDPxA.d.cts → stack.BOCvd9HK.d.cts} +1 -1
  200. package/dist/shared/{stack.n1_i1p2B.d.cts → stack.BOokfhZD.d.cts} +170 -110
  201. package/dist/shared/{stack.DXnclTG7.d.ts → stack.BSqJrCTM.d.cts} +120 -59
  202. package/dist/shared/{stack.B58oHdqm.d.mts → stack.BX7MHi0J.d.mts} +90 -45
  203. package/dist/shared/{stack.cfCkioTe.d.mts → stack.BXxrFL9R.d.ts} +120 -59
  204. package/dist/shared/{stack.CSx98K5H.d.cts → stack.BYN8wCV6.d.cts} +87 -58
  205. package/dist/shared/{stack.FVWf2JhZ.d.mts → stack.BgQrdSlo.d.mts} +60 -45
  206. package/dist/shared/{stack.BK9Z2dcL.d.ts → stack.BmMB0LNC.d.ts} +1 -1
  207. package/dist/shared/{stack.j75TpKh2.d.ts → stack.BvCR4-9H.d.ts} +170 -110
  208. package/dist/shared/{stack.FeaWkglm.d.ts → stack.BxFl46lB.d.cts} +24 -1
  209. package/dist/shared/stack.C-b3Sn8j.d.cts +142 -0
  210. package/dist/shared/stack.C-b3Sn8j.d.mts +142 -0
  211. package/dist/shared/stack.C-b3Sn8j.d.ts +142 -0
  212. package/dist/shared/{stack.CFECM0ew.d.cts → stack.C1nXGBr6.d.cts} +1 -1
  213. package/dist/shared/{stack.C9Mg2Q46.d.cts → stack.C9zoS1TN.d.cts} +90 -45
  214. package/dist/shared/stack.CJE9sAjV.d.ts +335 -0
  215. package/dist/shared/{stack.fdi94T4S.d.mts → stack.CPsYC2-Z.d.cts} +7 -7
  216. package/dist/shared/{stack.fdi94T4S.d.ts → stack.CPsYC2-Z.d.mts} +7 -7
  217. package/dist/shared/{stack.fdi94T4S.d.cts → stack.CPsYC2-Z.d.ts} +7 -7
  218. package/dist/shared/{stack.7n9Y_u7N.d.cts → stack.CQnwAN7x.d.cts} +6 -6
  219. package/dist/shared/{stack.7n9Y_u7N.d.mts → stack.CQnwAN7x.d.mts} +6 -6
  220. package/dist/shared/{stack.7n9Y_u7N.d.ts → stack.CQnwAN7x.d.ts} +6 -6
  221. package/dist/shared/{stack.CxaFNQCV.d.mts → stack.CWxAl9K3.d.mts} +170 -110
  222. package/dist/shared/{stack.D-b5zbPm.d.cts → stack.Cbsrl06u.d.cts} +60 -45
  223. package/dist/shared/stack.CmHRdhl8.d.cts +335 -0
  224. package/dist/shared/{stack.BgTmujxW.d.mts → stack.D88yU4FT.d.mts} +87 -58
  225. package/dist/shared/{stack.DVtk5CNw.d.mts → stack.DLPa6Gzm.d.mts} +1 -1
  226. package/dist/shared/{stack.BAT540yW.d.ts → stack.DOZ1EXjM.d.mts} +9 -15
  227. package/dist/shared/{stack.FeaWkglm.d.mts → stack.DRpeDS6X.d.ts} +24 -1
  228. package/dist/shared/{stack.B8vT-Yt4.d.mts → stack.DX-tQ93o.d.cts} +9 -15
  229. package/dist/shared/stack.Dcz6636A.d.mts +335 -0
  230. package/dist/shared/{stack.ASwEoINr.d.ts → stack.DxJ-tHLt.d.ts} +1 -1
  231. package/dist/shared/{stack.DaZM10cp.d.cts → stack.DzOhpIYM.d.mts} +120 -59
  232. package/dist/shared/{stack.CTDVxbrA.d.ts → stack.Fl2Kl_bt.d.ts} +60 -45
  233. package/dist/shared/{stack.FeaWkglm.d.cts → stack.Jb0kQDJC.d.mts} +24 -1
  234. package/dist/shared/stack.Ldfkr5b2.d.cts +112 -0
  235. package/dist/shared/stack.Ldfkr5b2.d.mts +112 -0
  236. package/dist/shared/stack.Ldfkr5b2.d.ts +112 -0
  237. package/dist/shared/{stack.CLQuVdwK.d.ts → stack.RuQ9JCLo.d.ts} +87 -58
  238. package/dist/shared/{stack.BwA7trxA.d.cts → stack.VF6FhyZw.d.ts} +9 -15
  239. package/dist/shared/{stack.sO33ZDhK.d.ts → stack.fQjVhw5a.d.ts} +90 -45
  240. package/package.json +70 -5
  241. package/src/__tests__/plugins.test.tsx +5 -1
  242. package/src/__tests__/stack-api.test.ts +1 -1
  243. package/src/plugins/ai-chat/__tests__/getters.test.ts +1 -1
  244. package/src/plugins/ai-chat/api/getters.ts +1 -1
  245. package/src/plugins/ai-chat/api/plugin.ts +1 -1
  246. package/src/plugins/api/index.ts +5 -1
  247. package/src/plugins/blog/__tests__/getters.test.ts +1 -1
  248. package/src/plugins/blog/api/getters.ts +1 -1
  249. package/src/plugins/blog/api/plugin.ts +1 -1
  250. package/src/plugins/blog/client/components/loading/post-navigation-skeleton.tsx +10 -0
  251. package/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.tsx +18 -0
  252. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +23 -8
  253. package/src/plugins/blog/client/components/shared/post-navigation.tsx +0 -5
  254. package/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx +1 -5
  255. package/src/plugins/blog/client/hooks/blog-hooks.tsx +8 -33
  256. package/src/plugins/blog/client/overrides.ts +26 -1
  257. package/src/plugins/cms/__tests__/getters.test.ts +1 -1
  258. package/src/plugins/cms/api/getters.ts +1 -1
  259. package/src/plugins/cms/api/mutations.ts +1 -1
  260. package/src/plugins/cms/api/plugin.ts +1 -1
  261. package/src/plugins/cms/client/components/shared/pagination.tsx +14 -42
  262. package/src/plugins/comments/api/getters.ts +444 -0
  263. package/src/plugins/comments/api/index.ts +21 -0
  264. package/src/plugins/comments/api/mutations.ts +206 -0
  265. package/src/plugins/comments/api/plugin.ts +628 -0
  266. package/src/plugins/comments/api/query-key-defs.ts +143 -0
  267. package/src/plugins/comments/api/serializers.ts +37 -0
  268. package/src/plugins/comments/client/components/comment-count.tsx +66 -0
  269. package/src/plugins/comments/client/components/comment-form.tsx +112 -0
  270. package/src/plugins/comments/client/components/comment-thread.tsx +799 -0
  271. package/src/plugins/comments/client/components/index.tsx +11 -0
  272. package/src/plugins/comments/client/components/pages/moderation-page.internal.tsx +550 -0
  273. package/src/plugins/comments/client/components/pages/moderation-page.tsx +70 -0
  274. package/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx +367 -0
  275. package/src/plugins/comments/client/components/pages/my-comments-page.tsx +72 -0
  276. package/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx +225 -0
  277. package/src/plugins/comments/client/components/pages/resource-comments-page.tsx +97 -0
  278. package/src/plugins/comments/client/components/shared/page-wrapper.tsx +32 -0
  279. package/src/plugins/comments/client/components/shared/pagination.tsx +44 -0
  280. package/src/plugins/comments/client/hooks/index.tsx +13 -0
  281. package/src/plugins/comments/client/hooks/use-comments.tsx +717 -0
  282. package/src/plugins/comments/client/index.ts +14 -0
  283. package/src/plugins/comments/client/localization/comments-moderation.ts +75 -0
  284. package/src/plugins/comments/client/localization/comments-my.ts +32 -0
  285. package/src/plugins/comments/client/localization/comments-thread.ts +32 -0
  286. package/src/plugins/comments/client/localization/index.ts +11 -0
  287. package/src/plugins/comments/client/overrides.ts +164 -0
  288. package/src/plugins/comments/client/plugin.tsx +195 -0
  289. package/src/plugins/comments/client/utils.ts +67 -0
  290. package/src/plugins/comments/client.css +2 -0
  291. package/src/plugins/comments/db.ts +77 -0
  292. package/src/plugins/comments/query-keys.ts +189 -0
  293. package/src/plugins/comments/schemas.ts +72 -0
  294. package/src/plugins/comments/style.css +15 -0
  295. package/src/plugins/comments/types.ts +73 -0
  296. package/src/plugins/form-builder/__tests__/getters.test.ts +1 -1
  297. package/src/plugins/form-builder/api/getters.ts +1 -1
  298. package/src/plugins/form-builder/api/plugin.ts +1 -1
  299. package/src/plugins/kanban/__tests__/getters.test.ts +1 -1
  300. package/src/plugins/kanban/api/getters.ts +1 -1
  301. package/src/plugins/kanban/api/mutations.ts +1 -1
  302. package/src/plugins/kanban/api/plugin.ts +6 -5
  303. package/src/plugins/kanban/client/components/forms/task-form.tsx +0 -1
  304. package/src/plugins/kanban/client/components/pages/board-page.internal.tsx +46 -27
  305. package/src/plugins/kanban/client/overrides.ts +27 -1
  306. package/src/types.ts +5 -1
  307. package/dist/shared/{stack.BQmuNl5p.d.mts → stack.BWp0hcm9.d.cts} +3 -3
  308. package/dist/shared/{stack.BQmuNl5p.d.ts → stack.BWp0hcm9.d.mts} +3 -3
  309. package/dist/shared/{stack.BQmuNl5p.d.cts → stack.BWp0hcm9.d.ts} +3 -3
@@ -0,0 +1,799 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type ComponentType } from "react";
4
+ import { WhenVisible } from "@workspace/ui/components/when-visible";
5
+ import {
6
+ Avatar,
7
+ AvatarFallback,
8
+ AvatarImage,
9
+ } from "@workspace/ui/components/avatar";
10
+ import { Badge } from "@workspace/ui/components/badge";
11
+ import { Button } from "@workspace/ui/components/button";
12
+ import { Separator } from "@workspace/ui/components/separator";
13
+ import {
14
+ Heart,
15
+ MessageSquare,
16
+ Pencil,
17
+ X,
18
+ LogIn,
19
+ ChevronDown,
20
+ ChevronUp,
21
+ } from "lucide-react";
22
+ import { formatDistanceToNow } from "date-fns";
23
+ import type { SerializedComment } from "../../types";
24
+ import { getInitials } from "../utils";
25
+ import { CommentForm } from "./comment-form";
26
+ import {
27
+ useComments,
28
+ useInfiniteComments,
29
+ usePostComment,
30
+ useUpdateComment,
31
+ useDeleteComment,
32
+ useToggleLike,
33
+ } from "../hooks/use-comments";
34
+ import {
35
+ COMMENTS_LOCALIZATION,
36
+ type CommentsLocalization,
37
+ } from "../localization";
38
+ import { usePluginOverrides } from "@btst/stack/context";
39
+ import type { CommentsPluginOverrides } from "../overrides";
40
+
41
+ /** Custom input component props */
42
+ export interface CommentInputProps {
43
+ value: string;
44
+ onChange: (value: string) => void;
45
+ disabled?: boolean;
46
+ placeholder?: string;
47
+ }
48
+
49
+ /** Custom renderer component props */
50
+ export interface CommentRendererProps {
51
+ body: string;
52
+ }
53
+
54
+ /** Override slot for custom input + renderer */
55
+ export interface CommentComponents {
56
+ Input?: ComponentType<CommentInputProps>;
57
+ Renderer?: ComponentType<CommentRendererProps>;
58
+ }
59
+
60
+ export interface CommentThreadProps {
61
+ /** The resource this thread is attached to (e.g. post slug, task ID) */
62
+ resourceId: string;
63
+ /** Discriminates resources across plugins (e.g. "blog-post", "kanban-task") */
64
+ resourceType: string;
65
+ /** Base URL for API calls */
66
+ apiBaseURL: string;
67
+ /** Path where the API is mounted */
68
+ apiBasePath: string;
69
+ /** Currently authenticated user ID. Omit for read-only / unauthenticated. */
70
+ currentUserId?: string;
71
+ /**
72
+ * URL to redirect unauthenticated users to.
73
+ * When provided and currentUserId is absent, shows a "Please login to comment" prompt.
74
+ */
75
+ loginHref?: string;
76
+ /** Optional HTTP headers for API calls (e.g. forwarding cookies) */
77
+ headers?: HeadersInit;
78
+ /** Swap in custom Input / Renderer components */
79
+ components?: CommentComponents;
80
+ /** Optional className applied to the root wrapper */
81
+ className?: string;
82
+ /** Localization strings — defaults to English */
83
+ localization?: Partial<CommentsLocalization>;
84
+ /**
85
+ * Number of top-level comments to load per page.
86
+ * Clicking "Load more" fetches the next page. Default: 10.
87
+ */
88
+ pageSize?: number;
89
+ /**
90
+ * When false, the comment form and reply buttons are hidden.
91
+ * Overrides the global `allowPosting` from `CommentsPluginOverrides`.
92
+ * Defaults to true.
93
+ */
94
+ allowPosting?: boolean;
95
+ /**
96
+ * When false, the edit button is hidden on comment cards.
97
+ * Overrides the global `allowEditing` from `CommentsPluginOverrides`.
98
+ * Defaults to true.
99
+ */
100
+ allowEditing?: boolean;
101
+ }
102
+
103
+ const DEFAULT_RENDERER: ComponentType<CommentRendererProps> = ({ body }) => (
104
+ <p className="text-sm whitespace-pre-wrap wrap-break-word">{body}</p>
105
+ );
106
+
107
+ // ─── Comment Card ─────────────────────────────────────────────────────────────
108
+
109
+ function CommentCard({
110
+ comment,
111
+ currentUserId,
112
+ apiBaseURL,
113
+ apiBasePath,
114
+ resourceId,
115
+ resourceType,
116
+ headers,
117
+ components,
118
+ loc,
119
+ infiniteKey,
120
+ onReplyClick,
121
+ allowPosting,
122
+ allowEditing,
123
+ }: {
124
+ comment: SerializedComment;
125
+ currentUserId?: string;
126
+ apiBaseURL: string;
127
+ apiBasePath: string;
128
+ resourceId: string;
129
+ resourceType: string;
130
+ headers?: HeadersInit;
131
+ components?: CommentComponents;
132
+ loc: CommentsLocalization;
133
+ /** Infinite thread query key — pass for top-level comments so like optimistic
134
+ * updates target the correct InfiniteData cache entry. */
135
+ infiniteKey?: readonly unknown[];
136
+ onReplyClick: (parentId: string) => void;
137
+ allowPosting: boolean;
138
+ allowEditing: boolean;
139
+ }) {
140
+ const [isEditing, setIsEditing] = useState(false);
141
+ const Renderer = components?.Renderer ?? DEFAULT_RENDERER;
142
+
143
+ const config = { apiBaseURL, apiBasePath, headers };
144
+
145
+ const updateMutation = useUpdateComment(config);
146
+ const deleteMutation = useDeleteComment(config);
147
+ const toggleLikeMutation = useToggleLike(config, {
148
+ resourceId,
149
+ resourceType,
150
+ parentId: comment.parentId,
151
+ currentUserId,
152
+ infiniteKey,
153
+ });
154
+
155
+ const isOwn = currentUserId && comment.authorId === currentUserId;
156
+ const isPending = comment.status === "pending";
157
+ const isApproved = comment.status === "approved";
158
+
159
+ const handleEdit = async (body: string) => {
160
+ await updateMutation.mutateAsync({ id: comment.id, body });
161
+ setIsEditing(false);
162
+ };
163
+
164
+ const handleDelete = async () => {
165
+ if (!window.confirm(loc.COMMENTS_DELETE_CONFIRM)) return;
166
+ await deleteMutation.mutateAsync(comment.id);
167
+ };
168
+
169
+ const handleLike = () => {
170
+ if (!currentUserId) return;
171
+ toggleLikeMutation.mutate({
172
+ commentId: comment.id,
173
+ authorId: currentUserId,
174
+ });
175
+ };
176
+
177
+ return (
178
+ <div
179
+ className="flex gap-3 py-3"
180
+ data-testid="comment-card"
181
+ data-comment-id={comment.id}
182
+ >
183
+ <Avatar className="h-8 w-8 shrink-0 mt-0.5">
184
+ {comment.resolvedAvatarUrl && (
185
+ <AvatarImage
186
+ src={comment.resolvedAvatarUrl}
187
+ alt={comment.resolvedAuthorName}
188
+ />
189
+ )}
190
+ <AvatarFallback className="text-xs">
191
+ {getInitials(comment.resolvedAuthorName)}
192
+ </AvatarFallback>
193
+ </Avatar>
194
+
195
+ <div className="flex-1 min-w-0">
196
+ <div className="flex flex-wrap items-center gap-2 mb-1">
197
+ <span className="text-sm font-medium">
198
+ {comment.resolvedAuthorName}
199
+ </span>
200
+ <span className="text-xs text-muted-foreground">
201
+ {formatDistanceToNow(new Date(comment.createdAt), {
202
+ addSuffix: true,
203
+ })}
204
+ </span>
205
+ {comment.editedAt && (
206
+ <span className="text-xs text-muted-foreground italic">
207
+ {loc.COMMENTS_EDITED_BADGE}
208
+ </span>
209
+ )}
210
+ {isPending && isOwn && (
211
+ <Badge
212
+ variant="secondary"
213
+ className="text-xs"
214
+ data-testid="pending-badge"
215
+ >
216
+ {loc.COMMENTS_PENDING_BADGE}
217
+ </Badge>
218
+ )}
219
+ </div>
220
+
221
+ {isEditing ? (
222
+ <CommentForm
223
+ authorId={currentUserId ?? ""}
224
+ initialBody={comment.body}
225
+ submitLabel={loc.COMMENTS_SAVE_EDIT}
226
+ InputComponent={components?.Input}
227
+ localization={loc}
228
+ onSubmit={handleEdit}
229
+ onCancel={() => setIsEditing(false)}
230
+ />
231
+ ) : (
232
+ <Renderer body={comment.body} />
233
+ )}
234
+
235
+ {!isEditing && (
236
+ <div className="flex items-center gap-1 mt-2">
237
+ {currentUserId && isApproved && (
238
+ <Button
239
+ variant="ghost"
240
+ size="sm"
241
+ className="h-7 px-2 text-xs gap-1"
242
+ onClick={handleLike}
243
+ aria-label={
244
+ comment.isLikedByCurrentUser
245
+ ? loc.COMMENTS_UNLIKE_ARIA
246
+ : loc.COMMENTS_LIKE_ARIA
247
+ }
248
+ data-testid="like-button"
249
+ >
250
+ <Heart
251
+ className={`h-3.5 w-3.5 ${comment.isLikedByCurrentUser ? "fill-current text-red-500" : ""}`}
252
+ />
253
+ {comment.likes > 0 && (
254
+ <span data-testid="like-count">{comment.likes}</span>
255
+ )}
256
+ </Button>
257
+ )}
258
+
259
+ {allowPosting &&
260
+ currentUserId &&
261
+ !comment.parentId &&
262
+ isApproved && (
263
+ <Button
264
+ variant="ghost"
265
+ size="sm"
266
+ className="h-7 px-2 text-xs"
267
+ onClick={() => onReplyClick(comment.id)}
268
+ data-testid="reply-button"
269
+ >
270
+ <MessageSquare className="h-3.5 w-3.5 mr-1" />
271
+ {loc.COMMENTS_REPLY_BUTTON}
272
+ </Button>
273
+ )}
274
+
275
+ {isOwn && (
276
+ <>
277
+ {allowEditing && isApproved && (
278
+ <Button
279
+ variant="ghost"
280
+ size="sm"
281
+ className="h-7 px-2 text-xs"
282
+ onClick={() => setIsEditing(true)}
283
+ data-testid="edit-button"
284
+ >
285
+ <Pencil className="h-3.5 w-3.5 mr-1" />
286
+ {loc.COMMENTS_EDIT_BUTTON}
287
+ </Button>
288
+ )}
289
+ <Button
290
+ variant="ghost"
291
+ size="sm"
292
+ className="h-7 px-2 text-xs text-destructive hover:text-destructive"
293
+ onClick={handleDelete}
294
+ disabled={deleteMutation.isPending}
295
+ data-testid="delete-button"
296
+ >
297
+ <X className="h-3.5 w-3.5 mr-1" />
298
+ {loc.COMMENTS_DELETE_BUTTON}
299
+ </Button>
300
+ </>
301
+ )}
302
+ </div>
303
+ )}
304
+ </div>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ // ─── Thread Inner (handles data) ──────────────────────────────────────────────
310
+
311
+ const DEFAULT_PAGE_SIZE = 100;
312
+ const REPLIES_PAGE_SIZE = 20;
313
+ const OPTIMISTIC_ID_PREFIX = "optimistic-";
314
+
315
+ function CommentThreadInner({
316
+ resourceId,
317
+ resourceType,
318
+ apiBaseURL,
319
+ apiBasePath,
320
+ currentUserId,
321
+ loginHref,
322
+ headers,
323
+ components,
324
+ localization: localizationProp,
325
+ pageSize: pageSizeProp,
326
+ allowPosting: allowPostingProp,
327
+ allowEditing: allowEditingProp,
328
+ }: CommentThreadProps) {
329
+ const overrides = usePluginOverrides<
330
+ CommentsPluginOverrides,
331
+ Partial<CommentsPluginOverrides>
332
+ >("comments", {});
333
+ const pageSize =
334
+ pageSizeProp ?? overrides.defaultCommentPageSize ?? DEFAULT_PAGE_SIZE;
335
+ const allowPosting = allowPostingProp ?? overrides.allowPosting ?? true;
336
+ const allowEditing = allowEditingProp ?? overrides.allowEditing ?? true;
337
+ const loc = { ...COMMENTS_LOCALIZATION, ...localizationProp };
338
+ const [replyingTo, setReplyingTo] = useState<string | null>(null);
339
+ const [expandedReplies, setExpandedReplies] = useState<Set<string>>(
340
+ new Set(),
341
+ );
342
+ const [replyOffsets, setReplyOffsets] = useState<Record<string, number>>({});
343
+
344
+ const config = { apiBaseURL, apiBasePath, headers };
345
+
346
+ const {
347
+ comments,
348
+ total,
349
+ isLoading,
350
+ loadMore,
351
+ hasMore,
352
+ isLoadingMore,
353
+ queryKey: threadQueryKey,
354
+ } = useInfiniteComments(config, {
355
+ resourceId,
356
+ resourceType,
357
+ status: "approved",
358
+ parentId: null,
359
+ currentUserId,
360
+ pageSize,
361
+ });
362
+
363
+ const postMutation = usePostComment(config, {
364
+ resourceId,
365
+ resourceType,
366
+ currentUserId,
367
+ infiniteKey: threadQueryKey,
368
+ pageSize,
369
+ });
370
+
371
+ const handlePost = async (body: string) => {
372
+ if (!currentUserId) return;
373
+ await postMutation.mutateAsync({
374
+ body,
375
+ parentId: null,
376
+ });
377
+ };
378
+
379
+ const handleReply = async (body: string, parentId: string) => {
380
+ if (!currentUserId) return;
381
+ await postMutation.mutateAsync({
382
+ body,
383
+ parentId,
384
+ limit: REPLIES_PAGE_SIZE,
385
+ offset: replyOffsets[parentId] ?? 0,
386
+ });
387
+ setReplyingTo(null);
388
+ setExpandedReplies((prev) => new Set(prev).add(parentId));
389
+ };
390
+
391
+ return (
392
+ <div className="space-y-1" data-testid="comment-thread">
393
+ <div className="flex items-center gap-2 mb-4">
394
+ <MessageSquare className="h-5 w-5 text-muted-foreground" />
395
+ <h3 className="font-semibold text-sm">
396
+ {total === 0 ? loc.COMMENTS_TITLE : `${total} ${loc.COMMENTS_TITLE}`}
397
+ </h3>
398
+ </div>
399
+
400
+ {isLoading && (
401
+ <div className="space-y-4">
402
+ {[1, 2].map((i) => (
403
+ <div key={i} className="flex gap-3 py-3 animate-pulse">
404
+ <div className="h-8 w-8 rounded-full bg-muted shrink-0" />
405
+ <div className="flex-1 space-y-2">
406
+ <div className="h-3 w-24 rounded bg-muted" />
407
+ <div className="h-3 w-full rounded bg-muted" />
408
+ <div className="h-3 w-3/4 rounded bg-muted" />
409
+ </div>
410
+ </div>
411
+ ))}
412
+ </div>
413
+ )}
414
+
415
+ {!isLoading && comments.length > 0 && (
416
+ <div className="divide-y divide-border">
417
+ {comments.map((comment) => (
418
+ <div key={comment.id}>
419
+ <CommentCard
420
+ comment={comment}
421
+ currentUserId={currentUserId}
422
+ apiBaseURL={apiBaseURL}
423
+ apiBasePath={apiBasePath}
424
+ resourceId={resourceId}
425
+ resourceType={resourceType}
426
+ headers={headers}
427
+ components={components}
428
+ loc={loc}
429
+ infiniteKey={threadQueryKey}
430
+ onReplyClick={(parentId) => {
431
+ setReplyingTo(replyingTo === parentId ? null : parentId);
432
+ }}
433
+ allowPosting={allowPosting}
434
+ allowEditing={allowEditing}
435
+ />
436
+
437
+ {/* Replies */}
438
+ <RepliesSection
439
+ parentId={comment.id}
440
+ resourceId={resourceId}
441
+ resourceType={resourceType}
442
+ apiBaseURL={apiBaseURL}
443
+ apiBasePath={apiBasePath}
444
+ currentUserId={currentUserId}
445
+ headers={headers}
446
+ components={components}
447
+ loc={loc}
448
+ expanded={expandedReplies.has(comment.id)}
449
+ replyCount={comment.replyCount}
450
+ onToggle={() => {
451
+ const isExpanded = expandedReplies.has(comment.id);
452
+ if (!isExpanded) {
453
+ setReplyOffsets((prev) => {
454
+ if ((prev[comment.id] ?? 0) === 0) return prev;
455
+ return { ...prev, [comment.id]: 0 };
456
+ });
457
+ }
458
+ setExpandedReplies((prev) => {
459
+ const next = new Set(prev);
460
+ next.has(comment.id)
461
+ ? next.delete(comment.id)
462
+ : next.add(comment.id);
463
+ return next;
464
+ });
465
+ }}
466
+ onOffsetChange={(offset) => {
467
+ setReplyOffsets((prev) => {
468
+ if (prev[comment.id] === offset) return prev;
469
+ return { ...prev, [comment.id]: offset };
470
+ });
471
+ }}
472
+ allowEditing={allowEditing}
473
+ />
474
+
475
+ {allowPosting && replyingTo === comment.id && currentUserId && (
476
+ <div className="pl-11 pb-3">
477
+ <CommentForm
478
+ authorId={currentUserId}
479
+ parentId={comment.id}
480
+ submitLabel={loc.COMMENTS_FORM_POST_REPLY}
481
+ InputComponent={components?.Input}
482
+ localization={loc}
483
+ onSubmit={(body) => handleReply(body, comment.id)}
484
+ onCancel={() => setReplyingTo(null)}
485
+ />
486
+ </div>
487
+ )}
488
+ </div>
489
+ ))}
490
+ </div>
491
+ )}
492
+
493
+ {!isLoading && comments.length === 0 && (
494
+ <p className="text-sm text-muted-foreground py-4 text-center">
495
+ {loc.COMMENTS_EMPTY}
496
+ </p>
497
+ )}
498
+
499
+ {hasMore && (
500
+ <div className="flex justify-center pt-2">
501
+ <Button
502
+ variant="outline"
503
+ size="sm"
504
+ onClick={() => loadMore()}
505
+ disabled={isLoadingMore}
506
+ data-testid="load-more-comments"
507
+ >
508
+ {isLoadingMore ? loc.COMMENTS_LOADING_MORE : loc.COMMENTS_LOAD_MORE}
509
+ </Button>
510
+ </div>
511
+ )}
512
+
513
+ {allowPosting && (
514
+ <>
515
+ <Separator className="my-4" />
516
+
517
+ {currentUserId ? (
518
+ <div data-testid="comment-form-wrapper">
519
+ <CommentForm
520
+ authorId={currentUserId}
521
+ submitLabel={loc.COMMENTS_FORM_POST_COMMENT}
522
+ InputComponent={components?.Input}
523
+ localization={loc}
524
+ onSubmit={handlePost}
525
+ />
526
+ </div>
527
+ ) : (
528
+ <div
529
+ className="flex flex-col items-center gap-3 py-6 text-center border rounded-lg bg-muted/30"
530
+ data-testid="login-prompt"
531
+ >
532
+ <LogIn className="h-6 w-6 text-muted-foreground" />
533
+ <p className="text-sm text-muted-foreground">
534
+ {loc.COMMENTS_LOGIN_PROMPT}
535
+ </p>
536
+ {loginHref && (
537
+ <a
538
+ href={loginHref}
539
+ className="inline-flex items-center gap-1 text-sm font-medium text-primary underline underline-offset-4"
540
+ data-testid="login-link"
541
+ >
542
+ {loc.COMMENTS_LOGIN_LINK}
543
+ </a>
544
+ )}
545
+ </div>
546
+ )}
547
+ </>
548
+ )}
549
+ </div>
550
+ );
551
+ }
552
+
553
+ // ─── Replies Section ───────────────────────────────────────────────────────────
554
+
555
+ function RepliesSection({
556
+ parentId,
557
+ resourceId,
558
+ resourceType,
559
+ apiBaseURL,
560
+ apiBasePath,
561
+ currentUserId,
562
+ headers,
563
+ components,
564
+ loc,
565
+ expanded,
566
+ replyCount,
567
+ onToggle,
568
+ onOffsetChange,
569
+ allowEditing,
570
+ }: {
571
+ parentId: string;
572
+ resourceId: string;
573
+ resourceType: string;
574
+ apiBaseURL: string;
575
+ apiBasePath: string;
576
+ currentUserId?: string;
577
+ headers?: HeadersInit;
578
+ components?: CommentComponents;
579
+ loc: CommentsLocalization;
580
+ expanded: boolean;
581
+ /** Pre-computed from the parent comment — avoids an extra fetch on mount. */
582
+ replyCount: number;
583
+ onToggle: () => void;
584
+ onOffsetChange: (offset: number) => void;
585
+ allowEditing: boolean;
586
+ }) {
587
+ const config = { apiBaseURL, apiBasePath, headers };
588
+ const [replyOffset, setReplyOffset] = useState(0);
589
+ const [loadedReplies, setLoadedReplies] = useState<SerializedComment[]>([]);
590
+ // Only fetch reply bodies once the section is expanded.
591
+ const {
592
+ comments: repliesPage,
593
+ total: repliesTotal,
594
+ isFetching: isFetchingReplies,
595
+ } = useComments(
596
+ config,
597
+ {
598
+ resourceId,
599
+ resourceType,
600
+ parentId,
601
+ status: "approved",
602
+ currentUserId,
603
+ limit: REPLIES_PAGE_SIZE,
604
+ offset: replyOffset,
605
+ },
606
+ { enabled: expanded },
607
+ );
608
+
609
+ useEffect(() => {
610
+ if (expanded) {
611
+ setReplyOffset(0);
612
+ setLoadedReplies([]);
613
+ }
614
+ }, [expanded, parentId]);
615
+
616
+ useEffect(() => {
617
+ onOffsetChange(replyOffset);
618
+ }, [onOffsetChange, replyOffset]);
619
+
620
+ useEffect(() => {
621
+ if (!expanded) return;
622
+ setLoadedReplies((prev) => {
623
+ const byId = new Map(prev.map((item) => [item.id, item]));
624
+ for (const reply of repliesPage) {
625
+ byId.set(reply.id, reply);
626
+ }
627
+
628
+ // Reconcile optimistic replies once the real server reply arrives with
629
+ // a different id. Without this, both entries can persist in local state
630
+ // until the section is collapsed and re-opened.
631
+ const currentPageIds = new Set(repliesPage.map((reply) => reply.id));
632
+ const currentPageRealReplies = repliesPage.filter(
633
+ (reply) => !reply.id.startsWith(OPTIMISTIC_ID_PREFIX),
634
+ );
635
+
636
+ return Array.from(byId.values()).filter((reply) => {
637
+ if (!reply.id.startsWith(OPTIMISTIC_ID_PREFIX)) return true;
638
+ // Keep optimistic items still present in the current cache page.
639
+ if (currentPageIds.has(reply.id)) return true;
640
+ // Drop stale optimistic rows that have been replaced by a real reply.
641
+ return !currentPageRealReplies.some(
642
+ (realReply) =>
643
+ realReply.parentId === reply.parentId &&
644
+ realReply.authorId === reply.authorId &&
645
+ realReply.body === reply.body,
646
+ );
647
+ });
648
+ });
649
+ }, [expanded, repliesPage]);
650
+
651
+ // Hide when there are no known replies — but keep rendered when already
652
+ // expanded so a freshly-posted first reply (which increments replyCount
653
+ // only after the server responds) stays visible in the same session.
654
+ if (replyCount === 0 && !expanded) return null;
655
+
656
+ // Prefer the fetched count (accurate after optimistic inserts); fall back to
657
+ // the server-provided replyCount before the fetch completes.
658
+ const displayCount = expanded
659
+ ? loadedReplies.length || replyCount
660
+ : replyCount;
661
+ const effectiveReplyTotal = repliesTotal || replyCount;
662
+ const hasMoreReplies = loadedReplies.length < effectiveReplyTotal;
663
+
664
+ return (
665
+ <div className="pl-11">
666
+ {/* Toggle button — always at the top so collapse is reachable without scrolling */}
667
+ <Button
668
+ variant="ghost"
669
+ size="sm"
670
+ className="h-7 px-2 text-xs mb-1"
671
+ onClick={onToggle}
672
+ data-testid={expanded ? "hide-replies-button" : "show-replies-button"}
673
+ >
674
+ {expanded ? (
675
+ <ChevronUp className="h-3 w-3 mr-1" />
676
+ ) : (
677
+ <ChevronDown className="h-3 w-3 mr-1" />
678
+ )}
679
+ {expanded
680
+ ? loc.COMMENTS_HIDE_REPLIES
681
+ : `${displayCount} ${displayCount === 1 ? loc.COMMENTS_REPLIES_SINGULAR : loc.COMMENTS_REPLIES_PLURAL}`}
682
+ </Button>
683
+ {expanded && (
684
+ <div
685
+ className="border-l-2 border-border pl-3 space-y-0"
686
+ data-testid="replies-list"
687
+ >
688
+ {loadedReplies.map((reply) => (
689
+ <CommentCard
690
+ key={reply.id}
691
+ comment={reply}
692
+ currentUserId={currentUserId}
693
+ apiBaseURL={apiBaseURL}
694
+ apiBasePath={apiBasePath}
695
+ resourceId={resourceId}
696
+ resourceType={resourceType}
697
+ headers={headers}
698
+ components={components}
699
+ loc={loc}
700
+ onReplyClick={() => {}} // No nested replies in v1
701
+ allowPosting={false}
702
+ allowEditing={allowEditing}
703
+ />
704
+ ))}
705
+ {hasMoreReplies && (
706
+ <div className="py-2">
707
+ <Button
708
+ variant="ghost"
709
+ size="sm"
710
+ className="h-7 px-2 text-xs"
711
+ onClick={() =>
712
+ setReplyOffset((prev) => prev + REPLIES_PAGE_SIZE)
713
+ }
714
+ disabled={isFetchingReplies}
715
+ data-testid="load-more-replies"
716
+ >
717
+ {isFetchingReplies
718
+ ? loc.COMMENTS_LOADING_MORE
719
+ : loc.COMMENTS_LOAD_MORE}
720
+ </Button>
721
+ </div>
722
+ )}
723
+ </div>
724
+ )}
725
+ </div>
726
+ );
727
+ }
728
+
729
+ // ─── Public export: lazy-mounts on scroll into view ───────────────────────────
730
+
731
+ /**
732
+ * Embeddable threaded comment section.
733
+ *
734
+ * Lazy-mounts when the component scrolls into the viewport (via WhenVisible).
735
+ * Requires `currentUserId` to allow posting; shows a "Please login" prompt otherwise.
736
+ *
737
+ * @example
738
+ * ```tsx
739
+ * <CommentThread
740
+ * resourceId={post.slug}
741
+ * resourceType="blog-post"
742
+ * apiBaseURL="https://example.com"
743
+ * apiBasePath="/api/data"
744
+ * currentUserId={session?.userId}
745
+ * loginHref="/login"
746
+ * />
747
+ * ```
748
+ */
749
+ function CommentThreadSkeleton() {
750
+ return (
751
+ <div className="space-y-1">
752
+ {/* Header */}
753
+ <div className="flex items-center gap-2 mb-4">
754
+ <div className="h-5 w-5 rounded bg-muted animate-pulse" />
755
+ <div className="h-4 w-24 rounded bg-muted animate-pulse" />
756
+ </div>
757
+
758
+ {/* Comment rows */}
759
+ {[1, 2, 3].map((i) => (
760
+ <div key={i} className="flex gap-3 py-3">
761
+ <div className="h-8 w-8 rounded-full bg-muted shrink-0 mt-0.5 animate-pulse" />
762
+ <div className="flex-1 space-y-2">
763
+ <div className="flex items-center gap-2">
764
+ <div className="h-3 w-20 rounded bg-muted animate-pulse" />
765
+ <div className="h-3 w-14 rounded bg-muted animate-pulse" />
766
+ </div>
767
+ <div className="h-3 w-full rounded bg-muted animate-pulse" />
768
+ <div className="h-3 w-4/5 rounded bg-muted animate-pulse" />
769
+ <div className="flex gap-1 mt-1">
770
+ <div className="h-7 w-12 rounded bg-muted animate-pulse" />
771
+ <div className="h-7 w-14 rounded bg-muted animate-pulse" />
772
+ </div>
773
+ </div>
774
+ </div>
775
+ ))}
776
+
777
+ {/* Separator */}
778
+ <div className="h-px w-full bg-muted my-4" />
779
+
780
+ {/* Textarea placeholder */}
781
+ <div className="space-y-2">
782
+ <div className="h-20 w-full rounded-md border bg-muted animate-pulse" />
783
+ <div className="flex justify-end">
784
+ <div className="h-9 w-28 rounded-md bg-muted animate-pulse" />
785
+ </div>
786
+ </div>
787
+ </div>
788
+ );
789
+ }
790
+
791
+ export function CommentThread(props: CommentThreadProps) {
792
+ return (
793
+ <div id="comments" className={props.className}>
794
+ <WhenVisible fallback={<CommentThreadSkeleton />} rootMargin="300px">
795
+ <CommentThreadInner {...props} />
796
+ </WhenVisible>
797
+ </div>
798
+ );
799
+ }