create-feedbax 0.1.0 → 0.2.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 (210) hide show
  1. package/LICENSE +201 -21
  2. package/README.md +11 -1
  3. package/dist/cli.js +1261 -26
  4. package/dist/index.js +1402 -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/LICENSE +201 -0
  11. package/template/components.json +25 -0
  12. package/template/eslint.config.mjs +25 -0
  13. package/template/package.json +70 -14
  14. package/template/public/feedbax-mark.svg +4 -0
  15. package/template/public/robots.txt +3 -0
  16. package/template/src/authorized-draft-post.test.tsx +212 -0
  17. package/template/src/authorized-draft-post.tsx +204 -0
  18. package/template/src/authorized-draft-query.ts +74 -0
  19. package/template/src/browser-post-state.test.ts +120 -0
  20. package/template/src/browser-post-state.ts +134 -0
  21. package/template/src/browser-vote-state.test.ts +14 -0
  22. package/template/src/browser-vote-state.ts +27 -0
  23. package/template/src/capability-authorization-error.ts +3 -0
  24. package/template/src/changelog-page.test.tsx +139 -0
  25. package/template/src/changelog-page.tsx +219 -0
  26. package/template/src/changelog-query.ts +13 -0
  27. package/template/src/changelog-runtime.ts +27 -0
  28. package/template/src/cloudflare-turnstile.ts +31 -0
  29. package/template/src/cloudflare-workers-test.ts +3 -0
  30. package/template/src/cloudflare-workers.d.ts +18 -0
  31. package/template/src/comment-creation.test.tsx +270 -0
  32. package/template/src/comment-profile.ts +5 -0
  33. package/template/src/comment-thread-page.ts +18 -0
  34. package/template/src/components/command-palette-context.tsx +71 -0
  35. package/template/src/components/command-palette.tsx +246 -0
  36. package/template/src/components/create-post-trigger.tsx +44 -0
  37. package/template/src/components/device-profile-provider.tsx +309 -0
  38. package/template/src/components/header.tsx +95 -0
  39. package/template/src/components/loader.tsx +10 -0
  40. package/template/src/components/profile-menu.tsx +89 -0
  41. package/template/src/components/ui/components/alert-dialog.tsx +185 -0
  42. package/template/src/components/ui/components/attachment.tsx +207 -0
  43. package/template/src/components/ui/components/avatar.tsx +107 -0
  44. package/template/src/components/ui/components/badge.tsx +52 -0
  45. package/template/src/components/ui/components/bubble.tsx +128 -0
  46. package/template/src/components/ui/components/button-group.tsx +87 -0
  47. package/template/src/components/ui/components/button.tsx +56 -0
  48. package/template/src/components/ui/components/card.tsx +103 -0
  49. package/template/src/components/ui/components/checkbox.tsx +26 -0
  50. package/template/src/components/ui/components/command.tsx +194 -0
  51. package/template/src/components/ui/components/dialog.tsx +154 -0
  52. package/template/src/components/ui/components/drawer.tsx +229 -0
  53. package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
  54. package/template/src/components/ui/components/empty.tsx +101 -0
  55. package/template/src/components/ui/components/field.tsx +236 -0
  56. package/template/src/components/ui/components/input-group.tsx +155 -0
  57. package/template/src/components/ui/components/input.tsx +20 -0
  58. package/template/src/components/ui/components/item.tsx +201 -0
  59. package/template/src/components/ui/components/kbd.tsx +26 -0
  60. package/template/src/components/ui/components/label.tsx +18 -0
  61. package/template/src/components/ui/components/marker.tsx +71 -0
  62. package/template/src/components/ui/components/message-scroller.tsx +128 -0
  63. package/template/src/components/ui/components/message.tsx +92 -0
  64. package/template/src/components/ui/components/native-select.tsx +65 -0
  65. package/template/src/components/ui/components/popover.tsx +88 -0
  66. package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
  67. package/template/src/components/ui/components/radio-group.tsx +36 -0
  68. package/template/src/components/ui/components/scroll-area.tsx +52 -0
  69. package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
  70. package/template/src/components/ui/components/select.tsx +199 -0
  71. package/template/src/components/ui/components/separator.tsx +23 -0
  72. package/template/src/components/ui/components/sheet.tsx +135 -0
  73. package/template/src/components/ui/components/skeleton.tsx +13 -0
  74. package/template/src/components/ui/components/sonner.tsx +43 -0
  75. package/template/src/components/ui/components/spinner.tsx +16 -0
  76. package/template/src/components/ui/components/tabs.tsx +80 -0
  77. package/template/src/components/ui/components/textarea.tsx +18 -0
  78. package/template/src/components/ui/components/tooltip.tsx +64 -0
  79. package/template/src/components/ui/hooks/.gitkeep +0 -0
  80. package/template/src/components/ui/lib/utils.ts +6 -0
  81. package/template/src/components/ui/styles/globals.css +151 -0
  82. package/template/src/create-post-overlay.tsx +82 -0
  83. package/template/src/draft-post-list.tsx +39 -0
  84. package/template/src/feedback-cache-invalidation.test.ts +185 -0
  85. package/template/src/feedback-cache-invalidation.ts +97 -0
  86. package/template/src/feedback-runtime.ts +38 -0
  87. package/template/src/feedbax/changelog/index.test.ts +156 -0
  88. package/template/src/feedbax/changelog/index.ts +206 -0
  89. package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
  90. package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
  91. package/template/src/feedbax/config/index.test.ts +111 -0
  92. package/template/src/feedbax/config/index.ts +271 -0
  93. package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
  94. package/template/src/feedbax/feedback/index.test.ts +900 -0
  95. package/template/src/feedbax/feedback/index.ts +942 -0
  96. package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
  97. package/template/src/feedbax/feedback/notion-comments.ts +231 -0
  98. package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
  99. package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
  100. package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
  101. package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
  102. package/template/src/feedbax/feedback/notion-request.ts +74 -0
  103. package/template/src/feedbax.test-fixture.ts +64 -0
  104. package/template/src/feedbax.ts +69 -0
  105. package/template/src/index.css +2138 -0
  106. package/template/src/lazy-create-post-overlay.tsx +6 -0
  107. package/template/src/lazy-post-detail-overlay.tsx +10 -0
  108. package/template/src/masked-post-link.tsx +27 -0
  109. package/template/src/overlay-post-detail.test.tsx +130 -0
  110. package/template/src/overlay-post-detail.tsx +92 -0
  111. package/template/src/portal-comments.test.ts +375 -0
  112. package/template/src/portal-comments.ts +384 -0
  113. package/template/src/portal-features.test.tsx +150 -0
  114. package/template/src/portal-feedback-form.tsx +515 -0
  115. package/template/src/portal-feedback-runtime.test.ts +32 -0
  116. package/template/src/portal-feedback-runtime.ts +55 -0
  117. package/template/src/portal-feedback-server-function.ts +93 -0
  118. package/template/src/portal-feedback-submission.test.ts +307 -0
  119. package/template/src/portal-feedback-submission.ts +214 -0
  120. package/template/src/portal-head.ts +58 -0
  121. package/template/src/portal-server-entry.ts +64 -0
  122. package/template/src/portal-voting.test.ts +106 -0
  123. package/template/src/portal-voting.ts +149 -0
  124. package/template/src/post-badges.tsx +27 -0
  125. package/template/src/post-creation-flow.test.tsx +230 -0
  126. package/template/src/post-creation-flow.tsx +88 -0
  127. package/template/src/post-detail-overlay.test.tsx +114 -0
  128. package/template/src/post-detail-overlay.tsx +67 -0
  129. package/template/src/post-feed-controls.test.tsx +239 -0
  130. package/template/src/post-feed-controls.tsx +298 -0
  131. package/template/src/post-presentation.ts +37 -0
  132. package/template/src/post-queries.test.ts +158 -0
  133. package/template/src/post-queries.ts +26 -0
  134. package/template/src/post-results.tsx +165 -0
  135. package/template/src/public-cache-policy.test.ts +141 -0
  136. package/template/src/public-cache-policy.ts +119 -0
  137. package/template/src/public-cache-tags.ts +7 -0
  138. package/template/src/public-changelog-page.ts +15 -0
  139. package/template/src/public-changelog-server-function.ts +27 -0
  140. package/template/src/public-date.ts +11 -0
  141. package/template/src/public-failure-state.test.tsx +69 -0
  142. package/template/src/public-feedback-index.tsx +124 -0
  143. package/template/src/public-feedback-page.test.tsx +290 -0
  144. package/template/src/public-feedback-page.ts +75 -0
  145. package/template/src/public-feedback-server-function.ts +22 -0
  146. package/template/src/public-portal-error.tsx +27 -0
  147. package/template/src/public-post-detail.tsx +682 -0
  148. package/template/src/public-post-page.test.tsx +173 -0
  149. package/template/src/public-post-page.ts +15 -0
  150. package/template/src/public-post-route.test.tsx +214 -0
  151. package/template/src/public-post-route.tsx +171 -0
  152. package/template/src/public-post-server-function.ts +48 -0
  153. package/template/src/public-post-unavailable.tsx +10 -0
  154. package/template/src/public-roadmap-page.test.tsx +139 -0
  155. package/template/src/public-roadmap-page.ts +24 -0
  156. package/template/src/public-roadmap-route.test.tsx +218 -0
  157. package/template/src/public-roadmap-route.tsx +50 -0
  158. package/template/src/public-roadmap-server-function.ts +44 -0
  159. package/template/src/public-roadmap-view.tsx +378 -0
  160. package/template/src/public-shell.test.tsx +518 -0
  161. package/template/src/roadmap-query.ts +21 -0
  162. package/template/src/root-branding.test.ts +53 -0
  163. package/template/src/routeTree.gen.ts +131 -0
  164. package/template/src/routed-post-creation-form.tsx +28 -0
  165. package/template/src/router.tsx +25 -0
  166. package/template/src/routes/__root.tsx +116 -0
  167. package/template/src/routes/changelog.tsx +39 -0
  168. package/template/src/routes/index.tsx +61 -0
  169. package/template/src/routes/p.$slug.tsx +16 -0
  170. package/template/src/routes/roadmap.tsx +11 -0
  171. package/template/src/routes/submit.tsx +28 -0
  172. package/template/src/safe-public-failure.ts +45 -0
  173. package/template/src/server.ts +111 -0
  174. package/template/src/similar-posts.tsx +45 -0
  175. package/template/src/submit-route.test.tsx +134 -0
  176. package/template/src/trusted-feedback-handler.test.ts +425 -0
  177. package/template/src/trusted-feedback-handler.ts +181 -0
  178. package/template/src/trusted-feedback-runtime.ts +17 -0
  179. package/template/src/turnstile-configuration.test.ts +37 -0
  180. package/template/src/turnstile-configuration.ts +19 -0
  181. package/template/src/use-media-query.ts +17 -0
  182. package/template/src/vote-toggle.test.tsx +171 -0
  183. package/template/src/vote-toggle.tsx +211 -0
  184. package/template/tsconfig.json +45 -0
  185. package/template/vite.config.ts +20 -0
  186. package/template/vitest.config.ts +4 -0
  187. package/template/vitest.setup.ts +3 -0
  188. package/template/wrangler.jsonc +35 -0
  189. package/dist/cli.d.ts +0 -3
  190. package/dist/cli.d.ts.map +0 -1
  191. package/dist/cli.js.map +0 -1
  192. package/dist/index.d.ts +0 -21
  193. package/dist/index.d.ts.map +0 -1
  194. package/dist/index.js.map +0 -1
  195. package/template/.env.example +0 -4
  196. package/template/README.md +0 -7
  197. package/template/apps/portal/components.json +0 -20
  198. package/template/apps/portal/package.json +0 -31
  199. package/template/apps/portal/src/lib/utils.ts +0 -6
  200. package/template/apps/portal/src/routeTree.gen.ts +0 -59
  201. package/template/apps/portal/src/router.tsx +0 -10
  202. package/template/apps/portal/src/routes/__root.tsx +0 -31
  203. package/template/apps/portal/src/routes/index.tsx +0 -35
  204. package/template/apps/portal/src/styles.css +0 -91
  205. package/template/apps/portal/tsconfig.json +0 -15
  206. package/template/apps/portal/vite.config.ts +0 -13
  207. package/template/feedbax.config.ts +0 -13
  208. package/template/feedbax.jsonc +0 -11
  209. package/template/pnpm-workspace.yaml +0 -3
  210. package/template/turbo.json +0 -8
@@ -0,0 +1,270 @@
1
+ // @vitest-environment jsdom
2
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import { cleanup } from "@testing-library/react";
5
+ import type { ComponentProps } from "react";
6
+
7
+ import { saveDeviceProfile } from "./browser-post-state";
8
+ import { PublicPostDetail } from "./public-post-detail";
9
+ import { submitPublicComment } from "./public-post-route";
10
+ import { DeviceProfileProvider } from "./components/device-profile-provider";
11
+
12
+ const post = {
13
+ slug: "roadmap-search",
14
+ title: "Roadmap search",
15
+ description: "Search the roadmap.",
16
+ type: "Feature Request" as const,
17
+ status: "Planned" as const,
18
+ createdAt: new Date("2026-08-25T00:00:00.000Z"),
19
+ updatedAt: new Date("2026-08-25T00:00:00.000Z"),
20
+ voteCount: 1,
21
+ };
22
+
23
+ afterEach(() => {
24
+ cleanup();
25
+ localStorage.clear();
26
+ sessionStorage.clear();
27
+ vi.restoreAllMocks();
28
+ delete window.turnstile;
29
+ });
30
+
31
+ describe("Comment creation", () => {
32
+ it("renders a clearly identified Comment composer for a complete Device Profile", async () => {
33
+ saveDeviceProfile(localStorage, {
34
+ name: "Ari",
35
+ email: "private@example.com",
36
+ });
37
+ renderCommentDetail({ submitComment: vi.fn() });
38
+
39
+ const composer = await screen.findByRole("form", {
40
+ name: "Comment composer",
41
+ });
42
+ expect(composer.classList.contains("comment-composer")).toBe(true);
43
+ expect(screen.getByLabelText("Add a comment").getAttribute("rows")).toBe(
44
+ "4",
45
+ );
46
+ expect(
47
+ screen.getByPlaceholderText("Share context, an example, or a question…"),
48
+ ).toBeTruthy();
49
+ });
50
+
51
+ it("deserializes Notion confirmation and clears a rejected Participation Pass", async () => {
52
+ vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
53
+ Response.json({
54
+ comment: {
55
+ id: "notion-comment",
56
+ discussionId: "notion-discussion",
57
+ body: "Confirmed",
58
+ author: { kind: "participant", displayName: "Ari" },
59
+ createdAt: "2026-08-25T01:00:00.000Z",
60
+ },
61
+ }),
62
+ );
63
+ const confirmed = await submitPublicComment({
64
+ slug: "roadmap-search",
65
+ body: "Confirmed",
66
+ displayName: "Ari",
67
+ email: "private@example.com",
68
+ });
69
+ expect(confirmed.createdAt).toEqual(new Date("2026-08-25T01:00:00.000Z"));
70
+
71
+ sessionStorage.setItem("feedbax:participation-pass", "expired");
72
+ vi.mocked(fetch).mockResolvedValueOnce(
73
+ Response.json(
74
+ {
75
+ error: "Comment verification is required.",
76
+ code: "verification_required",
77
+ },
78
+ { status: 400 },
79
+ ),
80
+ );
81
+ await expect(
82
+ submitPublicComment({
83
+ slug: "roadmap-search",
84
+ body: "Retry",
85
+ displayName: "Ari",
86
+ email: "private@example.com",
87
+ participationPass: "expired",
88
+ }),
89
+ ).rejects.toThrow("verification is required");
90
+ expect(sessionStorage.getItem("feedbax:participation-pass")).toBeNull();
91
+ });
92
+ it("adds a Comment optimistically and replaces it with Notion's confirmation", async () => {
93
+ saveDeviceProfile(localStorage, {
94
+ name: "Ari",
95
+ email: "private@example.com",
96
+ });
97
+ let confirm!: (value: {
98
+ id: string;
99
+ discussionId: string;
100
+ body: string;
101
+ author: { kind: "participant"; displayName: string };
102
+ createdAt: Date;
103
+ }) => void;
104
+ const submitComment = vi.fn(
105
+ () => new Promise<never>((resolve) => (confirm = resolve as never)),
106
+ );
107
+ renderCommentDetail({ submitComment });
108
+
109
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
110
+ target: { value: "Please add shortcuts." },
111
+ });
112
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
113
+ expect(screen.getByText("Please add shortcuts.")).toBeTruthy();
114
+ expect(submitComment).toHaveBeenCalledWith({
115
+ slug: "roadmap-search",
116
+ body: "Please add shortcuts.",
117
+ displayName: "Ari",
118
+ email: "private@example.com",
119
+ });
120
+
121
+ confirm({
122
+ id: "notion-comment",
123
+ discussionId: "notion-discussion",
124
+ body: "Please add shortcuts.",
125
+ author: { kind: "participant", displayName: "Ari" },
126
+ createdAt: new Date("2026-08-25T01:00:00.000Z"),
127
+ });
128
+ await waitFor(() =>
129
+ expect(screen.getAllByText("Please add shortcuts.")).toHaveLength(1),
130
+ );
131
+ });
132
+
133
+ it("replaces the Comment composer with profile completion until name and email are present", async () => {
134
+ saveDeviceProfile(localStorage, { name: "Ari" });
135
+ const submitComment = vi.fn();
136
+ renderCommentDetail({ submitComment });
137
+
138
+ expect(await screen.findByText("Complete your profile")).toBeTruthy();
139
+ expect(screen.queryByLabelText("Add a comment")).toBeNull();
140
+ fireEvent.click(screen.getByRole("button", { name: "Complete profile" }));
141
+ fireEvent.change(await screen.findByLabelText("Email"), {
142
+ target: { value: "private@example.com" },
143
+ });
144
+ fireEvent.click(screen.getByRole("button", { name: "Save profile" }));
145
+
146
+ expect(submitComment).not.toHaveBeenCalled();
147
+ expect(await screen.findByLabelText("Add a comment")).toBeTruthy();
148
+ });
149
+
150
+ it("rolls back a rejected Comment and shows an actionable error", async () => {
151
+ saveDeviceProfile(localStorage, {
152
+ name: "Ari",
153
+ email: "private@example.com",
154
+ });
155
+ renderCommentDetail({
156
+ submitComment: () =>
157
+ Promise.reject(new Error("Notion timed out. Try again.")),
158
+ });
159
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
160
+ target: { value: "Temporary message" },
161
+ });
162
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
163
+ expect(screen.getByText("Temporary message")).toBeTruthy();
164
+ await screen.findByRole("alert");
165
+ expect(screen.queryByText("Temporary message")).toBeNull();
166
+ expect(screen.getByRole("alert").textContent).toContain("Try again");
167
+ });
168
+
169
+ it("renders a fresh Turnstile challenge after a stale Participation Pass is rejected", async () => {
170
+ saveDeviceProfile(localStorage, {
171
+ name: "Ari",
172
+ email: "private@example.com",
173
+ });
174
+ sessionStorage.setItem("feedbax:participation-pass", "expired");
175
+ const renderTurnstile = vi.fn().mockReturnValue("widget-1");
176
+ window.turnstile = { render: renderTurnstile, reset: vi.fn() };
177
+ renderCommentDetail({
178
+ turnstileSiteKey: "public-site-key",
179
+ submitComment: () => {
180
+ sessionStorage.removeItem("feedbax:participation-pass");
181
+ return Promise.reject(new Error("Comment verification is required."));
182
+ },
183
+ });
184
+ fireEvent.change(await screen.findByLabelText("Add a comment"), {
185
+ target: { value: "Retry with verification" },
186
+ });
187
+ fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
188
+
189
+ await screen.findByRole("alert");
190
+ await waitFor(() => expect(renderTurnstile).toHaveBeenCalled());
191
+ });
192
+
193
+ it("rolls back an optimistic Comment edit when the server rejects it", async () => {
194
+ saveDeviceProfile(localStorage, {
195
+ name: "Ari",
196
+ email: "private@example.com",
197
+ });
198
+ localStorage.setItem(
199
+ "feedbax:comment-capabilities",
200
+ JSON.stringify({
201
+ "comment-1": {
202
+ capability: "signed-capability",
203
+ expiresAt: Date.now() + 60_000,
204
+ },
205
+ }),
206
+ );
207
+ vi.spyOn(window, "prompt").mockReturnValue("Corrected message");
208
+ let rejectMutation!: (reason: Error) => void;
209
+ const mutateComment = vi.fn(
210
+ () =>
211
+ new Promise<never>((_resolve, reject) => {
212
+ rejectMutation = reject;
213
+ }),
214
+ );
215
+ renderCommentDetail({
216
+ comments: {
217
+ items: [
218
+ {
219
+ id: "discussion-1",
220
+ comments: [
221
+ {
222
+ id: "comment-1",
223
+ body: "Original message",
224
+ author: { kind: "participant", displayName: "Ari" },
225
+ createdAt: new Date("2026-08-25T01:00:00.000Z"),
226
+ },
227
+ ],
228
+ },
229
+ ],
230
+ },
231
+ submitComment: vi.fn(),
232
+ mutateComment,
233
+ });
234
+
235
+ const actions = await screen.findByRole("group", {
236
+ name: "Comment actions by Ari",
237
+ });
238
+ expect(
239
+ Array.from(actions.querySelectorAll("button"), (button) =>
240
+ button.textContent?.trim(),
241
+ ),
242
+ ).toEqual(["Reply", "Edit", "Delete"]);
243
+ fireEvent.click(screen.getByRole("button", { name: "Edit" }));
244
+ fireEvent.change(screen.getByRole("textbox", { name: "Comment text" }), {
245
+ target: { value: "Corrected message" },
246
+ });
247
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
248
+ expect(screen.getByText("Corrected message")).toBeTruthy();
249
+ expect(mutateComment).toHaveBeenCalledWith({
250
+ action: "edit",
251
+ commentId: "comment-1",
252
+ commentCapability: "signed-capability",
253
+ body: "Corrected message",
254
+ });
255
+ rejectMutation(new Error("Notion rejected the update. Try again."));
256
+ await screen.findByRole("alert");
257
+ expect(screen.getByText("Original message")).toBeTruthy();
258
+ expect(screen.queryByText("Corrected message")).toBeNull();
259
+ });
260
+ });
261
+
262
+ function renderCommentDetail(
263
+ props: Omit<ComponentProps<typeof PublicPostDetail>, "post"> = {},
264
+ ) {
265
+ return render(
266
+ <DeviceProfileProvider>
267
+ <PublicPostDetail post={post} {...props} />
268
+ </DeviceProfileProvider>,
269
+ );
270
+ }
@@ -0,0 +1,5 @@
1
+ import { z } from "zod";
2
+
3
+ export function isValidCommentEmail(value: unknown): value is string {
4
+ return typeof value === "string" && z.email().safeParse(value.trim()).success;
5
+ }
@@ -0,0 +1,18 @@
1
+ import type { CommentThreadPage } from "@feedbax/feedback";
2
+
3
+ export function mergeCommentThreads(
4
+ current: CommentThreadPage["items"],
5
+ next: CommentThreadPage["items"],
6
+ ): CommentThreadPage["items"] {
7
+ const merged = new Map(current.map((thread) => [thread.id, thread]));
8
+ for (const thread of next) {
9
+ const existing = merged.get(thread.id);
10
+ merged.set(
11
+ thread.id,
12
+ existing
13
+ ? { ...existing, comments: [...existing.comments, ...thread.comments] }
14
+ : thread,
15
+ );
16
+ }
17
+ return [...merged.values()];
18
+ }
@@ -0,0 +1,71 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { Search } from "lucide-react";
11
+
12
+ import { Button } from "@feedbax/ui/components/button";
13
+ import { cn } from "@feedbax/ui/lib/utils";
14
+
15
+ const ignoreOpenChange = () => {};
16
+
17
+ const CommandPaletteContext = createContext<{
18
+ open: boolean;
19
+ setOpen: (open: boolean) => void;
20
+ } | null>(null);
21
+
22
+ export function CommandPaletteProvider({ children }: { children: ReactNode }) {
23
+ const [open, setOpen] = useState(false);
24
+
25
+ useEffect(() => {
26
+ function onKeyDown(event: KeyboardEvent) {
27
+ if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) {
28
+ event.preventDefault();
29
+ setOpen((current) => !current);
30
+ }
31
+ }
32
+ window.addEventListener("keydown", onKeyDown);
33
+ return () => window.removeEventListener("keydown", onKeyDown);
34
+ }, []);
35
+
36
+ const value = useMemo(() => ({ open, setOpen }), [open]);
37
+ return (
38
+ <CommandPaletteContext.Provider value={value}>
39
+ {children}
40
+ </CommandPaletteContext.Provider>
41
+ );
42
+ }
43
+
44
+ export function useCommandPalette() {
45
+ const context = useContext(CommandPaletteContext);
46
+ const setOpen = context?.setOpen ?? ignoreOpenChange;
47
+ const openPalette = useCallback(() => setOpen(true), [setOpen]);
48
+ return { open: context?.open ?? false, setOpen, openPalette };
49
+ }
50
+
51
+ export function CommandPaletteTrigger({
52
+ className,
53
+ compact = false,
54
+ }: {
55
+ className?: string;
56
+ compact?: boolean;
57
+ }) {
58
+ const { openPalette } = useCommandPalette();
59
+ return (
60
+ <Button
61
+ className={cn("h-10 gap-2 px-4 text-sm", className)}
62
+ variant="outline"
63
+ type="button"
64
+ aria-haspopup="dialog"
65
+ onClick={openPalette}
66
+ >
67
+ <Search aria-hidden="true" />
68
+ <span className={compact ? "sr-only" : undefined}>Search</span>
69
+ </Button>
70
+ );
71
+ }
@@ -0,0 +1,246 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useInfiniteQuery } from "@tanstack/react-query";
3
+ import { useRouter } from "@tanstack/react-router";
4
+ import type { PublicPostPage } from "@feedbax/feedback";
5
+ import type { PortalFeatures } from "@feedbax/config";
6
+
7
+ import { Badge } from "@feedbax/ui/components/badge";
8
+ import {
9
+ Command,
10
+ CommandDialog,
11
+ CommandEmpty,
12
+ CommandGroup,
13
+ CommandInput,
14
+ CommandItem,
15
+ CommandList,
16
+ CommandSeparator,
17
+ } from "@feedbax/ui/components/command";
18
+ import {
19
+ FileText,
20
+ LayoutList,
21
+ LoaderCircle,
22
+ Map,
23
+ Newspaper,
24
+ Plus,
25
+ } from "lucide-react";
26
+ import { publicPostsQuery } from "../post-queries";
27
+ import { useCommandPalette } from "./command-palette-context";
28
+ import feedbax from "../feedbax";
29
+ import { useDeviceProfile } from "./device-profile-provider";
30
+
31
+ export {
32
+ CommandPaletteProvider,
33
+ CommandPaletteTrigger,
34
+ useCommandPalette,
35
+ } from "./command-palette-context";
36
+
37
+ type SearchPosts = (term: string) => Promise<PublicPostPage>;
38
+ type NavigationTarget = "/" | "/roadmap" | "/changelog";
39
+
40
+ export function CommandPalette({
41
+ searchPosts,
42
+ features = feedbax.features,
43
+ }: {
44
+ searchPosts: SearchPosts;
45
+ features?: PortalFeatures;
46
+ }) {
47
+ const router = useRouter();
48
+ const { open, setOpen } = useCommandPalette();
49
+ const [term, setTerm] = useState("");
50
+ const [selection, setSelection] = useState("go-feedback");
51
+ const deferredTerm = useDebouncedValue(term, 200);
52
+ const { completeProfile, openProfileSetup } = useDeviceProfile();
53
+
54
+ useEffect(() => {
55
+ if (!open) {
56
+ setTerm("");
57
+ setSelection("go-feedback");
58
+ }
59
+ }, [open]);
60
+
61
+ const query = useInfiniteQuery({
62
+ ...publicPostsQuery(
63
+ { search: deferredTerm, sort: features.voting ? "top" : "new" },
64
+ (search) => searchPosts(search.search ?? ""),
65
+ ),
66
+ enabled: open && deferredTerm.trim().length > 0,
67
+ });
68
+ const posts = query.data?.pages.flatMap((page) => page.items) ?? [];
69
+ const showEmpty =
70
+ open &&
71
+ deferredTerm.trim().length > 0 &&
72
+ !query.isPending &&
73
+ posts.length === 0;
74
+ const searching = open && deferredTerm.trim().length > 0 && query.isFetching;
75
+ const searchStatus = searching
76
+ ? "Searching Posts…"
77
+ : showEmpty
78
+ ? "No Posts match this search."
79
+ : deferredTerm.trim().length > 0 && posts.length > 0
80
+ ? `${posts.length} ${posts.length === 1 ? "Post" : "Posts"} found.`
81
+ : "";
82
+
83
+ useEffect(() => {
84
+ const firstPost = posts[0];
85
+ if (firstPost) setSelection(`post-${firstPost.slug}`);
86
+ }, [posts]);
87
+
88
+ function getCurrentRouteOptions() {
89
+ const currentRoute = router.state.matches.at(-1);
90
+ if (!currentRoute) return undefined;
91
+ return {
92
+ from: currentRoute.fullPath,
93
+ to: "." as const,
94
+ params: currentRoute.params,
95
+ search: currentRoute.search,
96
+ };
97
+ }
98
+
99
+ function openPost(slug: string) {
100
+ const currentRouteOptions = getCurrentRouteOptions();
101
+ if (!currentRouteOptions) return;
102
+ setOpen(false);
103
+ router.navigate({
104
+ ...currentRouteOptions,
105
+ state: { postDetailOverlay: { slug } },
106
+ mask: {
107
+ to: "/p/$slug",
108
+ params: { slug },
109
+ unmaskOnReload: true,
110
+ },
111
+ });
112
+ }
113
+
114
+ function navigate(to: NavigationTarget) {
115
+ setOpen(false);
116
+ router.navigate({ to });
117
+ }
118
+
119
+ return (
120
+ <CommandDialog
121
+ open={open}
122
+ onOpenChange={setOpen}
123
+ title="Search feedback"
124
+ description="Find Posts and navigate the portal."
125
+ className="sm:max-w-xl"
126
+ >
127
+ <Command
128
+ shouldFilter={false}
129
+ className="gap-0"
130
+ value={selection}
131
+ onValueChange={setSelection}
132
+ >
133
+ <CommandInput
134
+ aria-label="Search feedback"
135
+ value={term}
136
+ onValueChange={setTerm}
137
+ placeholder="Search feedback…"
138
+ />
139
+ <CommandList aria-busy={searching || undefined} className="max-h-96">
140
+ <CommandEmpty>No Posts match this search.</CommandEmpty>
141
+ {searching && posts.length === 0 ? (
142
+ <CommandGroup heading="Posts">
143
+ <CommandItem disabled value="searching-posts">
144
+ <LoaderCircle className="animate-spin" aria-hidden="true" />
145
+ Searching Posts…
146
+ </CommandItem>
147
+ </CommandGroup>
148
+ ) : null}
149
+ {posts.length > 0 ? (
150
+ <CommandGroup heading="Posts">
151
+ {posts.map((post) => (
152
+ <CommandItem
153
+ key={post.slug}
154
+ value={`post-${post.slug}`}
155
+ onSelect={() => openPost(post.slug)}
156
+ className="min-h-11 gap-3 py-2.5"
157
+ >
158
+ <FileText aria-hidden="true" />
159
+ <div className="min-w-0 flex-1">
160
+ <div className="truncate text-sm font-medium">
161
+ {post.title}
162
+ </div>
163
+ <div className="truncate text-xs text-muted-foreground">
164
+ {post.description}
165
+ </div>
166
+ </div>
167
+ <div className="flex shrink-0 items-center gap-1.5">
168
+ <Badge variant="outline">{post.type}</Badge>
169
+ <Badge className="hidden sm:inline-flex" variant="outline">
170
+ {post.status}
171
+ </Badge>
172
+ </div>
173
+ </CommandItem>
174
+ ))}
175
+ </CommandGroup>
176
+ ) : null}
177
+ {!term.trim() ? <CommandSeparator /> : null}
178
+ {!term.trim() ? (
179
+ <CommandGroup heading="Navigate">
180
+ <CommandItem
181
+ className="min-h-11"
182
+ value="go-feedback"
183
+ onSelect={() => navigate("/")}
184
+ >
185
+ <LayoutList aria-hidden="true" />
186
+ Feedback
187
+ </CommandItem>
188
+ <CommandItem
189
+ className="min-h-11"
190
+ value="go-roadmap"
191
+ onSelect={() => navigate("/roadmap")}
192
+ >
193
+ <Map aria-hidden="true" />
194
+ Roadmap
195
+ </CommandItem>
196
+ {features.changelog ? (
197
+ <CommandItem
198
+ className="min-h-11"
199
+ value="go-changelog"
200
+ onSelect={() => navigate("/changelog")}
201
+ >
202
+ <Newspaper aria-hidden="true" />
203
+ Changelog
204
+ </CommandItem>
205
+ ) : null}
206
+ <CommandItem
207
+ className="min-h-11"
208
+ value="new-post"
209
+ onSelect={() => {
210
+ if (!completeProfile) {
211
+ setOpen(false);
212
+ openProfileSetup();
213
+ return;
214
+ }
215
+ const currentRouteOptions = getCurrentRouteOptions();
216
+ if (!currentRouteOptions) return;
217
+ setOpen(false);
218
+ router.navigate({
219
+ ...currentRouteOptions,
220
+ state: { createPostOverlay: true },
221
+ mask: { to: "/submit", unmaskOnReload: true },
222
+ });
223
+ }}
224
+ >
225
+ <Plus aria-hidden="true" />
226
+ New post
227
+ </CommandItem>
228
+ </CommandGroup>
229
+ ) : null}
230
+ </CommandList>
231
+ <div aria-label="Search status" className="sr-only" role="status">
232
+ {searchStatus}
233
+ </div>
234
+ </Command>
235
+ </CommandDialog>
236
+ );
237
+ }
238
+
239
+ function useDebouncedValue<T>(value: T, delay: number): T {
240
+ const [debounced, setDebounced] = useState(value);
241
+ useEffect(() => {
242
+ const timeout = window.setTimeout(() => setDebounced(value), delay);
243
+ return () => window.clearTimeout(timeout);
244
+ }, [value, delay]);
245
+ return debounced;
246
+ }
@@ -0,0 +1,44 @@
1
+ import { Button } from "@feedbax/ui/components/button";
2
+ import { cn } from "@feedbax/ui/lib/utils";
3
+ import { Link } from "@tanstack/react-router";
4
+ import { useDeviceProfile } from "./device-profile-provider";
5
+
6
+ export function CreatePostTrigger({
7
+ className,
8
+ contextual = false,
9
+ }: {
10
+ className?: string;
11
+ contextual?: boolean;
12
+ }) {
13
+ const { completeProfile, ready, openProfileSetup } = useDeviceProfile();
14
+ if (!ready || !completeProfile)
15
+ return (
16
+ <Button
17
+ className={cn("text-sm", className)}
18
+ type="button"
19
+ onClick={openProfileSetup}
20
+ >
21
+ New post
22
+ </Button>
23
+ );
24
+
25
+ const link = contextual ? (
26
+ <Link
27
+ to="."
28
+ state={{ createPostOverlay: true }}
29
+ mask={{ to: "/submit", unmaskOnReload: true }}
30
+ />
31
+ ) : (
32
+ <a href="/submit" />
33
+ );
34
+
35
+ return (
36
+ <Button
37
+ className={cn("text-sm", className)}
38
+ render={link}
39
+ nativeButton={false}
40
+ >
41
+ New post
42
+ </Button>
43
+ );
44
+ }