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,108 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useState,
6
+ type ReactNode,
7
+ } from "react";
8
+ import feedbax from "../feedbax";
9
+ import { Button } from "@feedbax/ui/components/button";
10
+ import {
11
+ Item,
12
+ ItemActions,
13
+ ItemContent,
14
+ ItemDescription,
15
+ ItemTitle,
16
+ } from "@feedbax/ui/components/item";
17
+
18
+ type IdentityState = {
19
+ enabled: boolean;
20
+ ready: boolean;
21
+ displayName?: string;
22
+ error?: string;
23
+ signOut: () => Promise<void>;
24
+ };
25
+ const IdentityContext = createContext<IdentityState>({
26
+ enabled: Boolean(feedbax.connected),
27
+ ready: !feedbax.connected,
28
+ signOut: async () => {},
29
+ });
30
+ export function useConnectedIdentity() {
31
+ return useContext(IdentityContext);
32
+ }
33
+ export function ConnectedIdentityProvider({
34
+ children,
35
+ }: {
36
+ children: ReactNode;
37
+ }) {
38
+ const [state, setState] = useState<{
39
+ ready: boolean;
40
+ displayName?: string;
41
+ error?: string;
42
+ }>({ ready: !feedbax.connected });
43
+ useEffect(() => {
44
+ if (!feedbax.connected) return;
45
+ const controller = new AbortController();
46
+ fetch("/internal/identity/session", {
47
+ signal: controller.signal,
48
+ cache: "no-store",
49
+ })
50
+ .then(async (response) => {
51
+ if (!response.ok) throw new Error();
52
+ const value = await response.json();
53
+ setState({ ready: true, displayName: value.participant?.displayName });
54
+ })
55
+ .catch(() => {
56
+ if (!controller.signal.aborted)
57
+ setState({
58
+ ready: true,
59
+ error: "Sign-in is temporarily unavailable.",
60
+ });
61
+ });
62
+ return () => controller.abort();
63
+ }, []);
64
+ async function signOut() {
65
+ const response = await fetch("/internal/identity/logout", {
66
+ method: "POST",
67
+ });
68
+ if (!response.ok) {
69
+ setState((current) => ({
70
+ ...current,
71
+ error: "Sign-out failed. Try again.",
72
+ }));
73
+ return;
74
+ }
75
+ window.location.reload();
76
+ }
77
+ return (
78
+ <IdentityContext.Provider
79
+ value={{ enabled: Boolean(feedbax.connected), ...state, signOut }}
80
+ >
81
+ {children}
82
+ </IdentityContext.Provider>
83
+ );
84
+ }
85
+ export function ConnectedSignInPrompt() {
86
+ const identity = useConnectedIdentity();
87
+ return (
88
+ <Item variant="muted" size="sm" className="my-4">
89
+ <ItemContent className="min-w-36">
90
+ <ItemTitle>Join the conversation</ItemTitle>
91
+ <ItemDescription className="line-clamp-none">
92
+ {identity.error ?? "Sign in with your product account to comment."}
93
+ </ItemDescription>
94
+ </ItemContent>
95
+ <ItemActions>
96
+ <Button
97
+ nativeButton={false}
98
+ render={<a href="/internal/identity/start" />}
99
+ variant="outline"
100
+ size="sm"
101
+ disabled={!identity.ready}
102
+ >
103
+ Sign in
104
+ </Button>
105
+ </ItemActions>
106
+ </Item>
107
+ );
108
+ }
@@ -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
+ }
@@ -0,0 +1,324 @@
1
+ import { ConnectedIdentityProvider } from "./connected-identity-provider";
2
+ import {
3
+ createContext,
4
+ useContext,
5
+ useEffect,
6
+ useId,
7
+ useMemo,
8
+ useState,
9
+ type ReactNode,
10
+ } from "react";
11
+ import { useForm } from "@tanstack/react-form";
12
+ import { UserRound } from "lucide-react";
13
+ import { z } from "zod";
14
+
15
+ import { Button } from "@feedbax/ui/components/button";
16
+ import {
17
+ Dialog,
18
+ DialogContent,
19
+ DialogDescription,
20
+ DialogFooter,
21
+ DialogHeader,
22
+ DialogTitle,
23
+ } from "@feedbax/ui/components/dialog";
24
+ import {
25
+ Item,
26
+ ItemActions,
27
+ ItemContent,
28
+ ItemDescription,
29
+ ItemMedia,
30
+ ItemTitle,
31
+ } from "@feedbax/ui/components/item";
32
+ import { Skeleton } from "@feedbax/ui/components/skeleton";
33
+ import {
34
+ Field,
35
+ FieldDescription,
36
+ FieldError,
37
+ FieldGroup,
38
+ FieldLabel,
39
+ } from "@feedbax/ui/components/field";
40
+ import { Input } from "@feedbax/ui/components/input";
41
+
42
+ import {
43
+ clearDeviceProfile,
44
+ isCompleteDeviceProfile,
45
+ readDeviceProfile,
46
+ saveDeviceProfile,
47
+ type CompleteDeviceProfile,
48
+ type DeviceProfile,
49
+ } from "../browser-post-state";
50
+
51
+ const profileSchema = z.object({
52
+ name: z.string().trim().min(1, "Display name is required."),
53
+ email: z
54
+ .string()
55
+ .trim()
56
+ .refine((value) => value.length > 0, "Email is required.")
57
+ .refine(
58
+ (value) => value === "" || z.email().safeParse(value).success,
59
+ "Enter a valid email address.",
60
+ ),
61
+ });
62
+
63
+ type DeviceProfileContextValue = {
64
+ profile?: DeviceProfile;
65
+ completeProfile?: CompleteDeviceProfile;
66
+ ready: boolean;
67
+ openProfileSetup: () => void;
68
+ clearProfile: () => void;
69
+ };
70
+
71
+ const DeviceProfileContext = createContext<DeviceProfileContextValue | null>(
72
+ null,
73
+ );
74
+
75
+ export function DeviceProfileProvider({ children }: { children: ReactNode }) {
76
+ const [profile, setProfile] = useState<DeviceProfile>();
77
+ const [ready, setReady] = useState(false);
78
+ const [setupOpen, setSetupOpen] = useState(false);
79
+
80
+ useEffect(() => {
81
+ const refresh = () => {
82
+ setProfile(readDeviceProfile(localStorage));
83
+ setReady(true);
84
+ };
85
+ refresh();
86
+ window.addEventListener("storage", refresh);
87
+ return () => window.removeEventListener("storage", refresh);
88
+ }, []);
89
+
90
+ const value = useMemo<DeviceProfileContextValue>(
91
+ () => ({
92
+ profile,
93
+ completeProfile: isCompleteDeviceProfile(profile) ? profile : undefined,
94
+ ready,
95
+ openProfileSetup: () => setSetupOpen(true),
96
+ clearProfile: () => {
97
+ clearDeviceProfile(localStorage);
98
+ setProfile(undefined);
99
+ },
100
+ }),
101
+ [profile, ready],
102
+ );
103
+
104
+ return (
105
+ <DeviceProfileContext.Provider value={value}>
106
+ <ConnectedIdentityProvider>{children}</ConnectedIdentityProvider>
107
+ {setupOpen ? (
108
+ <ProfileSetupDialog
109
+ initialProfile={profile}
110
+ open
111
+ onOpenChange={setSetupOpen}
112
+ onSave={(next) => {
113
+ saveDeviceProfile(localStorage, next);
114
+ setProfile(next);
115
+ setSetupOpen(false);
116
+ }}
117
+ />
118
+ ) : null}
119
+ </DeviceProfileContext.Provider>
120
+ );
121
+ }
122
+
123
+ export function useDeviceProfile() {
124
+ const context = useContext(DeviceProfileContext);
125
+ const [standaloneProfile, setStandaloneProfile] = useState<DeviceProfile>();
126
+ const [standaloneReady, setStandaloneReady] = useState(false);
127
+
128
+ useEffect(() => {
129
+ if (context) return;
130
+ const refresh = () => {
131
+ setStandaloneProfile(readDeviceProfile(localStorage));
132
+ setStandaloneReady(true);
133
+ };
134
+ refresh();
135
+ window.addEventListener("storage", refresh);
136
+ return () => window.removeEventListener("storage", refresh);
137
+ }, [context]);
138
+
139
+ const standalone = useMemo<DeviceProfileContextValue>(
140
+ () => ({
141
+ profile: standaloneProfile,
142
+ completeProfile: isCompleteDeviceProfile(standaloneProfile)
143
+ ? standaloneProfile
144
+ : undefined,
145
+ ready: standaloneReady,
146
+ openProfileSetup: () => undefined,
147
+ clearProfile: () => {
148
+ clearDeviceProfile(localStorage);
149
+ setStandaloneProfile(undefined);
150
+ },
151
+ }),
152
+ [standaloneProfile, standaloneReady],
153
+ );
154
+
155
+ return context ?? standalone;
156
+ }
157
+
158
+ export function DeviceProfilePrompt({
159
+ purpose,
160
+ }: {
161
+ purpose: "comment" | "post";
162
+ }) {
163
+ const id = useId();
164
+ const { profile, ready, openProfileSetup } = useDeviceProfile();
165
+
166
+ if (!ready)
167
+ return (
168
+ <div
169
+ className="my-4 flex items-center gap-3"
170
+ aria-label="Loading profile"
171
+ aria-busy
172
+ >
173
+ <Skeleton className="size-8" />
174
+ <Skeleton className="h-4 w-48" />
175
+ </div>
176
+ );
177
+
178
+ const incomplete = Boolean(profile);
179
+ return (
180
+ <Item variant="muted" size="sm" className="my-4" aria-labelledby={id}>
181
+ <ItemMedia variant="icon">
182
+ <UserRound aria-hidden="true" />
183
+ </ItemMedia>
184
+ <ItemContent className="min-w-36">
185
+ <ItemTitle id={id}>
186
+ {incomplete ? "Complete your profile" : "Add your profile"}
187
+ </ItemTitle>
188
+ <ItemDescription className="line-clamp-none">
189
+ {purpose === "comment"
190
+ ? "Add a name and email to comment."
191
+ : "Add a name and email to create a Post."}{" "}
192
+ Email stays private. Details are unverified.
193
+ </ItemDescription>
194
+ </ItemContent>
195
+ <ItemActions>
196
+ <Button
197
+ type="button"
198
+ variant="outline"
199
+ size="sm"
200
+ onClick={openProfileSetup}
201
+ >
202
+ {incomplete ? "Complete profile" : "Add profile"}
203
+ </Button>
204
+ </ItemActions>
205
+ </Item>
206
+ );
207
+ }
208
+
209
+ function ProfileSetupDialog({
210
+ initialProfile,
211
+ open,
212
+ onOpenChange,
213
+ onSave,
214
+ }: {
215
+ initialProfile?: DeviceProfile;
216
+ open: boolean;
217
+ onOpenChange: (open: boolean) => void;
218
+ onSave: (profile: CompleteDeviceProfile) => void;
219
+ }) {
220
+ const form = useForm({
221
+ defaultValues: {
222
+ name: initialProfile?.name ?? "",
223
+ email: initialProfile?.email ?? "",
224
+ },
225
+ validators: { onSubmit: profileSchema },
226
+ onSubmit: ({ value }) =>
227
+ onSave({ name: value.name.trim(), email: value.email.trim() }),
228
+ });
229
+
230
+ return (
231
+ <Dialog open={open} onOpenChange={onOpenChange}>
232
+ <DialogContent>
233
+ <DialogHeader>
234
+ <DialogTitle>Profile</DialogTitle>
235
+ <DialogDescription>
236
+ Saved only on this device. This is not sign-in and cannot be
237
+ recovered.
238
+ </DialogDescription>
239
+ </DialogHeader>
240
+ <form
241
+ noValidate
242
+ onSubmit={(event) => {
243
+ event.preventDefault();
244
+ form.handleSubmit();
245
+ }}
246
+ >
247
+ <FieldGroup>
248
+ <form.Field
249
+ name="name"
250
+ children={(field) => {
251
+ const isInvalid = field.state.meta.errors.length > 0;
252
+ return (
253
+ <Field data-invalid={isInvalid}>
254
+ <FieldLabel htmlFor={field.name}>Display name</FieldLabel>
255
+ <Input
256
+ id={field.name}
257
+ name={field.name}
258
+ value={field.state.value}
259
+ onBlur={field.handleBlur}
260
+ onChange={(event) =>
261
+ field.handleChange(event.currentTarget.value)
262
+ }
263
+ aria-invalid={isInvalid}
264
+ autoComplete="name"
265
+ required
266
+ />
267
+ {isInvalid ? (
268
+ <FieldError errors={field.state.meta.errors} />
269
+ ) : (
270
+ <FieldDescription>
271
+ Shown next to your public contributions.
272
+ </FieldDescription>
273
+ )}
274
+ </Field>
275
+ );
276
+ }}
277
+ />
278
+ <form.Field
279
+ name="email"
280
+ children={(field) => {
281
+ const isInvalid = field.state.meta.errors.length > 0;
282
+ return (
283
+ <Field data-invalid={isInvalid}>
284
+ <FieldLabel htmlFor={field.name}>Email</FieldLabel>
285
+ <Input
286
+ id={field.name}
287
+ name={field.name}
288
+ type="email"
289
+ value={field.state.value}
290
+ onBlur={field.handleBlur}
291
+ onChange={(event) =>
292
+ field.handleChange(event.currentTarget.value)
293
+ }
294
+ aria-invalid={isInvalid}
295
+ autoComplete="email"
296
+ required
297
+ />
298
+ {isInvalid ? (
299
+ <FieldError errors={field.state.meta.errors} />
300
+ ) : (
301
+ <FieldDescription>
302
+ Required for Posts and Comments. Never shown publicly.
303
+ </FieldDescription>
304
+ )}
305
+ </Field>
306
+ );
307
+ }}
308
+ />
309
+ </FieldGroup>
310
+ <DialogFooter>
311
+ <Button
312
+ type="button"
313
+ variant="ghost"
314
+ onClick={() => onOpenChange(false)}
315
+ >
316
+ Cancel
317
+ </Button>
318
+ <Button type="submit">Save profile</Button>
319
+ </DialogFooter>
320
+ </form>
321
+ </DialogContent>
322
+ </Dialog>
323
+ );
324
+ }
@@ -0,0 +1,89 @@
1
+ import { Bell, BellRing } from "lucide-react";
2
+ import { useState } from "react";
3
+ import type { FollowState } from "@feedbax/feedback";
4
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { Button } from "@feedbax/ui/components/button";
6
+ import { useConnectedIdentity } from "./connected-identity-provider";
7
+ import { followQuery, participationRequest } from "../participation-client";
8
+
9
+ export function FollowPostButton({ slug }: { slug: string }) {
10
+ const identity = useConnectedIdentity();
11
+ if (!identity.enabled) return null;
12
+ return <ConnectedFollowPostButton slug={slug} />;
13
+ }
14
+ function ConnectedFollowPostButton({ slug }: { slug: string }) {
15
+ const identity = useConnectedIdentity();
16
+ const queryClient = useQueryClient();
17
+ const state = useQuery({
18
+ ...followQuery(slug),
19
+ enabled: identity.enabled && Boolean(identity.displayName),
20
+ });
21
+ const [pending, setPending] = useState(false);
22
+ const [error, setError] = useState<string>();
23
+ if (!identity.enabled) return null;
24
+ if (!identity.displayName)
25
+ return (
26
+ <Button
27
+ variant="outline"
28
+ size="sm"
29
+ nativeButton={false}
30
+ render={<a href="/internal/identity/start" />}
31
+ >
32
+ Sign in to follow
33
+ </Button>
34
+ );
35
+ async function toggle() {
36
+ setPending(true);
37
+ setError(undefined);
38
+ try {
39
+ const result = await participationRequest<FollowState>("follow", {
40
+ slug,
41
+ following: !state.data?.following,
42
+ });
43
+ queryClient.setQueryData(followQuery(slug).queryKey, result);
44
+ } catch (cause) {
45
+ setError(
46
+ cause instanceof Error ? cause.message : "Couldn’t update Follow.",
47
+ );
48
+ } finally {
49
+ setPending(false);
50
+ }
51
+ }
52
+ const following = state.data?.following;
53
+ return (
54
+ <div className="space-y-2">
55
+ <Button
56
+ variant={following ? "secondary" : "outline"}
57
+ size="sm"
58
+ onClick={state.isError ? () => void state.refetch() : toggle}
59
+ disabled={pending || state.isPending}
60
+ aria-pressed={Boolean(following)}
61
+ >
62
+ {following ? (
63
+ <BellRing aria-hidden="true" />
64
+ ) : (
65
+ <Bell aria-hidden="true" />
66
+ )}
67
+ {state.isError
68
+ ? "Retry follow status"
69
+ : pending
70
+ ? "Updating…"
71
+ : following
72
+ ? state.data?.initializing
73
+ ? "Cancel preparation"
74
+ : "Following"
75
+ : "Follow updates"}
76
+ </Button>
77
+ {state.data?.initializing ? (
78
+ <p className="text-xs text-muted-foreground">
79
+ Preparing updates for this Post…
80
+ </p>
81
+ ) : null}
82
+ {error ? (
83
+ <p role="alert" className="text-sm text-destructive">
84
+ {error}
85
+ </p>
86
+ ) : null}
87
+ </div>
88
+ );
89
+ }
@@ -0,0 +1,97 @@
1
+ import { Link, useRouterState } from "@tanstack/react-router";
2
+ import { cn } from "@feedbax/ui/lib/utils";
3
+
4
+ import { CommandPaletteTrigger } from "./command-palette-context";
5
+ import { NotificationInbox } from "./notification-inbox";
6
+ import { ProfileMenu } from "./profile-menu";
7
+ import type { PortalFeatures, ProductConfiguration } from "@feedbax/config";
8
+ import feedbax from "../feedbax";
9
+
10
+ function Navigation({
11
+ feedbackContext,
12
+ features,
13
+ }: {
14
+ feedbackContext: boolean;
15
+ features: PortalFeatures;
16
+ }) {
17
+ const linkClassName =
18
+ "grid flex-1 place-items-center border-b-2 border-transparent px-4 py-4 text-sm text-muted-foreground transition-colors hover:text-foreground md:flex-none md:rounded-md md:border md:px-4 md:py-2 data-[status=active]:border-foreground data-[status=active]:text-foreground data-[status=active]:md:bg-muted";
19
+ return (
20
+ <>
21
+ <Link
22
+ aria-current={feedbackContext ? "page" : undefined}
23
+ className={cn(
24
+ linkClassName,
25
+ feedbackContext && "border-foreground text-foreground md:bg-muted",
26
+ )}
27
+ to="/"
28
+ activeOptions={{ exact: true }}
29
+ >
30
+ Feedback
31
+ </Link>
32
+ <Link className={linkClassName} to="/roadmap">
33
+ Roadmap
34
+ </Link>
35
+ {features.changelog ? (
36
+ <Link className={linkClassName} to="/changelog">
37
+ Changelog
38
+ </Link>
39
+ ) : null}
40
+ </>
41
+ );
42
+ }
43
+
44
+ export default function Header({
45
+ features = feedbax.features,
46
+ product = feedbax.product,
47
+ }: {
48
+ features?: PortalFeatures;
49
+ product?: ProductConfiguration;
50
+ }) {
51
+ const pathname = useRouterState({
52
+ select: (state) => state.location.pathname,
53
+ });
54
+ const needsPageSearch = pathname === "/submit" || pathname.startsWith("/p/");
55
+
56
+ return (
57
+ <>
58
+ <a
59
+ className="fixed top-3 left-3 z-[100] -translate-y-20 border bg-background px-4 py-3 text-sm font-medium shadow-sm transition-transform focus-visible:translate-y-0"
60
+ href="#main-content"
61
+ >
62
+ Skip to content
63
+ </a>
64
+ <header className="sticky top-0 z-40 grid grid-cols-[minmax(0,1fr)_auto] items-center border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/85 md:h-20 md:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] md:px-8">
65
+ <a
66
+ className="inline-flex items-center gap-3 py-3 text-lg font-semibold md:py-0"
67
+ href="/"
68
+ aria-label={`${product.name} home`}
69
+ >
70
+ <img
71
+ className="size-10 border bg-muted/40 object-contain"
72
+ src={product.logo}
73
+ alt=""
74
+ aria-hidden="true"
75
+ />
76
+ {product.name}
77
+ </a>
78
+ <nav
79
+ className="order-3 col-span-2 -mx-4 flex border-t bg-background md:order-none md:col-span-1 md:col-start-2 md:row-start-1 md:mx-0 md:gap-1 md:border-0"
80
+ aria-label="Public portal"
81
+ >
82
+ <Navigation feedbackContext={needsPageSearch} features={features} />
83
+ </nav>
84
+ <div className="flex items-center justify-self-end gap-2 md:col-start-3 md:row-start-1">
85
+ {needsPageSearch ? (
86
+ <CommandPaletteTrigger
87
+ className="size-11 px-0 md:size-10"
88
+ compact
89
+ />
90
+ ) : null}
91
+ <NotificationInbox />
92
+ <ProfileMenu />
93
+ </div>
94
+ </header>
95
+ </>
96
+ );
97
+ }