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,131 @@
1
+ /* eslint-disable */
2
+
3
+ // @ts-nocheck
4
+
5
+ // noinspection JSUnusedGlobalSymbols
6
+
7
+ // This file was automatically generated by TanStack Router.
8
+ // You should NOT make any changes in this file as it will be overwritten.
9
+ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
+
11
+ import { Route as rootRouteImport } from './routes/__root'
12
+ import { Route as IndexRouteImport } from './routes/index'
13
+ import { Route as ChangelogRouteImport } from './routes/changelog'
14
+ import { Route as RoadmapRouteImport } from './routes/roadmap'
15
+ import { Route as SubmitRouteImport } from './routes/submit'
16
+ import { Route as PSlugRouteImport } from './routes/p.$slug'
17
+
18
+ const IndexRoute = IndexRouteImport.update({
19
+ id: '/',
20
+ path: '/',
21
+ getParentRoute: () => rootRouteImport,
22
+ } as any)
23
+ const ChangelogRoute = ChangelogRouteImport.update({
24
+ id: '/changelog',
25
+ path: '/changelog',
26
+ getParentRoute: () => rootRouteImport,
27
+ } as any)
28
+ const RoadmapRoute = RoadmapRouteImport.update({
29
+ id: '/roadmap',
30
+ path: '/roadmap',
31
+ getParentRoute: () => rootRouteImport,
32
+ } as any)
33
+ const SubmitRoute = SubmitRouteImport.update({
34
+ id: '/submit',
35
+ path: '/submit',
36
+ getParentRoute: () => rootRouteImport,
37
+ } as any)
38
+ const PSlugRoute = PSlugRouteImport.update({
39
+ id: '/p/$slug',
40
+ path: '/p/$slug',
41
+ getParentRoute: () => rootRouteImport,
42
+ } as any)
43
+
44
+ export interface FileRoutesByFullPath {
45
+ '/': typeof IndexRoute
46
+ '/changelog': typeof ChangelogRoute
47
+ '/roadmap': typeof RoadmapRoute
48
+ '/submit': typeof SubmitRoute
49
+ '/p/$slug': typeof PSlugRoute
50
+ }
51
+ export interface FileRoutesByTo {
52
+ '/': typeof IndexRoute
53
+ '/changelog': typeof ChangelogRoute
54
+ '/roadmap': typeof RoadmapRoute
55
+ '/submit': typeof SubmitRoute
56
+ '/p/$slug': typeof PSlugRoute
57
+ }
58
+ export interface FileRoutesById {
59
+ __root__: typeof rootRouteImport
60
+ '/': typeof IndexRoute
61
+ '/changelog': typeof ChangelogRoute
62
+ '/roadmap': typeof RoadmapRoute
63
+ '/submit': typeof SubmitRoute
64
+ '/p/$slug': typeof PSlugRoute
65
+ }
66
+ export interface FileRouteTypes {
67
+ fileRoutesByFullPath: FileRoutesByFullPath
68
+ fullPaths: '/' | '/changelog' | '/roadmap' | '/submit' | '/p/$slug'
69
+ fileRoutesByTo: FileRoutesByTo
70
+ to: '/' | '/changelog' | '/roadmap' | '/submit' | '/p/$slug'
71
+ id: '__root__' | '/' | '/changelog' | '/roadmap' | '/submit' | '/p/$slug'
72
+ fileRoutesById: FileRoutesById
73
+ }
74
+ export interface RootRouteChildren {
75
+ IndexRoute: typeof IndexRoute
76
+ ChangelogRoute: typeof ChangelogRoute
77
+ RoadmapRoute: typeof RoadmapRoute
78
+ SubmitRoute: typeof SubmitRoute
79
+ PSlugRoute: typeof PSlugRoute
80
+ }
81
+
82
+ declare module '@tanstack/react-router' {
83
+ interface FileRoutesByPath {
84
+ '/': {
85
+ id: '/'
86
+ path: '/'
87
+ fullPath: '/'
88
+ preLoaderRoute: typeof IndexRouteImport
89
+ parentRoute: typeof rootRouteImport
90
+ }
91
+ '/changelog': {
92
+ id: '/changelog'
93
+ path: '/changelog'
94
+ fullPath: '/changelog'
95
+ preLoaderRoute: typeof ChangelogRouteImport
96
+ parentRoute: typeof rootRouteImport
97
+ }
98
+ '/roadmap': {
99
+ id: '/roadmap'
100
+ path: '/roadmap'
101
+ fullPath: '/roadmap'
102
+ preLoaderRoute: typeof RoadmapRouteImport
103
+ parentRoute: typeof rootRouteImport
104
+ }
105
+ '/submit': {
106
+ id: '/submit'
107
+ path: '/submit'
108
+ fullPath: '/submit'
109
+ preLoaderRoute: typeof SubmitRouteImport
110
+ parentRoute: typeof rootRouteImport
111
+ }
112
+ '/p/$slug': {
113
+ id: '/p/$slug'
114
+ path: '/p/$slug'
115
+ fullPath: '/p/$slug'
116
+ preLoaderRoute: typeof PSlugRouteImport
117
+ parentRoute: typeof rootRouteImport
118
+ }
119
+ }
120
+ }
121
+
122
+ const rootRouteChildren: RootRouteChildren = {
123
+ IndexRoute: IndexRoute,
124
+ ChangelogRoute: ChangelogRoute,
125
+ RoadmapRoute: RoadmapRoute,
126
+ SubmitRoute: SubmitRoute,
127
+ PSlugRoute: PSlugRoute,
128
+ }
129
+ export const routeTree = rootRouteImport
130
+ ._addFileChildren(rootRouteChildren)
131
+ ._addFileTypes<FileRouteTypes>()
@@ -0,0 +1,28 @@
1
+ import {
2
+ editPortalFeedbackDraft,
3
+ submitPortalPost,
4
+ withdrawPortalFeedbackDraft,
5
+ } from "./portal-feedback-server-function";
6
+ import { PostCreationFlow } from "./post-creation-flow";
7
+
8
+ export const portalFeedbackMutations = {
9
+ submitPost: submitPortalPost,
10
+ editDraftPost: editPortalFeedbackDraft,
11
+ withdrawDraftPost: withdrawPortalFeedbackDraft,
12
+ };
13
+
14
+ export function RoutedPostCreationForm({
15
+ display = "page",
16
+ onCancel,
17
+ }: {
18
+ display?: "page" | "overlay";
19
+ onCancel?: () => void;
20
+ }) {
21
+ return (
22
+ <PostCreationFlow
23
+ display={display}
24
+ onCancel={onCancel}
25
+ mutations={portalFeedbackMutations}
26
+ />
27
+ );
28
+ }
@@ -0,0 +1,25 @@
1
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
2
+ import { QueryClient } from "@tanstack/react-query";
3
+
4
+ import Loader from "./components/loader";
5
+ import { routeTree } from "./routeTree.gen";
6
+
7
+ export const getRouter = () => {
8
+ const queryClient = new QueryClient();
9
+ const router = createTanStackRouter({
10
+ routeTree,
11
+ scrollRestoration: true,
12
+ defaultPreloadStaleTime: 0,
13
+ context: { queryClient },
14
+ defaultPendingComponent: () => <Loader />,
15
+ defaultNotFoundComponent: () => <div>Not Found</div>,
16
+ });
17
+
18
+ return router;
19
+ };
20
+
21
+ declare module "@tanstack/react-router" {
22
+ interface Register {
23
+ router: ReturnType<typeof getRouter>;
24
+ }
25
+ }
@@ -0,0 +1,116 @@
1
+ import { Toaster } from "@feedbax/ui/components/sonner";
2
+ import {
3
+ HeadContent,
4
+ Outlet,
5
+ Scripts,
6
+ createRootRouteWithContext,
7
+ useRouterState,
8
+ } from "@tanstack/react-router";
9
+ import { lazy, Suspense } from "react";
10
+ import { ThemeProvider } from "next-themes";
11
+ import { QueryClientProvider } from "@tanstack/react-query";
12
+
13
+ import Header from "../components/header";
14
+ import { DeviceProfileProvider } from "../components/device-profile-provider";
15
+ import {
16
+ CommandPaletteProvider,
17
+ useCommandPalette,
18
+ } from "../components/command-palette-context";
19
+ import feedbax from "../feedbax";
20
+ import { createPortalHead } from "../portal-head";
21
+
22
+ import appCss from "../index.css?url";
23
+
24
+ const RouterDevtools = import.meta.env.DEV
25
+ ? lazy(() =>
26
+ import("@tanstack/react-router-devtools").then((module) => ({
27
+ default: module.TanStackRouterDevtools,
28
+ })),
29
+ )
30
+ : null;
31
+
32
+ const CommandPalette = lazy(() =>
33
+ import("../components/command-palette").then((module) => ({
34
+ default: module.CommandPalette,
35
+ })),
36
+ );
37
+ const CreatePostOverlay = lazy(() =>
38
+ import("../lazy-create-post-overlay").then((module) => ({
39
+ default: module.LazyCreatePostOverlay,
40
+ })),
41
+ );
42
+ const PostDetailOverlay = lazy(() =>
43
+ import("../lazy-post-detail-overlay").then((module) => ({
44
+ default: module.LazyPostDetailOverlay,
45
+ })),
46
+ );
47
+
48
+ export type RouterAppContext = {
49
+ queryClient: import("@tanstack/react-query").QueryClient;
50
+ };
51
+
52
+ export const Route = createRootRouteWithContext<RouterAppContext>()({
53
+ head: () => createPortalHead(feedbax.product, appCss),
54
+
55
+ component: RootDocument,
56
+ });
57
+
58
+ function OptionalPortalSurfaces() {
59
+ const { open: commandPaletteOpen } = useCommandPalette();
60
+ const overlayState = useRouterState({
61
+ select: (state) => state.location.state,
62
+ });
63
+
64
+ return (
65
+ <Suspense fallback={null}>
66
+ {overlayState.createPostOverlay ? <CreatePostOverlay /> : null}
67
+ {overlayState.postDetailOverlay ? <PostDetailOverlay /> : null}
68
+ {commandPaletteOpen ? (
69
+ <CommandPalette
70
+ searchPosts={async (term) => {
71
+ const { getPublicPostPage } =
72
+ await import("../public-feedback-server-function");
73
+ return getPublicPostPage({
74
+ data: { search: term || undefined },
75
+ });
76
+ }}
77
+ />
78
+ ) : null}
79
+ </Suspense>
80
+ );
81
+ }
82
+
83
+ function RootDocument() {
84
+ const { queryClient } = Route.useRouteContext();
85
+ return (
86
+ <html lang="en" suppressHydrationWarning>
87
+ <head>
88
+ <HeadContent />
89
+ </head>
90
+ <body>
91
+ <QueryClientProvider client={queryClient}>
92
+ <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
93
+ <DeviceProfileProvider>
94
+ <CommandPaletteProvider>
95
+ <div className="portal-shell">
96
+ <Header />
97
+ <div id="main-content" tabIndex={-1}>
98
+ <Outlet />
99
+ </div>
100
+ <OptionalPortalSurfaces />
101
+ </div>
102
+ </CommandPaletteProvider>
103
+ </DeviceProfileProvider>
104
+ </ThemeProvider>
105
+ </QueryClientProvider>
106
+ <Toaster richColors />
107
+ {RouterDevtools ? (
108
+ <Suspense fallback={null}>
109
+ <RouterDevtools position="bottom-left" />
110
+ </Suspense>
111
+ ) : null}
112
+ <Scripts />
113
+ </body>
114
+ </html>
115
+ );
116
+ }
@@ -0,0 +1,39 @@
1
+ import { createFileRoute, notFound } from "@tanstack/react-router";
2
+ import type { PortalFeatures } from "@feedbax/config";
3
+ import { useSuspenseQuery } from "@tanstack/react-query";
4
+
5
+ import { ChangelogPage } from "../changelog-page";
6
+ import { changelogQuery } from "../changelog-query";
7
+ import feedbax from "../feedbax";
8
+ import { getPublicChangelogPage } from "../public-changelog-server-function";
9
+ import { createRouteHead } from "../portal-head";
10
+
11
+ export function ensureChangelogEnabled(features: PortalFeatures) {
12
+ if (!features.changelog) throw notFound();
13
+ }
14
+
15
+ const query = changelogQuery(getPublicChangelogPage);
16
+
17
+ export const Route = createFileRoute("/changelog")({
18
+ head: () =>
19
+ createRouteHead(feedbax.product, {
20
+ title: "Changelog",
21
+ description: `Product updates from ${feedbax.product.name}.`,
22
+ path: "/changelog",
23
+ }),
24
+ beforeLoad: () => {
25
+ ensureChangelogEnabled(feedbax.features);
26
+ },
27
+ loader: ({ context }) => context.queryClient.ensureQueryData(query),
28
+ component: ChangelogRoute,
29
+ });
30
+
31
+ function ChangelogRoute() {
32
+ const initialPage = useSuspenseQuery(query).data;
33
+ return (
34
+ <ChangelogPage
35
+ initialPage={initialPage}
36
+ loadPage={(data) => getPublicChangelogPage({ data })}
37
+ />
38
+ );
39
+ }
@@ -0,0 +1,61 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+
3
+ import {
4
+ PublicPostFeedSkeleton,
5
+ PublicPostIndex,
6
+ } from "../public-feedback-index";
7
+ import { PublicPortalError } from "../public-portal-error";
8
+ import { publicPostSearch } from "../public-feedback-page";
9
+ import { publicPostsQuery, type FetchPublicPostsPage } from "../post-queries";
10
+ import { getPublicPostPage } from "../public-feedback-server-function";
11
+ import { useSuspenseInfiniteQuery } from "@tanstack/react-query";
12
+ import { useTransition } from "react";
13
+ import feedbax from "../feedbax";
14
+ import { createRouteHead } from "../portal-head";
15
+
16
+ const fetchPublicPostsPage: FetchPublicPostsPage = (search) =>
17
+ getPublicPostPage({ data: search });
18
+
19
+ export const Route = createFileRoute("/")({
20
+ head: () => createRouteHead(feedbax.product, { path: "/" }),
21
+ validateSearch: publicPostSearch,
22
+ loaderDeps: ({ search }) => search,
23
+ loader: ({ deps, context }) =>
24
+ context.queryClient.ensureInfiniteQueryData(
25
+ publicPostsQuery(deps, fetchPublicPostsPage),
26
+ ),
27
+ component: HomeComponent,
28
+ pendingComponent: PublicPostFeedSkeleton,
29
+ errorComponent: PublicPortalError,
30
+ });
31
+
32
+ function HomeComponent() {
33
+ const search = Route.useSearch();
34
+ const navigate = Route.useNavigate();
35
+ const [pending, startTransition] = useTransition();
36
+ const query = useSuspenseInfiniteQuery(
37
+ publicPostsQuery(search, fetchPublicPostsPage),
38
+ );
39
+ const pages = query.data.pages;
40
+ const page = {
41
+ items: pages.flatMap((page) => page.items),
42
+ nextCursor: pages.at(-1)?.nextCursor,
43
+ };
44
+
45
+ return (
46
+ <PublicPostIndex
47
+ page={page}
48
+ search={search}
49
+ pending={pending}
50
+ loadMore={() => query.fetchNextPage()}
51
+ loadingMore={query.isFetchingNextPage}
52
+ loadMoreError={query.isFetchNextPageError}
53
+ onSearchChange={(nextSearch) =>
54
+ startTransition(() => {
55
+ void navigate({ search: nextSearch, replace: false });
56
+ })
57
+ }
58
+ maskPostLinks
59
+ />
60
+ );
61
+ }
@@ -0,0 +1,16 @@
1
+ import { createPublicPostRoute } from "../public-post-route";
2
+ import { PublicPortalError } from "../public-portal-error";
3
+ import {
4
+ getPublicPost,
5
+ getPublicPostComments,
6
+ } from "../public-post-server-function";
7
+ import { AuthorizedDraftPost } from "../authorized-draft-post";
8
+
9
+ export const Route = createPublicPostRoute({
10
+ loadPost: (slug) => getPublicPost({ data: { slug } }),
11
+ loadComments: (slug, cursor) =>
12
+ getPublicPostComments({ data: { slug, cursor } }),
13
+ renderUnavailable: (slug) => <AuthorizedDraftPost slug={slug} />,
14
+ }).update({
15
+ errorComponent: PublicPortalError,
16
+ });
@@ -0,0 +1,11 @@
1
+ import { createPublicRoadmapRoute } from "../public-roadmap-route";
2
+ import {
3
+ getPublicRoadmapPage,
4
+ getPublicRoadmapStatusPage,
5
+ } from "../public-roadmap-server-function";
6
+
7
+ export const Route = createPublicRoadmapRoute({
8
+ fetchRoadmap: () => getPublicRoadmapPage(),
9
+ fetchRoadmapStatusPage: (query) =>
10
+ getPublicRoadmapStatusPage({ data: query }),
11
+ });
@@ -0,0 +1,28 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+
3
+ import { RoutedPostCreationForm } from "../routed-post-creation-form";
4
+ import { PublicPortalError } from "../public-portal-error";
5
+ import { PostCreationSkeleton } from "../post-creation-flow";
6
+ import feedbax from "../feedbax";
7
+ import { createRouteHead } from "../portal-head";
8
+
9
+ export const Route = createFileRoute("/submit")({
10
+ head: () =>
11
+ createRouteHead(feedbax.product, {
12
+ title: "Share feedback",
13
+ description: `Share feedback with ${feedbax.product.name}.`,
14
+ path: "/submit",
15
+ noIndex: true,
16
+ }),
17
+ component: SubmitComponent,
18
+ pendingComponent: PostCreationSkeleton,
19
+ errorComponent: PublicPortalError,
20
+ });
21
+
22
+ function SubmitComponent() {
23
+ return (
24
+ <main className="feedback-index submission-page">
25
+ <RoutedPostCreationForm />
26
+ </main>
27
+ );
28
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ BrowserCapabilityAuthorizationError,
3
+ VoteEligibilityError,
4
+ } from "@feedbax/feedback";
5
+
6
+ export async function runSafePublicRead<Value>(
7
+ operation: () => Promise<Value>,
8
+ ): Promise<Value> {
9
+ try {
10
+ return await operation();
11
+ } catch {
12
+ throw new Error("Public feedback is temporarily unavailable.");
13
+ }
14
+ }
15
+
16
+ export class ActionablePortalFailure extends Error {
17
+ constructor(
18
+ message: string,
19
+ readonly code?: string,
20
+ ) {
21
+ super(message);
22
+ this.name = "ActionablePortalFailure";
23
+ }
24
+ }
25
+
26
+ export async function runSafePortalMutation<Value>(
27
+ operation: () => Promise<Value>,
28
+ ): Promise<Value> {
29
+ try {
30
+ return await operation();
31
+ } catch (error) {
32
+ if (
33
+ error instanceof ActionablePortalFailure ||
34
+ error instanceof BrowserCapabilityAuthorizationError ||
35
+ error instanceof VoteEligibilityError
36
+ ) {
37
+ throw error;
38
+ }
39
+ throwSafePortalMutationFailure();
40
+ }
41
+ }
42
+
43
+ function throwSafePortalMutationFailure(): never {
44
+ throw new Error("Public feedback mutation is temporarily unavailable.");
45
+ }
@@ -0,0 +1,111 @@
1
+ import { configuredTurnstileSecret } from "./turnstile-configuration";
2
+ import { createPortalServerEntry } from "./portal-server-entry";
3
+ import { createConfiguredTrustedFeedbackHandler } from "./trusted-feedback-runtime";
4
+ import { env } from "cloudflare:workers";
5
+ import { createConfiguredFeedbackModule } from "./feedback-runtime";
6
+ import { createPortalVoteHandler } from "./portal-voting";
7
+ import { createCloudflareTurnstileVerifier } from "./cloudflare-turnstile";
8
+ import feedbax from "./feedbax";
9
+ import { ActionablePortalFailure } from "./safe-public-failure";
10
+ import { VoteEligibilityError } from "@feedbax/feedback";
11
+ import {
12
+ createPortalCommentMutationRequestHandler,
13
+ createPortalCommentRequestHandler,
14
+ } from "./portal-comments";
15
+
16
+ export default createPortalServerEntry({
17
+ commentHandler: (request) => {
18
+ const turnstileSecret = configuredTurnstileSecret(env.TURNSTILE_SECRET_KEY);
19
+ return createPortalCommentRequestHandler({
20
+ feedback: createConfiguredFeedbackModule(),
21
+ commentsEnabled: feedbax.features.comments,
22
+ rateLimiter: env.PORTAL_SUBMISSIONS_RATE_LIMITER,
23
+ rateLimitKey:
24
+ request.headers.get("CF-Connecting-IP") ?? "unknown-participant-ip",
25
+ ...(turnstileSecret
26
+ ? {
27
+ turnstileVerifier: createCloudflareTurnstileVerifier({
28
+ secretKey: turnstileSecret,
29
+ }),
30
+ participationSigningSecret: env.PARTICIPATION_SIGNING_SECRET,
31
+ }
32
+ : {}),
33
+ participationSigningSecret: env.PARTICIPATION_SIGNING_SECRET,
34
+ })(request);
35
+ },
36
+ commentMutationHandler: (request) =>
37
+ createPortalCommentMutationRequestHandler({
38
+ feedback: createConfiguredFeedbackModule(),
39
+ commentsEnabled: feedbax.features.comments,
40
+ rateLimiter: env.PORTAL_SUBMISSIONS_RATE_LIMITER,
41
+ rateLimitKey:
42
+ request.headers.get("CF-Connecting-IP") ?? "unknown-participant-ip",
43
+ ...(configuredTurnstileSecret(env.TURNSTILE_SECRET_KEY)
44
+ ? {
45
+ turnstileVerifier: createCloudflareTurnstileVerifier({
46
+ secretKey: configuredTurnstileSecret(env.TURNSTILE_SECRET_KEY)!,
47
+ }),
48
+ }
49
+ : {}),
50
+ participationSigningSecret: env.PARTICIPATION_SIGNING_SECRET,
51
+ })(request),
52
+ voteHandler: async (request) => {
53
+ try {
54
+ const turnstileSecret = configuredTurnstileSecret(
55
+ env.TURNSTILE_SECRET_KEY,
56
+ );
57
+ const result = await createPortalVoteHandler({
58
+ feedback: createConfiguredFeedbackModule(),
59
+ votingEnabled: feedbax.features.voting,
60
+ rateLimiter: env.PORTAL_SUBMISSIONS_RATE_LIMITER,
61
+ rateLimitKey:
62
+ request.headers.get("CF-Connecting-IP") ?? "unknown-participant-ip",
63
+ ...(turnstileSecret
64
+ ? {
65
+ turnstileVerifier: createCloudflareTurnstileVerifier({
66
+ secretKey: turnstileSecret,
67
+ }),
68
+ participationSigningSecret: env.PARTICIPATION_SIGNING_SECRET,
69
+ }
70
+ : {}),
71
+ })(await request.json());
72
+ return Response.json(result);
73
+ } catch (error) {
74
+ const actionable =
75
+ error instanceof ActionablePortalFailure ||
76
+ error instanceof VoteEligibilityError;
77
+ return Response.json(
78
+ {
79
+ error: actionable
80
+ ? error.message
81
+ : "Public feedback mutation is temporarily unavailable.",
82
+ code:
83
+ error instanceof ActionablePortalFailure
84
+ ? (error.code ?? "action_failed")
85
+ : error instanceof VoteEligibilityError
86
+ ? "ineligible_post"
87
+ : "temporarily_unavailable",
88
+ },
89
+ {
90
+ status:
91
+ error instanceof ActionablePortalFailure &&
92
+ error.code === "rate_limited"
93
+ ? 429
94
+ : actionable
95
+ ? 400
96
+ : 503,
97
+ },
98
+ );
99
+ }
100
+ },
101
+ trustedFeedbackHandler: async (request) => {
102
+ try {
103
+ return await createConfiguredTrustedFeedbackHandler()(request);
104
+ } catch {
105
+ return Response.json(
106
+ { error: "Trusted submission is unavailable." },
107
+ { status: 503 },
108
+ );
109
+ }
110
+ },
111
+ });
@@ -0,0 +1,45 @@
1
+ import type { PublicPostPage } from "@feedbax/feedback";
2
+ import { useEffect, useRef, useState } from "react";
3
+
4
+ import { getPublicPostPage } from "./public-feedback-server-function";
5
+
6
+ export const similarPostMinimumTitleLength = 4;
7
+
8
+ export function SimilarPosts({ title }: { title: string }) {
9
+ const [posts, setPosts] = useState<PublicPostPage["items"]>([]);
10
+ const requestSequence = useRef(0);
11
+
12
+ useEffect(() => {
13
+ const query = title.trim();
14
+ const sequence = ++requestSequence.current;
15
+ if (query.length < similarPostMinimumTitleLength) {
16
+ setPosts([]);
17
+ return;
18
+ }
19
+ const timeout = window.setTimeout(() => {
20
+ void getPublicPostPage({ data: { search: query, sort: "top" } })
21
+ .then((page) => {
22
+ if (sequence === requestSequence.current)
23
+ setPosts(page.items.slice(0, 3));
24
+ })
25
+ .catch(() => {
26
+ if (sequence === requestSequence.current) setPosts([]);
27
+ });
28
+ }, 300);
29
+ return () => window.clearTimeout(timeout);
30
+ }, [title]);
31
+
32
+ if (!posts.length) return null;
33
+ return (
34
+ <aside aria-label="Potentially related Posts">
35
+ <p className="feedback-eyebrow">Potentially related</p>
36
+ <ul>
37
+ {posts.map((post) => (
38
+ <li key={post.slug}>
39
+ <a href={`/p/${encodeURIComponent(post.slug)}`}>{post.title}</a>
40
+ </li>
41
+ ))}
42
+ </ul>
43
+ </aside>
44
+ );
45
+ }