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,239 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import {
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ within,
9
+ } from "@testing-library/react";
10
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
11
+ import { useState } from "react";
12
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ import {
15
+ BoardNavigation,
16
+ MobilePostFilters,
17
+ PostFeedControls,
18
+ StatusNavigation,
19
+ } from "./post-feed-controls";
20
+ import {
21
+ CommandPalette,
22
+ CommandPaletteProvider,
23
+ } from "./components/command-palette";
24
+ import type { PublicPostQuery } from "@feedbax/feedback";
25
+
26
+ beforeEach(() => {
27
+ window.ResizeObserver = class {
28
+ observe() {}
29
+ unobserve() {}
30
+ disconnect() {}
31
+ } as unknown as typeof ResizeObserver;
32
+ window.HTMLElement.prototype.scrollIntoView = function () {};
33
+ });
34
+
35
+ afterEach(() => {
36
+ cleanup();
37
+ vi.restoreAllMocks();
38
+ vi.unstubAllGlobals();
39
+ });
40
+
41
+ describe("Post feed controls", () => {
42
+ it("opens the mobile filters sheet with every Board and Post Status", async () => {
43
+ render(<MobileFiltersHarness />);
44
+
45
+ fireEvent.click(screen.getByRole("button", { name: "Filters" }));
46
+
47
+ const sheet = await screen.findByRole("dialog", {
48
+ name: "Filter Posts",
49
+ });
50
+ expect(
51
+ screen.getByRole("navigation", { name: "Mobile Boards" }),
52
+ ).toBeTruthy();
53
+ expect(
54
+ screen.getByRole("group", { name: "Mobile Status filters" }),
55
+ ).toBeTruthy();
56
+ expect(screen.getByRole("button", { name: "All posts" })).toBeTruthy();
57
+ for (const status of [
58
+ "New",
59
+ "Reviewing",
60
+ "Planned",
61
+ "In progress",
62
+ "Shipped",
63
+ "Closed",
64
+ ]) {
65
+ expect(screen.getByRole("button", { name: status })).toBeTruthy();
66
+ }
67
+ expect(sheet).toBeTruthy();
68
+ });
69
+
70
+ it("applies mobile Board and Post Status selections to route query state", async () => {
71
+ render(<MobileFiltersHarness />);
72
+ fireEvent.click(screen.getByRole("button", { name: "Filters" }));
73
+ await screen.findByRole("dialog", { name: "Filter Posts" });
74
+
75
+ fireEvent.click(screen.getByRole("button", { name: "Feature requests" }));
76
+ fireEvent.click(screen.getByRole("button", { name: "New" }));
77
+ fireEvent.click(screen.getByRole("button", { name: "Closed" }));
78
+
79
+ expect(screen.getByTestId("route-state").textContent).toContain(
80
+ '"types":["Feature Request"]',
81
+ );
82
+ expect(screen.getByTestId("route-state").textContent).toContain(
83
+ '"statuses":["New","Closed"]',
84
+ );
85
+
86
+ fireEvent.click(screen.getByRole("button", { name: "New" }));
87
+ expect(screen.getByTestId("route-state").textContent).toContain(
88
+ '"statuses":["Closed"]',
89
+ );
90
+ });
91
+
92
+ it("switches sort from visible tabs immediately", () => {
93
+ render(<Harness />);
94
+
95
+ fireEvent.click(screen.getByRole("button", { name: "New" }));
96
+ expect(screen.getByTestId("route-state").textContent).toContain(
97
+ '"sort":"new"',
98
+ );
99
+ });
100
+
101
+ it("exposes the mobile sort, create, search, and filter controls in tap order", async () => {
102
+ stubViewport(true);
103
+ render(<Harness />);
104
+
105
+ const controls = screen.getByRole("group", {
106
+ name: "Post feed controls",
107
+ });
108
+ await screen.findByRole("button", { name: "Filters" });
109
+ expect(
110
+ within(controls)
111
+ .getAllByRole("button")
112
+ .map((button) => button.textContent),
113
+ ).toEqual(["Top", "New", "New post", "Search", "Filters"]);
114
+ });
115
+
116
+ it("keeps the mobile Filters control out of the desktop breakpoint", () => {
117
+ stubViewport(false);
118
+ render(<Harness />);
119
+
120
+ expect(screen.queryByRole("button", { name: "Filters" })).toBeNull();
121
+ });
122
+
123
+ it("opens the command palette from the Search button", async () => {
124
+ render(<Harness withPalette />);
125
+
126
+ fireEvent.click(screen.getByRole("button", { name: "Search" }));
127
+
128
+ expect(await screen.findByRole("dialog")).toBeTruthy();
129
+ });
130
+
131
+ it("selects one Board at a time and reselecting it returns to All posts", () => {
132
+ render(<Harness />);
133
+
134
+ fireEvent.click(screen.getByRole("button", { name: "Feature requests" }));
135
+ expect(screen.getByTestId("route-state").textContent).toContain(
136
+ '"types":["Feature Request"]',
137
+ );
138
+
139
+ fireEvent.click(screen.getByRole("button", { name: "Bug reports" }));
140
+ expect(screen.getByTestId("route-state").textContent).toContain(
141
+ '"types":["Bug Report"]',
142
+ );
143
+
144
+ fireEvent.click(screen.getByRole("button", { name: "Bug reports" }));
145
+ expect(screen.getByTestId("route-state").textContent).not.toContain(
146
+ '"types"',
147
+ );
148
+ expect(
149
+ screen
150
+ .getByRole("button", { name: "All posts" })
151
+ .getAttribute("aria-pressed"),
152
+ ).toBe("true");
153
+ });
154
+
155
+ it("combines and toggles roadmap Post Status filters", () => {
156
+ render(<Harness />);
157
+
158
+ fireEvent.click(screen.getByRole("button", { name: "Planned" }));
159
+ fireEvent.click(screen.getByRole("button", { name: "Shipped" }));
160
+ expect(screen.getByTestId("route-state").textContent).toContain(
161
+ '"statuses":["Planned","Shipped"]',
162
+ );
163
+
164
+ fireEvent.click(screen.getByRole("button", { name: "Planned" }));
165
+ expect(screen.getByTestId("route-state").textContent).toContain(
166
+ '"statuses":["Shipped"]',
167
+ );
168
+
169
+ fireEvent.click(screen.getByRole("button", { name: "Shipped" }));
170
+ expect(screen.getByTestId("route-state").textContent).not.toContain(
171
+ '"statuses"',
172
+ );
173
+ });
174
+
175
+ it("announces route updates locally from the feed controls", () => {
176
+ render(<Harness pending />);
177
+
178
+ expect(
179
+ screen
180
+ .getByRole("group", { name: "Post feed controls" })
181
+ .getAttribute("aria-busy"),
182
+ ).toBe("true");
183
+ expect(screen.getByText("Updating Posts…").className).toBe("sr-only");
184
+ });
185
+ });
186
+
187
+ function MobileFiltersHarness() {
188
+ const [search, setSearch] = useState<PublicPostQuery>({ sort: "trending" });
189
+ return (
190
+ <>
191
+ <MobilePostFilters search={search} onSearchChange={setSearch} />
192
+ <output data-testid="route-state">{JSON.stringify(search)}</output>
193
+ </>
194
+ );
195
+ }
196
+
197
+ function stubViewport(mobile: boolean) {
198
+ vi.stubGlobal("matchMedia", (query: string) => ({
199
+ matches: mobile && query === "(max-width: 1023px)",
200
+ media: query,
201
+ addEventListener: vi.fn(),
202
+ removeEventListener: vi.fn(),
203
+ }));
204
+ }
205
+
206
+ function Harness({
207
+ pending = false,
208
+ withPalette = false,
209
+ }: {
210
+ pending?: boolean;
211
+ withPalette?: boolean;
212
+ } = {}) {
213
+ const [search, setSearch] = useState<PublicPostQuery>({
214
+ sort: "trending",
215
+ });
216
+ return (
217
+ <QueryClientProvider
218
+ client={
219
+ new QueryClient({ defaultOptions: { queries: { retry: false } } })
220
+ }
221
+ >
222
+ <CommandPaletteProvider>
223
+ <PostFeedControls
224
+ search={search}
225
+ pending={pending}
226
+ onSearchChange={(next) => setSearch(next)}
227
+ />
228
+ <BoardNavigation search={search} onSearchChange={setSearch} />
229
+ <StatusNavigation search={search} onSearchChange={setSearch} />
230
+ {withPalette ? (
231
+ <CommandPalette
232
+ searchPosts={async () => ({ items: [], nextCursor: undefined })}
233
+ />
234
+ ) : null}
235
+ </CommandPaletteProvider>
236
+ <output data-testid="route-state">{JSON.stringify(search)}</output>
237
+ </QueryClientProvider>
238
+ );
239
+ }
@@ -0,0 +1,298 @@
1
+ import type { PostStatus, PostType, PublicPostQuery } from "@feedbax/feedback";
2
+ import type { PortalFeatures } from "@feedbax/config";
3
+ import { Button } from "@feedbax/ui/components/button";
4
+ import { ButtonGroup } from "@feedbax/ui/components/button-group";
5
+ import { Card } from "@feedbax/ui/components/card";
6
+ import {
7
+ Sheet,
8
+ SheetContent,
9
+ SheetDescription,
10
+ SheetHeader,
11
+ SheetTitle,
12
+ SheetTrigger,
13
+ } from "@feedbax/ui/components/sheet";
14
+ import { List, ListFilter } from "lucide-react";
15
+ import { type ComponentType } from "react";
16
+
17
+ import { CommandPaletteTrigger } from "./components/command-palette-context";
18
+ import { CreatePostTrigger } from "./components/create-post-trigger";
19
+ import { postStatuses, postTypes } from "./public-feedback-page";
20
+ import {
21
+ postStatusPresentation,
22
+ postTypePresentation,
23
+ } from "./post-presentation";
24
+ import { useMediaQuery } from "./use-media-query";
25
+ import feedbax from "./feedbax";
26
+
27
+ type SearchChange = (search: PublicPostQuery) => void;
28
+
29
+ const boards = [
30
+ { label: "All posts", type: undefined, icon: List },
31
+ ...postTypes.map((type) => ({
32
+ label: postTypePresentation[type].boardLabel,
33
+ type,
34
+ icon: postTypePresentation[type].Icon,
35
+ })),
36
+ ] as const satisfies readonly {
37
+ label: string;
38
+ type: PostType | undefined;
39
+ icon: ComponentType<{ className?: string; "aria-hidden"?: boolean }>;
40
+ }[];
41
+
42
+ const roadmapStatuses = [
43
+ "Planned",
44
+ "In Progress",
45
+ "Shipped",
46
+ ] as const satisfies readonly PostStatus[];
47
+
48
+ const sortOptions = [
49
+ { label: "Top", value: "top" },
50
+ { label: "New", value: "new" },
51
+ ] as const satisfies readonly {
52
+ label: string;
53
+ value: NonNullable<PublicPostQuery["sort"]>;
54
+ }[];
55
+
56
+ const mobileMediaQuery = "(max-width: 1023px)";
57
+
58
+ export function PostFeedControls({
59
+ search,
60
+ onSearchChange,
61
+ pending = false,
62
+ contextualCreatePost = false,
63
+ features = feedbax.features,
64
+ }: {
65
+ search: PublicPostQuery;
66
+ onSearchChange?: SearchChange;
67
+ pending?: boolean;
68
+ contextualCreatePost?: boolean;
69
+ features?: PortalFeatures;
70
+ }) {
71
+ const mobile = useMediaQuery(mobileMediaQuery);
72
+ const newPost = (
73
+ <CreatePostTrigger
74
+ className={
75
+ mobile
76
+ ? "order-2 h-12 w-full px-5 text-sm"
77
+ : "order-3 h-10 w-auto px-5 text-sm"
78
+ }
79
+ contextual={contextualCreatePost}
80
+ />
81
+ );
82
+
83
+ return (
84
+ <div
85
+ className="flex min-w-0 flex-1 flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"
86
+ role="group"
87
+ aria-label="Post feed controls"
88
+ aria-busy={pending || undefined}
89
+ >
90
+ {pending ? <span className="sr-only">Updating Posts…</span> : null}
91
+ <ButtonGroup
92
+ aria-label="Sort Posts"
93
+ className={
94
+ mobile
95
+ ? `order-1 grid h-12 w-full ${features.voting ? "grid-cols-2" : "grid-cols-1"}`
96
+ : `order-1 grid h-10 ${features.voting ? "w-48 grid-cols-2" : "w-24 grid-cols-1"}`
97
+ }
98
+ >
99
+ {sortOptions
100
+ .filter(({ value }) => features.voting || value === "new")
101
+ .map(({ label, value }) => (
102
+ <Button
103
+ key={value}
104
+ type="button"
105
+ variant="outline"
106
+ aria-pressed={
107
+ (search.sort ?? (features.voting ? "top" : "new")) === value
108
+ }
109
+ className={`${mobile ? "h-12" : "h-10"} px-5 text-sm aria-pressed:bg-muted aria-pressed:text-foreground`}
110
+ onClick={() =>
111
+ onSearchChange?.({
112
+ ...search,
113
+ cursor: undefined,
114
+ sort: value,
115
+ })
116
+ }
117
+ >
118
+ {label}
119
+ </Button>
120
+ ))}
121
+ </ButtonGroup>
122
+ {mobile ? (
123
+ <>
124
+ {newPost}
125
+ <div className="order-3 grid grid-cols-[minmax(0,1fr)_auto] gap-3">
126
+ <CommandPaletteTrigger className="h-12 w-full justify-start" />
127
+ <MobilePostFilters
128
+ search={search}
129
+ onSearchChange={onSearchChange}
130
+ />
131
+ </div>
132
+ </>
133
+ ) : (
134
+ <>
135
+ <CommandPaletteTrigger className="order-2 ml-auto justify-center" />
136
+ {newPost}
137
+ </>
138
+ )}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ export function BoardNavigation({
144
+ search,
145
+ onSearchChange,
146
+ headingId = "boards-heading",
147
+ ariaLabel,
148
+ }: {
149
+ search: PublicPostQuery;
150
+ onSearchChange?: SearchChange;
151
+ headingId?: string;
152
+ ariaLabel?: string;
153
+ }) {
154
+ const activeType = search.types?.length === 1 ? search.types[0] : undefined;
155
+ const hasNoType = !search.types?.length;
156
+
157
+ return (
158
+ <nav
159
+ aria-labelledby={ariaLabel ? undefined : headingId}
160
+ aria-label={ariaLabel}
161
+ >
162
+ <h2 id={headingId} className="mb-3 text-sm font-medium">
163
+ Boards
164
+ </h2>
165
+ <Card className="gap-0 overflow-hidden py-0">
166
+ {boards.map(({ label, type, icon: Icon }) => {
167
+ const active = type ? activeType === type : hasNoType;
168
+ return (
169
+ <Button
170
+ key={label}
171
+ type="button"
172
+ variant="ghost"
173
+ aria-pressed={active}
174
+ className="h-12 w-full justify-start gap-3 border-b px-4 text-sm text-muted-foreground last:border-b-0 aria-pressed:bg-muted aria-pressed:text-foreground"
175
+ onClick={() =>
176
+ onSearchChange?.({
177
+ ...search,
178
+ cursor: undefined,
179
+ types: !type || active ? undefined : [type],
180
+ })
181
+ }
182
+ >
183
+ <Icon className="size-4" aria-hidden="true" />
184
+ {label}
185
+ </Button>
186
+ );
187
+ })}
188
+ </Card>
189
+ </nav>
190
+ );
191
+ }
192
+
193
+ export function StatusNavigation({
194
+ search,
195
+ onSearchChange,
196
+ headingId = "status-filters-heading",
197
+ ariaLabel = "Status filters",
198
+ statuses = roadmapStatuses,
199
+ }: {
200
+ search: PublicPostQuery;
201
+ onSearchChange?: SearchChange;
202
+ headingId?: string;
203
+ ariaLabel?: string;
204
+ statuses?: readonly PostStatus[];
205
+ }) {
206
+ const selected = search.statuses ?? [];
207
+
208
+ return (
209
+ <section aria-labelledby={headingId}>
210
+ <h2 id={headingId} className="mb-3 text-sm font-medium">
211
+ Status
212
+ </h2>
213
+ <Card
214
+ className="gap-0 overflow-hidden py-0"
215
+ role="group"
216
+ aria-label={ariaLabel}
217
+ >
218
+ {statuses.map((status) => {
219
+ const { Icon, label } = postStatusPresentation[status];
220
+ const active = selected.includes(status);
221
+ const nextStatuses = active
222
+ ? selected.filter((candidate) => candidate !== status)
223
+ : [...selected, status];
224
+ return (
225
+ <Button
226
+ key={status}
227
+ type="button"
228
+ variant="ghost"
229
+ aria-pressed={active}
230
+ className="h-12 w-full justify-start gap-3 border-b px-4 text-sm text-muted-foreground last:border-b-0 aria-pressed:bg-muted aria-pressed:text-foreground"
231
+ onClick={() =>
232
+ onSearchChange?.({
233
+ ...search,
234
+ cursor: undefined,
235
+ statuses: nextStatuses.length ? nextStatuses : undefined,
236
+ })
237
+ }
238
+ >
239
+ <Icon className="size-4" aria-hidden="true" />
240
+ {label}
241
+ </Button>
242
+ );
243
+ })}
244
+ </Card>
245
+ </section>
246
+ );
247
+ }
248
+
249
+ export function MobilePostFilters({
250
+ search,
251
+ onSearchChange,
252
+ }: {
253
+ search: PublicPostQuery;
254
+ onSearchChange?: SearchChange;
255
+ }) {
256
+ return (
257
+ <Sheet>
258
+ <SheetTrigger
259
+ render={
260
+ <Button
261
+ className="h-12 gap-2 px-4 text-sm lg:hidden"
262
+ type="button"
263
+ variant="outline"
264
+ />
265
+ }
266
+ >
267
+ <ListFilter aria-hidden="true" />
268
+ Filters
269
+ </SheetTrigger>
270
+ <SheetContent
271
+ className="w-[min(100%,24rem)] overflow-y-auto lg:hidden"
272
+ side="right"
273
+ >
274
+ <SheetHeader className="border-b p-6 pr-14">
275
+ <SheetTitle className="text-lg">Filter Posts</SheetTitle>
276
+ <SheetDescription>
277
+ Choose a Board and combine any Post Statuses.
278
+ </SheetDescription>
279
+ </SheetHeader>
280
+ <div className="space-y-8 p-6">
281
+ <BoardNavigation
282
+ search={search}
283
+ onSearchChange={onSearchChange}
284
+ headingId="mobile-boards-heading"
285
+ ariaLabel="Mobile Boards"
286
+ />
287
+ <StatusNavigation
288
+ search={search}
289
+ onSearchChange={onSearchChange}
290
+ headingId="mobile-status-filters-heading"
291
+ ariaLabel="Mobile Status filters"
292
+ statuses={postStatuses}
293
+ />
294
+ </div>
295
+ </SheetContent>
296
+ </Sheet>
297
+ );
298
+ }
@@ -0,0 +1,37 @@
1
+ import type { PostStatus, PostType } from "@feedbax/feedback";
2
+ import {
3
+ Bug,
4
+ CalendarDays,
5
+ CheckCircle2,
6
+ Circle,
7
+ CircleDot,
8
+ Lightbulb,
9
+ MessageCircle,
10
+ Search,
11
+ XCircle,
12
+ type LucideIcon,
13
+ } from "lucide-react";
14
+
15
+ export const postTypePresentation = {
16
+ "Feature Request": {
17
+ Icon: Lightbulb,
18
+ boardLabel: "Feature requests",
19
+ },
20
+ "Bug Report": {
21
+ Icon: Bug,
22
+ boardLabel: "Bug reports",
23
+ },
24
+ "General Feedback": {
25
+ Icon: MessageCircle,
26
+ boardLabel: "General feedback",
27
+ },
28
+ } satisfies Record<PostType, { Icon: LucideIcon; boardLabel: string }>;
29
+
30
+ export const postStatusPresentation = {
31
+ New: { Icon: Circle, label: "New" },
32
+ Reviewing: { Icon: Search, label: "Reviewing" },
33
+ Planned: { Icon: CalendarDays, label: "Planned" },
34
+ "In Progress": { Icon: CircleDot, label: "In progress" },
35
+ Shipped: { Icon: CheckCircle2, label: "Shipped" },
36
+ Closed: { Icon: XCircle, label: "Closed" },
37
+ } satisfies Record<PostStatus, { Icon: LucideIcon; label: string }>;