@btst/stack 2.7.0 → 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 (181) hide show
  1. package/README.md +1 -0
  2. package/dist/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.cjs +13 -0
  3. package/dist/packages/stack/src/plugins/blog/client/components/loading/post-navigation-skeleton.mjs +11 -0
  4. package/dist/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.cjs +17 -0
  5. package/dist/packages/stack/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.mjs +15 -0
  6. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +18 -7
  7. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +18 -7
  8. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-navigation.cjs +48 -52
  9. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-navigation.mjs +49 -53
  10. package/dist/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.cjs +34 -37
  11. package/dist/packages/stack/src/plugins/blog/client/components/shared/recent-posts-carousel.mjs +35 -38
  12. package/dist/packages/stack/src/plugins/blog/client/hooks/blog-hooks.cjs +4 -21
  13. package/dist/packages/stack/src/plugins/blog/client/hooks/blog-hooks.mjs +4 -21
  14. package/dist/packages/stack/src/plugins/comments/api/getters.cjs +284 -0
  15. package/dist/packages/stack/src/plugins/comments/api/getters.mjs +280 -0
  16. package/dist/packages/stack/src/plugins/comments/api/mutations.cjs +118 -0
  17. package/dist/packages/stack/src/plugins/comments/api/mutations.mjs +112 -0
  18. package/dist/packages/stack/src/plugins/comments/api/plugin.cjs +335 -0
  19. package/dist/packages/stack/src/plugins/comments/api/plugin.mjs +333 -0
  20. package/dist/packages/stack/src/plugins/comments/api/query-key-defs.cjs +60 -0
  21. package/dist/packages/stack/src/plugins/comments/api/query-key-defs.mjs +55 -0
  22. package/dist/packages/stack/src/plugins/comments/api/serializers.cjs +23 -0
  23. package/dist/packages/stack/src/plugins/comments/api/serializers.mjs +21 -0
  24. package/dist/packages/stack/src/plugins/comments/client/components/comment-count.cjs +46 -0
  25. package/dist/packages/stack/src/plugins/comments/client/components/comment-count.mjs +44 -0
  26. package/dist/packages/stack/src/plugins/comments/client/components/comment-form.cjs +86 -0
  27. package/dist/packages/stack/src/plugins/comments/client/components/comment-form.mjs +84 -0
  28. package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.cjs +540 -0
  29. package/dist/packages/stack/src/plugins/comments/client/components/comment-thread.mjs +538 -0
  30. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.cjs +64 -0
  31. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.cjs +426 -0
  32. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.internal.mjs +424 -0
  33. package/dist/packages/stack/src/plugins/comments/client/components/pages/moderation-page.mjs +62 -0
  34. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.cjs +66 -0
  35. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.cjs +256 -0
  36. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.internal.mjs +254 -0
  37. package/dist/packages/stack/src/plugins/comments/client/components/pages/my-comments-page.mjs +64 -0
  38. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.cjs +86 -0
  39. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.cjs +191 -0
  40. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.internal.mjs +189 -0
  41. package/dist/packages/stack/src/plugins/comments/client/components/pages/resource-comments-page.mjs +84 -0
  42. package/dist/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.cjs +27 -0
  43. package/dist/packages/stack/src/plugins/comments/client/components/shared/page-wrapper.mjs +25 -0
  44. package/dist/packages/stack/src/plugins/comments/client/components/shared/pagination.cjs +37 -0
  45. package/dist/packages/stack/src/plugins/comments/client/components/shared/pagination.mjs +35 -0
  46. package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.cjs +476 -0
  47. package/dist/packages/stack/src/plugins/comments/client/hooks/use-comments.mjs +464 -0
  48. package/dist/packages/stack/src/plugins/comments/client/localization/comments-moderation.cjs +67 -0
  49. package/dist/packages/stack/src/plugins/comments/client/localization/comments-moderation.mjs +65 -0
  50. package/dist/packages/stack/src/plugins/comments/client/localization/comments-my.cjs +27 -0
  51. package/dist/packages/stack/src/plugins/comments/client/localization/comments-my.mjs +25 -0
  52. package/dist/packages/stack/src/plugins/comments/client/localization/comments-thread.cjs +30 -0
  53. package/dist/packages/stack/src/plugins/comments/client/localization/comments-thread.mjs +28 -0
  54. package/dist/packages/stack/src/plugins/comments/client/localization/index.cjs +13 -0
  55. package/dist/packages/stack/src/plugins/comments/client/localization/index.mjs +11 -0
  56. package/dist/packages/stack/src/plugins/comments/client/plugin.cjs +116 -0
  57. package/dist/packages/stack/src/plugins/comments/client/plugin.mjs +114 -0
  58. package/dist/packages/stack/src/plugins/comments/client/utils.cjs +41 -0
  59. package/dist/packages/stack/src/plugins/comments/client/utils.mjs +37 -0
  60. package/dist/packages/stack/src/plugins/comments/db.cjs +75 -0
  61. package/dist/packages/stack/src/plugins/comments/db.mjs +73 -0
  62. package/dist/packages/stack/src/plugins/comments/schemas.cjs +45 -0
  63. package/dist/packages/stack/src/plugins/comments/schemas.mjs +38 -0
  64. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.cjs +0 -1
  65. package/dist/packages/stack/src/plugins/kanban/client/components/forms/task-form.mjs +0 -1
  66. package/dist/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.cjs +39 -22
  67. package/dist/packages/stack/src/plugins/kanban/client/components/pages/board-page.internal.mjs +40 -23
  68. package/dist/packages/ui/src/components/avatar.mjs +1 -1
  69. package/dist/packages/ui/src/components/pagination-controls.cjs +64 -0
  70. package/dist/packages/ui/src/components/pagination-controls.mjs +62 -0
  71. package/dist/packages/ui/src/components/when-visible.cjs +39 -0
  72. package/dist/packages/ui/src/components/when-visible.mjs +37 -0
  73. package/dist/plugins/blog/client/hooks/index.d.cts +1 -1
  74. package/dist/plugins/blog/client/hooks/index.d.mts +1 -1
  75. package/dist/plugins/blog/client/hooks/index.d.ts +1 -1
  76. package/dist/plugins/blog/client/index.d.cts +24 -2
  77. package/dist/plugins/blog/client/index.d.mts +24 -2
  78. package/dist/plugins/blog/client/index.d.ts +24 -2
  79. package/dist/plugins/comments/api/index.cjs +21 -0
  80. package/dist/plugins/comments/api/index.d.cts +126 -0
  81. package/dist/plugins/comments/api/index.d.mts +126 -0
  82. package/dist/plugins/comments/api/index.d.ts +126 -0
  83. package/dist/plugins/comments/api/index.mjs +5 -0
  84. package/dist/plugins/comments/client/components/index.cjs +15 -0
  85. package/dist/plugins/comments/client/components/index.d.cts +125 -0
  86. package/dist/plugins/comments/client/components/index.d.mts +125 -0
  87. package/dist/plugins/comments/client/components/index.d.ts +125 -0
  88. package/dist/plugins/comments/client/components/index.mjs +5 -0
  89. package/dist/plugins/comments/client/hooks/index.cjs +17 -0
  90. package/dist/plugins/comments/client/hooks/index.d.cts +200 -0
  91. package/dist/plugins/comments/client/hooks/index.d.mts +200 -0
  92. package/dist/plugins/comments/client/hooks/index.d.ts +200 -0
  93. package/dist/plugins/comments/client/hooks/index.mjs +1 -0
  94. package/dist/plugins/comments/client/index.cjs +9 -0
  95. package/dist/plugins/comments/client/index.d.cts +262 -0
  96. package/dist/plugins/comments/client/index.d.mts +262 -0
  97. package/dist/plugins/comments/client/index.d.ts +262 -0
  98. package/dist/plugins/comments/client/index.mjs +2 -0
  99. package/dist/plugins/comments/client.css +2 -0
  100. package/dist/plugins/comments/query-keys.cjs +113 -0
  101. package/dist/plugins/comments/query-keys.d.cts +71 -0
  102. package/dist/plugins/comments/query-keys.d.mts +71 -0
  103. package/dist/plugins/comments/query-keys.d.ts +71 -0
  104. package/dist/plugins/comments/query-keys.mjs +111 -0
  105. package/dist/plugins/comments/style.css +15 -0
  106. package/dist/plugins/kanban/api/index.d.cts +1 -1
  107. package/dist/plugins/kanban/api/index.d.mts +1 -1
  108. package/dist/plugins/kanban/api/index.d.ts +1 -1
  109. package/dist/plugins/kanban/client/hooks/index.d.cts +1 -1
  110. package/dist/plugins/kanban/client/hooks/index.d.mts +1 -1
  111. package/dist/plugins/kanban/client/hooks/index.d.ts +1 -1
  112. package/dist/plugins/kanban/client/index.d.cts +1 -1
  113. package/dist/plugins/kanban/client/index.d.mts +1 -1
  114. package/dist/plugins/kanban/client/index.d.ts +1 -1
  115. package/dist/plugins/kanban/query-keys.d.cts +1 -1
  116. package/dist/plugins/kanban/query-keys.d.mts +1 -1
  117. package/dist/plugins/kanban/query-keys.d.ts +1 -1
  118. package/dist/shared/{stack.FeaWkglm.d.ts → stack.BxFl46lB.d.cts} +24 -1
  119. package/dist/shared/stack.C-b3Sn8j.d.cts +142 -0
  120. package/dist/shared/stack.C-b3Sn8j.d.mts +142 -0
  121. package/dist/shared/stack.C-b3Sn8j.d.ts +142 -0
  122. package/dist/shared/stack.CJE9sAjV.d.ts +335 -0
  123. package/dist/shared/stack.CmHRdhl8.d.cts +335 -0
  124. package/dist/shared/{stack.CNLHlv7r.d.mts → stack.DOZ1EXjM.d.mts} +6 -12
  125. package/dist/shared/{stack.FeaWkglm.d.mts → stack.DRpeDS6X.d.ts} +24 -1
  126. package/dist/shared/{stack.CQAZwXhV.d.cts → stack.DX-tQ93o.d.cts} +6 -12
  127. package/dist/shared/stack.Dcz6636A.d.mts +335 -0
  128. package/dist/shared/{stack.FeaWkglm.d.cts → stack.Jb0kQDJC.d.mts} +24 -1
  129. package/dist/shared/stack.Ldfkr5b2.d.cts +112 -0
  130. package/dist/shared/stack.Ldfkr5b2.d.mts +112 -0
  131. package/dist/shared/stack.Ldfkr5b2.d.ts +112 -0
  132. package/dist/shared/{stack.D3BsrpAz.d.ts → stack.VF6FhyZw.d.ts} +6 -12
  133. package/package.json +67 -2
  134. package/src/plugins/blog/client/components/loading/post-navigation-skeleton.tsx +10 -0
  135. package/src/plugins/blog/client/components/loading/recent-posts-carousel-skeleton.tsx +18 -0
  136. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +23 -8
  137. package/src/plugins/blog/client/components/shared/post-navigation.tsx +0 -5
  138. package/src/plugins/blog/client/components/shared/recent-posts-carousel.tsx +1 -5
  139. package/src/plugins/blog/client/hooks/blog-hooks.tsx +8 -33
  140. package/src/plugins/blog/client/overrides.ts +26 -1
  141. package/src/plugins/cms/client/components/shared/pagination.tsx +14 -42
  142. package/src/plugins/comments/api/getters.ts +444 -0
  143. package/src/plugins/comments/api/index.ts +21 -0
  144. package/src/plugins/comments/api/mutations.ts +206 -0
  145. package/src/plugins/comments/api/plugin.ts +628 -0
  146. package/src/plugins/comments/api/query-key-defs.ts +143 -0
  147. package/src/plugins/comments/api/serializers.ts +37 -0
  148. package/src/plugins/comments/client/components/comment-count.tsx +66 -0
  149. package/src/plugins/comments/client/components/comment-form.tsx +112 -0
  150. package/src/plugins/comments/client/components/comment-thread.tsx +799 -0
  151. package/src/plugins/comments/client/components/index.tsx +11 -0
  152. package/src/plugins/comments/client/components/pages/moderation-page.internal.tsx +550 -0
  153. package/src/plugins/comments/client/components/pages/moderation-page.tsx +70 -0
  154. package/src/plugins/comments/client/components/pages/my-comments-page.internal.tsx +367 -0
  155. package/src/plugins/comments/client/components/pages/my-comments-page.tsx +72 -0
  156. package/src/plugins/comments/client/components/pages/resource-comments-page.internal.tsx +225 -0
  157. package/src/plugins/comments/client/components/pages/resource-comments-page.tsx +97 -0
  158. package/src/plugins/comments/client/components/shared/page-wrapper.tsx +32 -0
  159. package/src/plugins/comments/client/components/shared/pagination.tsx +44 -0
  160. package/src/plugins/comments/client/hooks/index.tsx +13 -0
  161. package/src/plugins/comments/client/hooks/use-comments.tsx +717 -0
  162. package/src/plugins/comments/client/index.ts +14 -0
  163. package/src/plugins/comments/client/localization/comments-moderation.ts +75 -0
  164. package/src/plugins/comments/client/localization/comments-my.ts +32 -0
  165. package/src/plugins/comments/client/localization/comments-thread.ts +32 -0
  166. package/src/plugins/comments/client/localization/index.ts +11 -0
  167. package/src/plugins/comments/client/overrides.ts +164 -0
  168. package/src/plugins/comments/client/plugin.tsx +195 -0
  169. package/src/plugins/comments/client/utils.ts +67 -0
  170. package/src/plugins/comments/client.css +2 -0
  171. package/src/plugins/comments/db.ts +77 -0
  172. package/src/plugins/comments/query-keys.ts +189 -0
  173. package/src/plugins/comments/schemas.ts +72 -0
  174. package/src/plugins/comments/style.css +15 -0
  175. package/src/plugins/comments/types.ts +73 -0
  176. package/src/plugins/kanban/client/components/forms/task-form.tsx +0 -1
  177. package/src/plugins/kanban/client/components/pages/board-page.internal.tsx +46 -27
  178. package/src/plugins/kanban/client/overrides.ts +27 -1
  179. package/dist/shared/{stack.Rtcvl8sS.d.cts → stack.BOokfhZD.d.cts} +3 -3
  180. package/dist/shared/{stack.D4Cea8II.d.ts → stack.BvCR4-9H.d.ts} +3 -3
  181. package/dist/shared/{stack.HE_IvqV5.d.mts → stack.CWxAl9K3.d.mts} +3 -3
@@ -0,0 +1,717 @@
1
+ "use client";
2
+
3
+ import {
4
+ useQuery,
5
+ useInfiniteQuery,
6
+ useMutation,
7
+ useQueryClient,
8
+ useSuspenseQuery,
9
+ type InfiniteData,
10
+ } from "@tanstack/react-query";
11
+ import { createApiClient } from "@btst/stack/plugins/client";
12
+ import { createCommentsQueryKeys } from "../../query-keys";
13
+ import type { CommentsApiRouter } from "../../api";
14
+ import type { SerializedComment, CommentListResult } from "../../types";
15
+ import { toError } from "../utils";
16
+
17
+ interface CommentsClientConfig {
18
+ apiBaseURL: string;
19
+ apiBasePath: string;
20
+ headers?: HeadersInit;
21
+ }
22
+
23
+ function getClient(config: CommentsClientConfig) {
24
+ return createApiClient<CommentsApiRouter>({
25
+ baseURL: config.apiBaseURL,
26
+ basePath: config.apiBasePath,
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Fetch a paginated list of comments for a resource.
32
+ * Returns approved comments by default.
33
+ */
34
+ export function useComments(
35
+ config: CommentsClientConfig,
36
+ params: {
37
+ resourceId?: string;
38
+ resourceType?: string;
39
+ parentId?: string | null;
40
+ status?: "pending" | "approved" | "spam";
41
+ currentUserId?: string;
42
+ authorId?: string;
43
+ sort?: "asc" | "desc";
44
+ limit?: number;
45
+ offset?: number;
46
+ },
47
+ options?: { enabled?: boolean },
48
+ ) {
49
+ const client = getClient(config);
50
+ const queries = createCommentsQueryKeys(client, config.headers);
51
+
52
+ const query = useQuery({
53
+ ...queries.comments.list(params),
54
+ staleTime: 30_000,
55
+ retry: false,
56
+ enabled: options?.enabled ?? true,
57
+ });
58
+
59
+ return {
60
+ data: query.data,
61
+ comments: query.data?.items ?? [],
62
+ total: query.data?.total ?? 0,
63
+ isLoading: query.isLoading,
64
+ isFetching: query.isFetching,
65
+ error: query.error,
66
+ refetch: query.refetch,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * useSuspenseQuery version — for use in .internal.tsx files.
72
+ */
73
+ export function useSuspenseComments(
74
+ config: CommentsClientConfig,
75
+ params: {
76
+ resourceId?: string;
77
+ resourceType?: string;
78
+ parentId?: string | null;
79
+ status?: "pending" | "approved" | "spam";
80
+ currentUserId?: string;
81
+ authorId?: string;
82
+ sort?: "asc" | "desc";
83
+ limit?: number;
84
+ offset?: number;
85
+ },
86
+ ) {
87
+ const client = getClient(config);
88
+ const queries = createCommentsQueryKeys(client, config.headers);
89
+
90
+ const { data, refetch, error, isFetching } = useSuspenseQuery({
91
+ ...queries.comments.list(params),
92
+ staleTime: 30_000,
93
+ retry: false,
94
+ });
95
+
96
+ if (error && !isFetching) {
97
+ throw error;
98
+ }
99
+
100
+ return {
101
+ comments: data?.items ?? [],
102
+ total: data?.total ?? 0,
103
+ refetch,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Page-based variant for the moderation dashboard.
109
+ * Uses useSuspenseQuery with explicit offset so the table always shows exactly
110
+ * one page of results and navigation is handled by Prev / Next controls.
111
+ */
112
+ export function useSuspenseModerationComments(
113
+ config: CommentsClientConfig,
114
+ params: {
115
+ status?: "pending" | "approved" | "spam";
116
+ limit?: number;
117
+ page?: number;
118
+ },
119
+ ) {
120
+ const limit = params.limit ?? 20;
121
+ const page = params.page ?? 1;
122
+ const offset = (page - 1) * limit;
123
+
124
+ const client = getClient(config);
125
+ const queries = createCommentsQueryKeys(client, config.headers);
126
+
127
+ const { data, refetch, error, isFetching } = useSuspenseQuery({
128
+ ...queries.comments.list({ status: params.status, limit, offset }),
129
+ staleTime: 30_000,
130
+ retry: false,
131
+ });
132
+
133
+ if (error && !isFetching) {
134
+ throw error;
135
+ }
136
+
137
+ const comments = data?.items ?? [];
138
+ const total = data?.total ?? 0;
139
+ const totalPages = Math.max(1, Math.ceil(total / limit));
140
+
141
+ return {
142
+ comments,
143
+ total,
144
+ limit,
145
+ offset,
146
+ totalPages,
147
+ refetch,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Infinite-scroll variant for the CommentThread component.
153
+ * Uses the "commentsThread" factory namespace (separate from the plain
154
+ * useComments / useSuspenseComments queries) to avoid InfiniteData shape conflicts.
155
+ *
156
+ * Mirrors the blog's usePosts pattern: spread the factory base query into
157
+ * useInfiniteQuery, drive pages via pageParam, and derive hasMore from server total.
158
+ */
159
+ export function useInfiniteComments(
160
+ config: CommentsClientConfig,
161
+ params: {
162
+ resourceId: string;
163
+ resourceType: string;
164
+ parentId?: string | null;
165
+ status?: "pending" | "approved" | "spam";
166
+ currentUserId?: string;
167
+ pageSize?: number;
168
+ },
169
+ options?: { enabled?: boolean },
170
+ ) {
171
+ const pageSize = params.pageSize ?? 10;
172
+ const client = getClient(config);
173
+ const queries = createCommentsQueryKeys(client, config.headers);
174
+
175
+ const baseQuery = queries.commentsThread.list({
176
+ resourceId: params.resourceId,
177
+ resourceType: params.resourceType,
178
+ parentId: params.parentId ?? null,
179
+ status: params.status,
180
+ currentUserId: params.currentUserId,
181
+ limit: pageSize,
182
+ });
183
+
184
+ const query = useInfiniteQuery<
185
+ CommentListResult,
186
+ Error,
187
+ InfiniteData<CommentListResult>,
188
+ typeof baseQuery.queryKey,
189
+ number
190
+ >({
191
+ ...baseQuery,
192
+ initialPageParam: 0,
193
+ getNextPageParam: (lastPage) => {
194
+ const nextOffset = lastPage.offset + lastPage.limit;
195
+ return nextOffset < lastPage.total ? nextOffset : undefined;
196
+ },
197
+ staleTime: 30_000,
198
+ retry: false,
199
+ enabled: options?.enabled ?? true,
200
+ });
201
+
202
+ const comments = query.data?.pages.flatMap((p) => p.items) ?? [];
203
+ const total = query.data?.pages[0]?.total ?? 0;
204
+
205
+ return {
206
+ comments,
207
+ total,
208
+ queryKey: baseQuery.queryKey,
209
+ isLoading: query.isLoading,
210
+ isFetching: query.isFetching,
211
+ loadMore: query.fetchNextPage,
212
+ hasMore: !!query.hasNextPage,
213
+ isLoadingMore: query.isFetchingNextPage,
214
+ error: query.error,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * Fetch the approved comment count for a resource.
220
+ */
221
+ export function useCommentCount(
222
+ config: CommentsClientConfig,
223
+ params: {
224
+ resourceId: string;
225
+ resourceType: string;
226
+ status?: "pending" | "approved" | "spam";
227
+ },
228
+ ) {
229
+ const client = getClient(config);
230
+ const queries = createCommentsQueryKeys(client, config.headers);
231
+
232
+ const query = useQuery({
233
+ ...queries.commentCount.byResource(params),
234
+ staleTime: 60_000,
235
+ retry: false,
236
+ });
237
+
238
+ return {
239
+ count: query.data ?? 0,
240
+ isLoading: query.isLoading,
241
+ error: query.error,
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Post a new comment with optimistic update.
247
+ * When autoApprove is false the optimistic entry shows as "pending" — visible
248
+ * only to the comment's own author via the `currentUserId` match in the UI.
249
+ *
250
+ * Pass `infiniteKey` (from `useInfiniteComments`) when the thread uses an
251
+ * infinite query so the optimistic update targets InfiniteData<CommentListResult>
252
+ * instead of a plain CommentListResult cache entry.
253
+ */
254
+ export function usePostComment(
255
+ config: CommentsClientConfig,
256
+ params: {
257
+ resourceId: string;
258
+ resourceType: string;
259
+ currentUserId?: string;
260
+ /** When provided, optimistic updates target this infinite-query cache key. */
261
+ infiniteKey?: readonly unknown[];
262
+ /**
263
+ * Page size used by the corresponding `useInfiniteComments` call.
264
+ * Used only when the infinite-query cache is empty at the time of the
265
+ * optimistic update — ensures `getNextPageParam` computes the correct
266
+ * `nextOffset` from `lastPage.limit` instead of a hardcoded fallback.
267
+ */
268
+ pageSize?: number;
269
+ },
270
+ ) {
271
+ const queryClient = useQueryClient();
272
+ const client = getClient(config);
273
+ const queries = createCommentsQueryKeys(client, config.headers);
274
+
275
+ // Compute the list key for a given parentId so optimistic updates always
276
+ // target the exact cache entry the component is subscribed to.
277
+ // parentId must be normalised to null (not undefined) because useComments
278
+ // passes `parentId: null` explicitly — null and undefined produce different
279
+ // discriminator objects and therefore different React Query cache keys.
280
+ const getListKey = (
281
+ parentId: string | null | undefined,
282
+ offset?: number,
283
+ limit?: number,
284
+ ) => {
285
+ // Top-level posts for a thread using useInfiniteComments get the infinite key.
286
+ if (params.infiniteKey && (parentId ?? null) === null) {
287
+ return params.infiniteKey;
288
+ }
289
+ return queries.comments.list({
290
+ resourceId: params.resourceId,
291
+ resourceType: params.resourceType,
292
+ parentId: parentId ?? null,
293
+ status: "approved",
294
+ currentUserId: params.currentUserId,
295
+ limit,
296
+ offset,
297
+ }).queryKey;
298
+ };
299
+
300
+ const isInfinitePost = (parentId: string | null | undefined) =>
301
+ !!params.infiniteKey && (parentId ?? null) === null;
302
+
303
+ return useMutation({
304
+ mutationFn: async (input: {
305
+ body: string;
306
+ parentId?: string | null;
307
+ limit?: number;
308
+ offset?: number;
309
+ }) => {
310
+ const response = await client("@post/comments", {
311
+ method: "POST",
312
+ body: {
313
+ resourceId: params.resourceId,
314
+ resourceType: params.resourceType,
315
+ parentId: input.parentId ?? null,
316
+ body: input.body,
317
+ },
318
+ headers: config.headers,
319
+ });
320
+
321
+ const data = (response as { data?: unknown }).data;
322
+ if (!data) throw toError((response as { error?: unknown }).error);
323
+ return data as SerializedComment;
324
+ },
325
+ onMutate: async (input) => {
326
+ const listKey = getListKey(input.parentId, input.offset, input.limit);
327
+ await queryClient.cancelQueries({ queryKey: listKey });
328
+
329
+ // Optimistic comment — shows to own author with "pending" badge
330
+ const optimisticId = `optimistic-${Date.now()}`;
331
+ const optimistic: SerializedComment = {
332
+ id: optimisticId,
333
+ resourceId: params.resourceId,
334
+ resourceType: params.resourceType,
335
+ parentId: input.parentId ?? null,
336
+ authorId: params.currentUserId ?? "",
337
+ resolvedAuthorName: "You",
338
+ resolvedAvatarUrl: null,
339
+ body: input.body,
340
+ status: "pending",
341
+ likes: 0,
342
+ isLikedByCurrentUser: false,
343
+ editedAt: null,
344
+ createdAt: new Date().toISOString(),
345
+ updatedAt: new Date().toISOString(),
346
+ replyCount: 0,
347
+ };
348
+
349
+ if (isInfinitePost(input.parentId)) {
350
+ const previous =
351
+ queryClient.getQueryData<InfiniteData<CommentListResult>>(listKey);
352
+
353
+ queryClient.setQueryData<InfiniteData<CommentListResult>>(
354
+ listKey,
355
+ (old) => {
356
+ if (!old) {
357
+ return {
358
+ pages: [
359
+ {
360
+ items: [optimistic],
361
+ total: 1,
362
+ limit: params.pageSize ?? 10,
363
+ offset: 0,
364
+ },
365
+ ],
366
+ pageParams: [0],
367
+ };
368
+ }
369
+ const lastIdx = old.pages.length - 1;
370
+ return {
371
+ ...old,
372
+ // Increment `total` on every page so the header count (which reads
373
+ // pages[0].total) stays in sync even after multiple pages are loaded.
374
+ pages: old.pages.map((page, idx) =>
375
+ idx === lastIdx
376
+ ? {
377
+ ...page,
378
+ items: [...page.items, optimistic],
379
+ total: page.total + 1,
380
+ }
381
+ : { ...page, total: page.total + 1 },
382
+ ),
383
+ };
384
+ },
385
+ );
386
+
387
+ return { previous, isInfinite: true as const, listKey, optimisticId };
388
+ }
389
+
390
+ const previous = queryClient.getQueryData<CommentListResult>(listKey);
391
+ queryClient.setQueryData<CommentListResult>(listKey, (old) => {
392
+ if (!old) {
393
+ return { items: [optimistic], total: 1, limit: 20, offset: 0 };
394
+ }
395
+ return {
396
+ ...old,
397
+ items: [...old.items, optimistic],
398
+ total: old.total + 1,
399
+ };
400
+ });
401
+
402
+ return { previous, isInfinite: false as const, listKey, optimisticId };
403
+ },
404
+ onSuccess: (data, _input, context) => {
405
+ if (!context) return;
406
+ // Replace the optimistic item with the real server response.
407
+ // The server may return status "pending" (autoApprove: false) or "approved"
408
+ // (autoApprove: true). Either way we keep the item in the cache so the
409
+ // author continues to see their comment — with a "Pending approval" badge
410
+ // when pending.
411
+ //
412
+ // For replies (non-infinite path): do NOT call invalidateQueries here.
413
+ // The setQueryData below already puts the authoritative server response
414
+ // (including the pending reply) in the cache. Invalidating would trigger
415
+ // a background refetch that goes to the server without auth context and
416
+ // returns only approved replies — overwriting the cache and making the
417
+ // pending reply disappear.
418
+ if (context.isInfinite) {
419
+ queryClient.setQueryData<InfiniteData<CommentListResult>>(
420
+ context.listKey,
421
+ (old) => {
422
+ if (!old) {
423
+ // Cache was cleared between onMutate and onSuccess (rare).
424
+ // Seed with the real server response so the thread keeps the new comment.
425
+ return {
426
+ pages: [
427
+ {
428
+ items: [data],
429
+ total: 1,
430
+ limit: _input.limit ?? params.pageSize ?? 10,
431
+ offset: _input.offset ?? 0,
432
+ },
433
+ ],
434
+ pageParams: [_input.offset ?? 0],
435
+ };
436
+ }
437
+ return {
438
+ ...old,
439
+ pages: old.pages.map((page) => ({
440
+ ...page,
441
+ items: page.items.map((item) =>
442
+ item.id === context.optimisticId ? data : item,
443
+ ),
444
+ })),
445
+ };
446
+ },
447
+ );
448
+ } else {
449
+ queryClient.setQueryData<CommentListResult>(context.listKey, (old) => {
450
+ if (!old) {
451
+ // Cache was cleared between onMutate and onSuccess (rare).
452
+ // Seed it with the real server response so the reply stays visible.
453
+ return {
454
+ items: [data],
455
+ total: 1,
456
+ limit: _input.limit ?? params.pageSize ?? 20,
457
+ offset: _input.offset ?? 0,
458
+ };
459
+ }
460
+ return {
461
+ ...old,
462
+ items: old.items.map((item) =>
463
+ item.id === context.optimisticId ? data : item,
464
+ ),
465
+ };
466
+ });
467
+ }
468
+ },
469
+ onError: (_err, _input, context) => {
470
+ if (!context) return;
471
+ queryClient.setQueryData(context.listKey, context.previous);
472
+ },
473
+ });
474
+ }
475
+
476
+ /**
477
+ * Edit the body of an existing comment.
478
+ */
479
+ export function useUpdateComment(config: CommentsClientConfig) {
480
+ const queryClient = useQueryClient();
481
+ const client = getClient(config);
482
+ const queries = createCommentsQueryKeys(client, config.headers);
483
+
484
+ return useMutation({
485
+ mutationFn: async (input: { id: string; body: string }) => {
486
+ const response = await client("@patch/comments/:id", {
487
+ method: "PATCH",
488
+ params: { id: input.id },
489
+ body: { body: input.body },
490
+ headers: config.headers,
491
+ });
492
+ const data = (response as { data?: unknown }).data;
493
+ if (!data) throw toError((response as { error?: unknown }).error);
494
+ return data as SerializedComment;
495
+ },
496
+ onSettled: () => {
497
+ queryClient.invalidateQueries({
498
+ queryKey: queries.comments.list._def,
499
+ });
500
+ // Also invalidate the infinite thread cache so edits are reflected there.
501
+ queryClient.invalidateQueries({ queryKey: ["commentsThread"] });
502
+ },
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Approve a comment (set status to "approved"). Admin use.
508
+ */
509
+ export function useApproveComment(config: CommentsClientConfig) {
510
+ const queryClient = useQueryClient();
511
+ const client = getClient(config);
512
+ const queries = createCommentsQueryKeys(client, config.headers);
513
+
514
+ return useMutation({
515
+ mutationFn: async (id: string) => {
516
+ const response = await client("@patch/comments/:id/status", {
517
+ method: "PATCH",
518
+ params: { id },
519
+ body: { status: "approved" },
520
+ headers: config.headers,
521
+ });
522
+ const data = (response as { data?: unknown }).data;
523
+ if (!data) throw toError((response as { error?: unknown }).error);
524
+ return data as SerializedComment;
525
+ },
526
+ onSettled: () => {
527
+ queryClient.invalidateQueries({
528
+ queryKey: queries.comments.list._def,
529
+ });
530
+ queryClient.invalidateQueries({
531
+ queryKey: queries.commentCount.byResource._def,
532
+ });
533
+ // Also invalidate the infinite thread cache so status changes are reflected there.
534
+ queryClient.invalidateQueries({ queryKey: ["commentsThread"] });
535
+ },
536
+ });
537
+ }
538
+
539
+ /**
540
+ * Update comment status (pending / approved / spam). Admin use.
541
+ */
542
+ export function useUpdateCommentStatus(config: CommentsClientConfig) {
543
+ const queryClient = useQueryClient();
544
+ const client = getClient(config);
545
+ const queries = createCommentsQueryKeys(client, config.headers);
546
+
547
+ return useMutation({
548
+ mutationFn: async (input: {
549
+ id: string;
550
+ status: "pending" | "approved" | "spam";
551
+ }) => {
552
+ const response = await client("@patch/comments/:id/status", {
553
+ method: "PATCH",
554
+ params: { id: input.id },
555
+ body: { status: input.status },
556
+ headers: config.headers,
557
+ });
558
+ const data = (response as { data?: unknown }).data;
559
+ if (!data) throw toError((response as { error?: unknown }).error);
560
+ return data as SerializedComment;
561
+ },
562
+ onSettled: () => {
563
+ queryClient.invalidateQueries({
564
+ queryKey: queries.comments.list._def,
565
+ });
566
+ queryClient.invalidateQueries({
567
+ queryKey: queries.commentCount.byResource._def,
568
+ });
569
+ // Also invalidate the infinite thread cache so status changes are reflected there.
570
+ queryClient.invalidateQueries({ queryKey: ["commentsThread"] });
571
+ },
572
+ });
573
+ }
574
+
575
+ /**
576
+ * Delete a comment. Admin use.
577
+ */
578
+ export function useDeleteComment(config: CommentsClientConfig) {
579
+ const queryClient = useQueryClient();
580
+ const client = getClient(config);
581
+ const queries = createCommentsQueryKeys(client, config.headers);
582
+
583
+ return useMutation({
584
+ mutationFn: async (id: string) => {
585
+ const response = await client("@delete/comments/:id", {
586
+ method: "DELETE",
587
+ params: { id },
588
+ headers: config.headers,
589
+ });
590
+ const data = (response as { data?: unknown }).data;
591
+ if (!data) throw toError((response as { error?: unknown }).error);
592
+ return data as { success: boolean };
593
+ },
594
+ onSettled: () => {
595
+ queryClient.invalidateQueries({
596
+ queryKey: queries.comments.list._def,
597
+ });
598
+ queryClient.invalidateQueries({
599
+ queryKey: queries.commentCount.byResource._def,
600
+ });
601
+ // Also invalidate the infinite thread cache so deletions are reflected there.
602
+ queryClient.invalidateQueries({ queryKey: ["commentsThread"] });
603
+ },
604
+ });
605
+ }
606
+
607
+ /**
608
+ * Toggle a like on a comment with optimistic update.
609
+ *
610
+ * Pass `infiniteKey` (from `useInfiniteComments`) for top-level thread comments
611
+ * so the optimistic update targets InfiniteData<CommentListResult> instead of
612
+ * a plain CommentListResult cache entry.
613
+ */
614
+ export function useToggleLike(
615
+ config: CommentsClientConfig,
616
+ params: {
617
+ resourceId: string;
618
+ resourceType: string;
619
+ /** parentId of the comment being liked — must match the parentId used by
620
+ * useComments so the optimistic setQueryData hits the correct cache entry.
621
+ * Pass `null` for top-level comments, or the parent comment ID for replies. */
622
+ parentId?: string | null;
623
+ currentUserId?: string;
624
+ /** When the comment lives in an infinite thread, pass the thread's query key
625
+ * so the optimistic update targets the correct InfiniteData cache entry. */
626
+ infiniteKey?: readonly unknown[];
627
+ },
628
+ ) {
629
+ const queryClient = useQueryClient();
630
+ const client = getClient(config);
631
+ const queries = createCommentsQueryKeys(client, config.headers);
632
+
633
+ // For top-level thread comments use the infinite key; for replies (or when no
634
+ // infinite key is supplied) fall back to the regular list cache entry.
635
+ const isInfinite = !!params.infiniteKey && (params.parentId ?? null) === null;
636
+ const listKey = isInfinite
637
+ ? params.infiniteKey!
638
+ : queries.comments.list({
639
+ resourceId: params.resourceId,
640
+ resourceType: params.resourceType,
641
+ parentId: params.parentId ?? null,
642
+ status: "approved",
643
+ currentUserId: params.currentUserId,
644
+ }).queryKey;
645
+
646
+ function applyLikeUpdate(
647
+ commentId: string,
648
+ updater: (c: SerializedComment) => SerializedComment,
649
+ ) {
650
+ if (isInfinite) {
651
+ queryClient.setQueryData<InfiniteData<CommentListResult>>(
652
+ listKey,
653
+ (old) => {
654
+ if (!old) return old;
655
+ return {
656
+ ...old,
657
+ pages: old.pages.map((page) => ({
658
+ ...page,
659
+ items: page.items.map((c) =>
660
+ c.id === commentId ? updater(c) : c,
661
+ ),
662
+ })),
663
+ };
664
+ },
665
+ );
666
+ } else {
667
+ queryClient.setQueryData<CommentListResult>(listKey, (old) => {
668
+ if (!old) return old;
669
+ return {
670
+ ...old,
671
+ items: old.items.map((c) => (c.id === commentId ? updater(c) : c)),
672
+ };
673
+ });
674
+ }
675
+ }
676
+
677
+ return useMutation({
678
+ mutationFn: async (input: { commentId: string; authorId: string }) => {
679
+ const response = await client("@post/comments/:id/like", {
680
+ method: "POST",
681
+ params: { id: input.commentId },
682
+ body: { authorId: input.authorId },
683
+ headers: config.headers,
684
+ });
685
+ const data = (response as { data?: unknown }).data;
686
+ if (!data) throw toError((response as { error?: unknown }).error);
687
+ return data as { likes: number; isLiked: boolean };
688
+ },
689
+ onMutate: async (input) => {
690
+ await queryClient.cancelQueries({ queryKey: listKey });
691
+
692
+ // Snapshot previous state for rollback.
693
+ const previous = isInfinite
694
+ ? queryClient.getQueryData<InfiniteData<CommentListResult>>(listKey)
695
+ : queryClient.getQueryData<CommentListResult>(listKey);
696
+
697
+ applyLikeUpdate(input.commentId, (c) => {
698
+ const wasLiked = c.isLikedByCurrentUser;
699
+ return {
700
+ ...c,
701
+ isLikedByCurrentUser: !wasLiked,
702
+ likes: wasLiked ? Math.max(0, c.likes - 1) : c.likes + 1,
703
+ };
704
+ });
705
+
706
+ return { previous };
707
+ },
708
+ onError: (_err, _input, context) => {
709
+ if (context?.previous !== undefined) {
710
+ queryClient.setQueryData(listKey, context.previous);
711
+ }
712
+ },
713
+ onSettled: () => {
714
+ queryClient.invalidateQueries({ queryKey: listKey });
715
+ },
716
+ });
717
+ }