create-feedbax 0.1.0 → 0.2.1

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 (251) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +3283 -22
  4. package/dist/index.js +3394 -160
  5. package/package.json +40 -14
  6. package/template/.feedbax-template.sha256 +1 -0
  7. package/template/.gitignore.template +6 -0
  8. package/template/.npmignore.template +5 -0
  9. package/template/.prettierignore.template +4 -0
  10. package/template/AGENTS.md +13 -0
  11. package/template/CONNECTED.md +404 -0
  12. package/template/LICENSE +201 -0
  13. package/template/README.md +56 -5
  14. package/template/components.json +25 -0
  15. package/template/eslint.config.mjs +48 -0
  16. package/template/migrations/0001_connected.sql +6 -0
  17. package/template/migrations/0002_participation.sql +8 -0
  18. package/template/migrations/0003_images.sql +3 -0
  19. package/template/migrations/0004_followed_status.sql +9 -0
  20. package/template/package.json +74 -14
  21. package/template/public/feedbax-mark.svg +4 -0
  22. package/template/public/robots.txt +3 -0
  23. package/template/src/authorized-draft-post.test.tsx +212 -0
  24. package/template/src/authorized-draft-post.tsx +205 -0
  25. package/template/src/authorized-draft-query.ts +74 -0
  26. package/template/src/browser-post-state.test.ts +120 -0
  27. package/template/src/browser-post-state.ts +134 -0
  28. package/template/src/browser-vote-state.test.ts +14 -0
  29. package/template/src/browser-vote-state.ts +27 -0
  30. package/template/src/capability-authorization-error.ts +3 -0
  31. package/template/src/changelog-page.test.tsx +139 -0
  32. package/template/src/changelog-page.tsx +228 -0
  33. package/template/src/changelog-query.ts +13 -0
  34. package/template/src/changelog-runtime.ts +27 -0
  35. package/template/src/cloudflare-turnstile.ts +31 -0
  36. package/template/src/cloudflare-workers-test.ts +3 -0
  37. package/template/src/cloudflare-workers.d.ts +24 -0
  38. package/template/src/comment-creation.test.tsx +311 -0
  39. package/template/src/comment-profile.ts +5 -0
  40. package/template/src/comment-thread-page.ts +28 -0
  41. package/template/src/components/command-palette-context.tsx +71 -0
  42. package/template/src/components/command-palette.tsx +275 -0
  43. package/template/src/components/connected-identity-provider.tsx +108 -0
  44. package/template/src/components/create-post-trigger.tsx +44 -0
  45. package/template/src/components/device-profile-provider.tsx +324 -0
  46. package/template/src/components/follow-post-button.tsx +89 -0
  47. package/template/src/components/header.tsx +97 -0
  48. package/template/src/components/image-attachments.tsx +187 -0
  49. package/template/src/components/loader.tsx +53 -0
  50. package/template/src/components/notification-inbox.tsx +232 -0
  51. package/template/src/components/portal-footer.tsx +14 -0
  52. package/template/src/components/profile-menu.tsx +119 -0
  53. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  54. package/template/src/components/ui/components/attachment.tsx +207 -0
  55. package/template/src/components/ui/components/avatar.tsx +107 -0
  56. package/template/src/components/ui/components/badge.tsx +52 -0
  57. package/template/src/components/ui/components/bubble.tsx +128 -0
  58. package/template/src/components/ui/components/button-group.tsx +87 -0
  59. package/template/src/components/ui/components/button.tsx +56 -0
  60. package/template/src/components/ui/components/card.tsx +103 -0
  61. package/template/src/components/ui/components/checkbox.tsx +26 -0
  62. package/template/src/components/ui/components/command.tsx +195 -0
  63. package/template/src/components/ui/components/dialog.tsx +154 -0
  64. package/template/src/components/ui/components/drawer.tsx +229 -0
  65. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  66. package/template/src/components/ui/components/empty.tsx +101 -0
  67. package/template/src/components/ui/components/field.tsx +236 -0
  68. package/template/src/components/ui/components/input-group.tsx +155 -0
  69. package/template/src/components/ui/components/input.tsx +20 -0
  70. package/template/src/components/ui/components/item.tsx +201 -0
  71. package/template/src/components/ui/components/kbd.tsx +26 -0
  72. package/template/src/components/ui/components/label.tsx +18 -0
  73. package/template/src/components/ui/components/marker.tsx +71 -0
  74. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  75. package/template/src/components/ui/components/message.tsx +92 -0
  76. package/template/src/components/ui/components/native-select.tsx +65 -0
  77. package/template/src/components/ui/components/popover.tsx +88 -0
  78. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  79. package/template/src/components/ui/components/radio-group.tsx +36 -0
  80. package/template/src/components/ui/components/scroll-area.tsx +51 -0
  81. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  82. package/template/src/components/ui/components/select.tsx +199 -0
  83. package/template/src/components/ui/components/separator.tsx +23 -0
  84. package/template/src/components/ui/components/sheet.tsx +135 -0
  85. package/template/src/components/ui/components/skeleton.tsx +13 -0
  86. package/template/src/components/ui/components/sonner.tsx +43 -0
  87. package/template/src/components/ui/components/spinner.tsx +16 -0
  88. package/template/src/components/ui/components/switch.tsx +29 -0
  89. package/template/src/components/ui/components/tabs.tsx +80 -0
  90. package/template/src/components/ui/components/textarea.tsx +18 -0
  91. package/template/src/components/ui/components/tooltip.tsx +64 -0
  92. package/template/src/components/ui/hooks/.gitkeep +0 -0
  93. package/template/src/components/ui/lib/utils.ts +6 -0
  94. package/template/src/components/ui/styles/globals.css +151 -0
  95. package/template/src/connected-identity.test.ts +144 -0
  96. package/template/src/connected-identity.ts +122 -0
  97. package/template/src/connected-participation.test.tsx +67 -0
  98. package/template/src/connected-runtime.test.ts +241 -0
  99. package/template/src/connected-runtime.ts +176 -0
  100. package/template/src/connected-social.test.tsx +158 -0
  101. package/template/src/create-post-overlay.tsx +82 -0
  102. package/template/src/draft-post-list.tsx +39 -0
  103. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  104. package/template/src/feedback-cache-invalidation.ts +98 -0
  105. package/template/src/feedback-runtime.ts +105 -0
  106. package/template/src/feedbax/changelog/index.test.ts +156 -0
  107. package/template/src/feedbax/changelog/index.ts +206 -0
  108. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  109. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  110. package/template/src/feedbax/config/index.test.ts +139 -0
  111. package/template/src/feedbax/config/index.ts +364 -0
  112. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  113. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  114. package/template/src/feedbax/feedback/connected.ts +215 -0
  115. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  116. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  117. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  118. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  119. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  120. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  121. package/template/src/feedbax/feedback/images.test.ts +223 -0
  122. package/template/src/feedbax/feedback/images.ts +324 -0
  123. package/template/src/feedbax/feedback/index.test.ts +900 -0
  124. package/template/src/feedbax/feedback/index.ts +996 -0
  125. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  126. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  127. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  128. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  129. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  130. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  131. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  132. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  133. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  134. package/template/src/feedbax/feedback/participation.ts +502 -0
  135. package/template/src/feedbax.test-fixture.ts +64 -0
  136. package/template/src/feedbax.ts +69 -0
  137. package/template/src/images-runtime.test.ts +133 -0
  138. package/template/src/images-runtime.ts +125 -0
  139. package/template/src/index.css +1795 -0
  140. package/template/src/lazy-create-post-overlay.tsx +6 -0
  141. package/template/src/lazy-post-detail-overlay.tsx +21 -0
  142. package/template/src/masked-post-link.tsx +27 -0
  143. package/template/src/overlay-post-detail.test.tsx +181 -0
  144. package/template/src/overlay-post-detail.tsx +43 -0
  145. package/template/src/participation-client.ts +63 -0
  146. package/template/src/participation-runtime.test.ts +35 -0
  147. package/template/src/participation-runtime.ts +255 -0
  148. package/template/src/portal-comments.test.ts +375 -0
  149. package/template/src/portal-comments.ts +403 -0
  150. package/template/src/portal-features.test.tsx +150 -0
  151. package/template/src/portal-feedback-form.tsx +530 -0
  152. package/template/src/portal-feedback-runtime.test.ts +32 -0
  153. package/template/src/portal-feedback-runtime.ts +58 -0
  154. package/template/src/portal-feedback-server-function.ts +96 -0
  155. package/template/src/portal-feedback-submission.test.ts +307 -0
  156. package/template/src/portal-feedback-submission.ts +224 -0
  157. package/template/src/portal-head.ts +58 -0
  158. package/template/src/portal-server-entry.ts +68 -0
  159. package/template/src/portal-voting.test.ts +106 -0
  160. package/template/src/portal-voting.ts +149 -0
  161. package/template/src/post-badges.tsx +27 -0
  162. package/template/src/post-creation-flow.test.tsx +230 -0
  163. package/template/src/post-creation-flow.tsx +88 -0
  164. package/template/src/post-detail-content.test.tsx +233 -0
  165. package/template/src/post-detail-content.tsx +162 -0
  166. package/template/src/post-detail-overlay.test.tsx +114 -0
  167. package/template/src/post-detail-overlay.tsx +67 -0
  168. package/template/src/post-detail-query.ts +34 -0
  169. package/template/src/post-feed-controls.test.tsx +239 -0
  170. package/template/src/post-feed-controls.tsx +298 -0
  171. package/template/src/post-presentation.ts +37 -0
  172. package/template/src/post-queries.test.ts +158 -0
  173. package/template/src/post-queries.ts +26 -0
  174. package/template/src/post-results.tsx +165 -0
  175. package/template/src/public-cache-policy.test.ts +148 -0
  176. package/template/src/public-cache-policy.ts +119 -0
  177. package/template/src/public-cache-tags.ts +7 -0
  178. package/template/src/public-changelog-page.ts +15 -0
  179. package/template/src/public-changelog-server-function.ts +27 -0
  180. package/template/src/public-date.ts +11 -0
  181. package/template/src/public-failure-state.test.tsx +69 -0
  182. package/template/src/public-feedback-index.tsx +94 -0
  183. package/template/src/public-feedback-page.test.tsx +355 -0
  184. package/template/src/public-feedback-page.ts +86 -0
  185. package/template/src/public-feedback-server-function.ts +22 -0
  186. package/template/src/public-portal-error.tsx +27 -0
  187. package/template/src/public-post-detail-skeleton.tsx +54 -0
  188. package/template/src/public-post-detail.tsx +772 -0
  189. package/template/src/public-post-page.test.tsx +173 -0
  190. package/template/src/public-post-page.ts +15 -0
  191. package/template/src/public-post-route.test.tsx +250 -0
  192. package/template/src/public-post-route.tsx +158 -0
  193. package/template/src/public-post-server-function.ts +48 -0
  194. package/template/src/public-post-unavailable.tsx +10 -0
  195. package/template/src/public-roadmap-page.test.tsx +139 -0
  196. package/template/src/public-roadmap-page.ts +24 -0
  197. package/template/src/public-roadmap-route.test.tsx +254 -0
  198. package/template/src/public-roadmap-route.tsx +50 -0
  199. package/template/src/public-roadmap-server-function.ts +44 -0
  200. package/template/src/public-roadmap-view.tsx +411 -0
  201. package/template/src/public-shell.test.tsx +544 -0
  202. package/template/src/roadmap-query.ts +21 -0
  203. package/template/src/root-branding.test.ts +53 -0
  204. package/template/src/routeTree.gen.ts +177 -0
  205. package/template/src/routed-post-creation-form.tsx +28 -0
  206. package/template/src/router.tsx +27 -0
  207. package/template/src/routes/__root.tsx +114 -0
  208. package/template/src/routes/changelog.tsx +39 -0
  209. package/template/src/routes/email-preferences.tsx +77 -0
  210. package/template/src/routes/index.tsx +61 -0
  211. package/template/src/routes/p.$slug.tsx +16 -0
  212. package/template/src/routes/roadmap.tsx +11 -0
  213. package/template/src/routes/submit.tsx +28 -0
  214. package/template/src/safe-public-failure.ts +47 -0
  215. package/template/src/server.ts +151 -0
  216. package/template/src/similar-posts.tsx +45 -0
  217. package/template/src/submit-route.test.tsx +134 -0
  218. package/template/src/trusted-feedback-handler.test.ts +425 -0
  219. package/template/src/trusted-feedback-handler.ts +181 -0
  220. package/template/src/trusted-feedback-runtime.ts +17 -0
  221. package/template/src/turnstile-configuration.test.ts +37 -0
  222. package/template/src/turnstile-configuration.ts +19 -0
  223. package/template/src/use-media-query.ts +17 -0
  224. package/template/src/vote-toggle.test.tsx +171 -0
  225. package/template/src/vote-toggle.tsx +244 -0
  226. package/template/tsconfig.json +45 -0
  227. package/template/vite.config.ts +20 -0
  228. package/template/vitest.config.ts +4 -0
  229. package/template/vitest.setup.ts +3 -0
  230. package/template/wrangler.jsonc +35 -0
  231. package/dist/cli.d.ts +0 -3
  232. package/dist/cli.d.ts.map +0 -1
  233. package/dist/cli.js.map +0 -1
  234. package/dist/index.d.ts +0 -21
  235. package/dist/index.d.ts.map +0 -1
  236. package/dist/index.js.map +0 -1
  237. package/template/.env.example +0 -4
  238. package/template/apps/portal/components.json +0 -20
  239. package/template/apps/portal/package.json +0 -31
  240. package/template/apps/portal/src/lib/utils.ts +0 -6
  241. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  242. package/template/apps/portal/src/router.tsx +0 -10
  243. package/template/apps/portal/src/routes/__root.tsx +0 -31
  244. package/template/apps/portal/src/routes/index.tsx +0 -35
  245. package/template/apps/portal/src/styles.css +0 -91
  246. package/template/apps/portal/tsconfig.json +0 -15
  247. package/template/apps/portal/vite.config.ts +0 -13
  248. package/template/feedbax.config.ts +0 -13
  249. package/template/feedbax.jsonc +0 -11
  250. package/template/pnpm-workspace.yaml +0 -3
  251. package/template/turbo.json +0 -8
@@ -0,0 +1,233 @@
1
+ // @vitest-environment jsdom
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from "@testing-library/react";
10
+ import { afterEach, expect, it, vi } from "vitest";
11
+ import { PostDetailContent } from "./post-detail-content";
12
+ import { saveDeviceProfile } from "./browser-post-state";
13
+
14
+ const post = {
15
+ slug: "comment-cache",
16
+ title: "Comment cache",
17
+ description: "Feedback",
18
+ type: "Feature Request" as const,
19
+ status: "Planned" as const,
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ };
23
+ const author = { kind: "participant" as const, displayName: "Ama" };
24
+ afterEach(() => {
25
+ cleanup();
26
+ localStorage.clear();
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ it.each(["edit", "delete"] as const)(
31
+ "preserves a confirmed Comment %s when another page arrives",
32
+ async (action) => {
33
+ saveDeviceProfile(localStorage, { name: "Ama", email: "ama@example.com" });
34
+ localStorage.setItem(
35
+ "feedbax:comment-capabilities",
36
+ JSON.stringify({
37
+ "comment-1": {
38
+ capability: "capability",
39
+ expiresAt: Date.now() + 60000,
40
+ },
41
+ }),
42
+ );
43
+ let state: "original" | "edit" | "delete" = "original";
44
+ const loadComments = vi.fn(async (_slug: string, cursor?: string) =>
45
+ cursor
46
+ ? {
47
+ items: [
48
+ {
49
+ id: "thread-2",
50
+ comments: [
51
+ {
52
+ id: "comment-2",
53
+ body: "Second page",
54
+ author,
55
+ createdAt: new Date(),
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ }
61
+ : {
62
+ items:
63
+ state === "delete"
64
+ ? []
65
+ : [
66
+ {
67
+ id: "thread-1",
68
+ comments: [
69
+ {
70
+ id: "comment-1",
71
+ body:
72
+ state === "edit"
73
+ ? "Corrected message"
74
+ : "Original message",
75
+ author,
76
+ createdAt: new Date(),
77
+ },
78
+ ],
79
+ },
80
+ ],
81
+ nextCursor: "next",
82
+ },
83
+ );
84
+ const mutateComment = vi.fn(async () => {
85
+ state = action;
86
+ });
87
+ vi.spyOn(window, "confirm").mockReturnValue(true);
88
+ const client = new QueryClient({
89
+ defaultOptions: { queries: { retry: false } },
90
+ });
91
+ render(
92
+ <QueryClientProvider client={client}>
93
+ <PostDetailContent
94
+ post={post}
95
+ loadComments={loadComments}
96
+ mutateComment={mutateComment}
97
+ />
98
+ </QueryClientProvider>,
99
+ );
100
+ expect(await screen.findByText("Original message")).toBeTruthy();
101
+ fireEvent.click(
102
+ await screen.findByRole("button", {
103
+ name: action === "edit" ? "Edit" : "Delete",
104
+ }),
105
+ );
106
+ if (action === "edit") {
107
+ fireEvent.change(screen.getByRole("textbox", { name: "Comment text" }), {
108
+ target: { value: "Corrected message" },
109
+ });
110
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
111
+ }
112
+ await waitFor(() => expect(mutateComment).toHaveBeenCalledOnce());
113
+ await waitFor(() =>
114
+ expect(screen.queryByText("Original message")).toBeNull(),
115
+ );
116
+ const more = await screen.findByRole("button", {
117
+ name: "Load more",
118
+ });
119
+ await waitFor(() => expect(more.hasAttribute("disabled")).toBe(false));
120
+ fireEvent.click(more);
121
+ expect(await screen.findByText("Second page")).toBeTruthy();
122
+ expect(screen.queryByText("Original message")).toBeNull();
123
+ expect(screen.queryAllByText("Corrected message")).toHaveLength(
124
+ action === "edit" ? 1 : 0,
125
+ );
126
+ },
127
+ );
128
+
129
+ it("removes a Comment resolved in Notion when the shared query refreshes", async () => {
130
+ let visible = true;
131
+ const loadComments = vi.fn(async () => ({
132
+ items: visible
133
+ ? [
134
+ {
135
+ id: "thread-1",
136
+ comments: [
137
+ {
138
+ id: "comment-1",
139
+ body: "Resolved in Notion",
140
+ author,
141
+ createdAt: new Date(),
142
+ },
143
+ ],
144
+ },
145
+ ]
146
+ : [],
147
+ }));
148
+ const client = new QueryClient({
149
+ defaultOptions: { queries: { retry: false } },
150
+ });
151
+ render(
152
+ <QueryClientProvider client={client}>
153
+ <PostDetailContent post={post} loadComments={loadComments} />
154
+ </QueryClientProvider>,
155
+ );
156
+ expect(await screen.findByText("Resolved in Notion")).toBeTruthy();
157
+ visible = false;
158
+ void client.invalidateQueries({
159
+ queryKey: ["public-post-comments", post.slug],
160
+ });
161
+ await waitFor(() =>
162
+ expect(screen.queryByText("Resolved in Notion")).toBeNull(),
163
+ );
164
+ expect(screen.getByText("No comments yet")).toBeTruthy();
165
+ });
166
+
167
+ it("keeps private edit capabilities out of the shared public Comment cache", async () => {
168
+ saveDeviceProfile(localStorage, { name: "Ama", email: "ama@example.com" });
169
+ const confirmed = {
170
+ id: "comment-1",
171
+ discussionId: "thread-1",
172
+ body: "Public message",
173
+ author,
174
+ createdAt: new Date(),
175
+ commentCapability: "private-edit-capability",
176
+ commentCapabilityExpiresAt: Date.now() + 60000,
177
+ };
178
+ let submitted = false;
179
+ const loadComments = vi.fn(async () => ({
180
+ items: submitted
181
+ ? [
182
+ {
183
+ id: "thread-1",
184
+ comments: [
185
+ {
186
+ id: confirmed.id,
187
+ body: confirmed.body,
188
+ author,
189
+ createdAt: confirmed.createdAt,
190
+ },
191
+ ],
192
+ },
193
+ ]
194
+ : [],
195
+ }));
196
+ const client = new QueryClient({
197
+ defaultOptions: { queries: { retry: false } },
198
+ });
199
+ const snapshots: string[] = [];
200
+ const unsubscribe = client.getQueryCache().subscribe((event) => {
201
+ snapshots.push(JSON.stringify(event.query.state.data));
202
+ });
203
+ render(
204
+ <QueryClientProvider client={client}>
205
+ <PostDetailContent
206
+ post={post}
207
+ loadComments={loadComments}
208
+ submitComment={async () => {
209
+ submitted = true;
210
+ return confirmed;
211
+ }}
212
+ />
213
+ </QueryClientProvider>,
214
+ );
215
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
216
+ target: { value: confirmed.body },
217
+ });
218
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
219
+ expect(await screen.findByText(confirmed.body)).toBeTruthy();
220
+ await waitFor(() =>
221
+ expect(localStorage.getItem("feedbax:comment-capabilities")).toContain(
222
+ confirmed.commentCapability,
223
+ ),
224
+ );
225
+ const cached = JSON.stringify(
226
+ client.getQueryData(["public-post-comments", post.slug]),
227
+ );
228
+ expect(cached).toContain(confirmed.body);
229
+ expect(cached).not.toContain("commentCapability");
230
+ expect(cached).not.toContain(confirmed.commentCapability);
231
+ expect(snapshots.join("\n")).not.toContain(confirmed.commentCapability);
232
+ unsubscribe();
233
+ });
@@ -0,0 +1,162 @@
1
+ import type { CommentThreadPage, CreatedComment } from "@feedbax/feedback";
2
+ import { type ComponentProps, useMemo } from "react";
3
+ import {
4
+ type InfiniteData,
5
+ useInfiniteQuery,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
8
+ import { mergeCommentThreads } from "./comment-thread-page";
9
+ import { PublicPostDetail } from "./public-post-detail";
10
+ import {
11
+ publicPostCommentsQuery,
12
+ type FetchPostComments,
13
+ } from "./post-detail-query";
14
+ import feedbax from "./feedbax";
15
+
16
+ type Props = Omit<
17
+ ComponentProps<typeof PublicPostDetail>,
18
+ "comments" | "loadMore"
19
+ > & { loadComments?: FetchPostComments };
20
+ type Pages = InfiniteData<CommentThreadPage, string | undefined>;
21
+ const emptyComments: FetchPostComments = async () => ({ items: [] });
22
+
23
+ export function PostDetailContent(props: Props) {
24
+ return <PostDiscussion key={props.post.slug} {...props} />;
25
+ }
26
+
27
+ function PostDiscussion({ loadComments, ...props }: Props) {
28
+ const client = useQueryClient();
29
+ const options = publicPostCommentsQuery(
30
+ props.post.slug,
31
+ loadComments ?? emptyComments,
32
+ );
33
+ const query = useInfiniteQuery({
34
+ ...options,
35
+ enabled:
36
+ (props.features ?? feedbax.features).comments && Boolean(loadComments),
37
+ });
38
+ const comments = useMemo(
39
+ () => ({
40
+ items:
41
+ query.data?.pages.reduce(
42
+ (current, page) => mergeCommentThreads(current, page.items),
43
+ [] as CommentThreadPage["items"],
44
+ ) ?? [],
45
+ }),
46
+ [query.data],
47
+ );
48
+ function updatePages(update: (pages: Pages) => Pages) {
49
+ client.setQueryData<Pages>(options.queryKey, (current) =>
50
+ update(current ?? { pages: [{ items: [] }], pageParams: [undefined] }),
51
+ );
52
+ }
53
+ return (
54
+ <PublicPostDetail
55
+ {...props}
56
+ comments={comments}
57
+ commentsLoading={query.isFetching}
58
+ commentsLoadError={query.isError}
59
+ retryComments={() => {
60
+ if (query.isFetchNextPageError) void query.fetchNextPage();
61
+ else void query.refetch();
62
+ }}
63
+ loadMore={
64
+ query.hasNextPage && !query.isError
65
+ ? async () => {
66
+ await query.fetchNextPage();
67
+ }
68
+ : undefined
69
+ }
70
+ submitComment={
71
+ props.submitComment
72
+ ? async (input) => {
73
+ await client.cancelQueries({ queryKey: options.queryKey });
74
+ try {
75
+ const confirmed = await props.submitComment!(input);
76
+ updatePages((current) => upsertComment(current, confirmed));
77
+ return confirmed;
78
+ } finally {
79
+ void client.invalidateQueries({ queryKey: options.queryKey });
80
+ }
81
+ }
82
+ : undefined
83
+ }
84
+ mutateComment={
85
+ props.mutateComment
86
+ ? async (input) => {
87
+ await client.cancelQueries({ queryKey: options.queryKey });
88
+ try {
89
+ const result = await props.mutateComment!(input);
90
+ updatePages((current) => ({
91
+ ...current,
92
+ pages: current.pages.map((page) => ({
93
+ ...page,
94
+ items: page.items
95
+ .map((thread) => ({
96
+ ...thread,
97
+ comments: thread.comments.flatMap((comment) =>
98
+ comment.id !== input.commentId
99
+ ? [comment]
100
+ : input.action === "delete"
101
+ ? []
102
+ : [{ ...comment, body: input.body! }],
103
+ ),
104
+ }))
105
+ .filter((thread) => thread.comments.length > 0),
106
+ })),
107
+ }));
108
+ return result;
109
+ } finally {
110
+ void client.invalidateQueries({ queryKey: options.queryKey });
111
+ }
112
+ }
113
+ : undefined
114
+ }
115
+ />
116
+ );
117
+ }
118
+
119
+ function upsertComment(current: Pages, comment: CreatedComment): Pages {
120
+ // Cache only the public projection; browser edit capabilities stay outside it.
121
+ const publicComment = {
122
+ id: comment.id,
123
+ body: comment.body,
124
+ ...(comment.images?.length
125
+ ? {
126
+ images: comment.images.map((image) => ({
127
+ id: image.id,
128
+ src: image.src,
129
+ contentType: image.contentType,
130
+ bytes: image.bytes,
131
+ })),
132
+ }
133
+ : {}),
134
+ createdAt: comment.createdAt,
135
+ author:
136
+ comment.author.kind === "participant"
137
+ ? {
138
+ kind: "participant" as const,
139
+ displayName: comment.author.displayName,
140
+ ...(comment.author.verified ? { verified: true } : {}),
141
+ }
142
+ : {
143
+ kind: "product-team" as const,
144
+ displayName: comment.author.displayName,
145
+ },
146
+ };
147
+ const thread = { id: comment.discussionId, comments: [publicComment] };
148
+ const present = current.pages.some((page) =>
149
+ page.items.some((item) => item.id === thread.id),
150
+ );
151
+ return {
152
+ ...current,
153
+ pages: current.pages.map((page, index) => ({
154
+ ...page,
155
+ items:
156
+ (index === 0 && !present) ||
157
+ page.items.some((item) => item.id === thread.id)
158
+ ? mergeCommentThreads(page.items, [thread])
159
+ : page.items,
160
+ })),
161
+ };
162
+ }
@@ -0,0 +1,114 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
5
+ import {
6
+ Link,
7
+ Outlet,
8
+ RouterProvider,
9
+ createMemoryHistory,
10
+ createRootRoute,
11
+ createRoute,
12
+ createRouter,
13
+ } from "@tanstack/react-router";
14
+ import { afterEach, expect, it, vi } from "vitest";
15
+
16
+ import { PostDetailOverlay } from "./post-detail-overlay";
17
+
18
+ afterEach(() => {
19
+ cleanup();
20
+ vi.unstubAllGlobals();
21
+ });
22
+
23
+ it("masks a Post detail over its originating route and Back restores that route", async () => {
24
+ const history = createMemoryHistory({ initialEntries: ["/?query=search"] });
25
+ const router = createTestRouter(history);
26
+
27
+ render(
28
+ <QueryClientProvider client={new QueryClient()}>
29
+ <RouterProvider router={router} />
30
+ </QueryClientProvider>,
31
+ );
32
+ const origin = await screen.findByRole("link", { name: "Open Post" });
33
+ origin.focus();
34
+ fireEvent.click(origin);
35
+
36
+ expect(
37
+ await screen.findByRole("dialog", { name: "Post details" }),
38
+ ).toBeTruthy();
39
+ expect(document.body.textContent).toContain("Feed remains visible");
40
+ expect(history.location.pathname).toBe("/p/masked-post");
41
+
42
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
43
+ await vi.waitFor(() => expect(history.location.pathname).toBe("/"));
44
+ expect(history.location.search).toBe("?query=search");
45
+ await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
46
+ await vi.waitFor(() => expect(document.activeElement).toBe(origin));
47
+ });
48
+
49
+ it("uses a Drawer on mobile and follows browser Back and Forward history", async () => {
50
+ vi.stubGlobal("matchMedia", (query: string) => ({
51
+ matches: query === "(max-width: 720px)",
52
+ media: query,
53
+ addEventListener: vi.fn(),
54
+ removeEventListener: vi.fn(),
55
+ }));
56
+ const history = createMemoryHistory({ initialEntries: ["/?query=search"] });
57
+ const router = createTestRouter(history);
58
+
59
+ render(
60
+ <QueryClientProvider client={new QueryClient()}>
61
+ <RouterProvider router={router} />
62
+ </QueryClientProvider>,
63
+ );
64
+ fireEvent.click(await screen.findByRole("link", { name: "Open Post" }));
65
+
66
+ const drawer = await screen.findByRole("dialog", { name: "Post details" });
67
+ expect(drawer.closest('[data-slot="drawer-popup"]')).toBeTruthy();
68
+ history.back();
69
+ await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
70
+ expect(history.location.search).toBe("?query=search");
71
+
72
+ history.forward();
73
+ expect(
74
+ await screen.findByRole("dialog", { name: "Post details" }),
75
+ ).toBeTruthy();
76
+ expect(history.location.pathname).toBe("/p/masked-post");
77
+ });
78
+
79
+ function createTestRouter(history: ReturnType<typeof createMemoryHistory>) {
80
+ const root = createRootRoute({
81
+ component: () => (
82
+ <>
83
+ <Outlet />
84
+ <PostDetailOverlay renderDetail={(slug) => <article>{slug}</article>} />
85
+ </>
86
+ ),
87
+ });
88
+ const index = createRoute({
89
+ getParentRoute: () => root,
90
+ path: "/",
91
+ component: () => (
92
+ <main>
93
+ Feed remains visible
94
+ <Link
95
+ to="."
96
+ state={{ postDetailOverlay: { slug: "masked-post" } }}
97
+ mask={{
98
+ to: "/p/$slug",
99
+ params: { slug: "masked-post" },
100
+ unmaskOnReload: true,
101
+ }}
102
+ >
103
+ Open Post
104
+ </Link>
105
+ </main>
106
+ ),
107
+ });
108
+ const post = createRoute({
109
+ getParentRoute: () => root,
110
+ path: "/p/$slug",
111
+ component: () => <main>Full page</main>,
112
+ });
113
+ return createRouter({ history, routeTree: root.addChildren([index, post]) });
114
+ }
@@ -0,0 +1,67 @@
1
+ import {
2
+ Dialog,
3
+ DialogContent,
4
+ DialogDescription,
5
+ DialogHeader,
6
+ DialogTitle,
7
+ } from "@feedbax/ui/components/dialog";
8
+ import {
9
+ Drawer,
10
+ DrawerContent,
11
+ DrawerDescription,
12
+ DrawerHeader,
13
+ DrawerTitle,
14
+ } from "@feedbax/ui/components/drawer";
15
+ import { useRouter, useRouterState } from "@tanstack/react-router";
16
+ import { type ReactNode } from "react";
17
+ import { useMediaQuery } from "./use-media-query";
18
+
19
+ declare module "@tanstack/react-router" {
20
+ interface HistoryState {
21
+ postDetailOverlay?: { slug: string };
22
+ }
23
+ }
24
+
25
+ export function PostDetailOverlay({
26
+ renderDetail,
27
+ }: {
28
+ renderDetail: (slug: string) => ReactNode;
29
+ }) {
30
+ const router = useRouter();
31
+ const request = useRouterState({
32
+ select: (state) => state.location.state.postDetailOverlay,
33
+ });
34
+ const mobile = useMediaQuery("(max-width: 720px)");
35
+ const close = (open: boolean) => {
36
+ if (!open) router.history.back();
37
+ };
38
+
39
+ if (!request) return null;
40
+ const detail = renderDetail(request.slug);
41
+
42
+ if (mobile) {
43
+ return (
44
+ <Drawer open onOpenChange={close}>
45
+ <DrawerContent className="post-detail-drawer">
46
+ <DrawerHeader className="sr-only">
47
+ <DrawerTitle>Post details</DrawerTitle>
48
+ <DrawerDescription>View and manage this Post.</DrawerDescription>
49
+ </DrawerHeader>
50
+ {detail}
51
+ </DrawerContent>
52
+ </Drawer>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <Dialog open onOpenChange={close}>
58
+ <DialogContent className="post-detail-dialog">
59
+ <DialogHeader className="sr-only">
60
+ <DialogTitle>Post details</DialogTitle>
61
+ <DialogDescription>View and manage this Post.</DialogDescription>
62
+ </DialogHeader>
63
+ {detail}
64
+ </DialogContent>
65
+ </Dialog>
66
+ );
67
+ }
@@ -0,0 +1,34 @@
1
+ import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
2
+ import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
3
+
4
+ export const publicPostQueryKey = ["public-post"] as const;
5
+ export type FetchPostComments = (
6
+ slug: string,
7
+ cursor?: string,
8
+ ) => Promise<CommentThreadPage>;
9
+
10
+ export function publicPostQuery(
11
+ slug: string,
12
+ loadPost: (slug: string) => Promise<PublicPost | undefined>,
13
+ ) {
14
+ return queryOptions({
15
+ queryKey: [...publicPostQueryKey, slug],
16
+ queryFn: async () => (await loadPost(slug)) ?? null,
17
+ staleTime: 30_000,
18
+ retry: false,
19
+ });
20
+ }
21
+
22
+ export function publicPostCommentsQuery(
23
+ slug: string,
24
+ loadComments: FetchPostComments,
25
+ ) {
26
+ return infiniteQueryOptions({
27
+ queryKey: ["public-post-comments", slug],
28
+ initialPageParam: undefined as string | undefined,
29
+ queryFn: ({ pageParam }) => loadComments(slug, pageParam),
30
+ getNextPageParam: (page) => page.nextCursor,
31
+ staleTime: 0,
32
+ retry: false,
33
+ });
34
+ }