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,309 @@
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useEffect,
5
+ useId,
6
+ useMemo,
7
+ useState,
8
+ type ReactNode,
9
+ } from "react";
10
+ import { useForm } from "@tanstack/react-form";
11
+ import { UserRound } from "lucide-react";
12
+ import { z } from "zod";
13
+
14
+ import { Button } from "@feedbax/ui/components/button";
15
+ import {
16
+ Dialog,
17
+ DialogContent,
18
+ DialogDescription,
19
+ DialogFooter,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ } from "@feedbax/ui/components/dialog";
23
+ import {
24
+ Empty,
25
+ EmptyContent,
26
+ EmptyDescription,
27
+ EmptyHeader,
28
+ EmptyMedia,
29
+ EmptyTitle,
30
+ } from "@feedbax/ui/components/empty";
31
+ import {
32
+ Field,
33
+ FieldDescription,
34
+ FieldError,
35
+ FieldGroup,
36
+ FieldLabel,
37
+ } from "@feedbax/ui/components/field";
38
+ import { Input } from "@feedbax/ui/components/input";
39
+
40
+ import {
41
+ clearDeviceProfile,
42
+ isCompleteDeviceProfile,
43
+ readDeviceProfile,
44
+ saveDeviceProfile,
45
+ type CompleteDeviceProfile,
46
+ type DeviceProfile,
47
+ } from "../browser-post-state";
48
+
49
+ const profileSchema = z.object({
50
+ name: z.string().trim().min(1, "Display name is required."),
51
+ email: z
52
+ .string()
53
+ .trim()
54
+ .refine((value) => value.length > 0, "Email is required.")
55
+ .refine(
56
+ (value) => value === "" || z.email().safeParse(value).success,
57
+ "Enter a valid email address.",
58
+ ),
59
+ });
60
+
61
+ type DeviceProfileContextValue = {
62
+ profile?: DeviceProfile;
63
+ completeProfile?: CompleteDeviceProfile;
64
+ ready: boolean;
65
+ openProfileSetup: () => void;
66
+ clearProfile: () => void;
67
+ };
68
+
69
+ const DeviceProfileContext = createContext<DeviceProfileContextValue | null>(
70
+ null,
71
+ );
72
+
73
+ export function DeviceProfileProvider({ children }: { children: ReactNode }) {
74
+ const [profile, setProfile] = useState<DeviceProfile>();
75
+ const [ready, setReady] = useState(false);
76
+ const [setupOpen, setSetupOpen] = useState(false);
77
+
78
+ useEffect(() => {
79
+ const refresh = () => {
80
+ setProfile(readDeviceProfile(localStorage));
81
+ setReady(true);
82
+ };
83
+ refresh();
84
+ window.addEventListener("storage", refresh);
85
+ return () => window.removeEventListener("storage", refresh);
86
+ }, []);
87
+
88
+ const value = useMemo<DeviceProfileContextValue>(
89
+ () => ({
90
+ profile,
91
+ completeProfile: isCompleteDeviceProfile(profile) ? profile : undefined,
92
+ ready,
93
+ openProfileSetup: () => setSetupOpen(true),
94
+ clearProfile: () => {
95
+ clearDeviceProfile(localStorage);
96
+ setProfile(undefined);
97
+ },
98
+ }),
99
+ [profile, ready],
100
+ );
101
+
102
+ return (
103
+ <DeviceProfileContext.Provider value={value}>
104
+ {children}
105
+ {setupOpen ? (
106
+ <ProfileSetupDialog
107
+ initialProfile={profile}
108
+ open
109
+ onOpenChange={setSetupOpen}
110
+ onSave={(next) => {
111
+ saveDeviceProfile(localStorage, next);
112
+ setProfile(next);
113
+ setSetupOpen(false);
114
+ }}
115
+ />
116
+ ) : null}
117
+ </DeviceProfileContext.Provider>
118
+ );
119
+ }
120
+
121
+ export function useDeviceProfile() {
122
+ const context = useContext(DeviceProfileContext);
123
+ const [standaloneProfile, setStandaloneProfile] = useState<DeviceProfile>();
124
+ const [standaloneReady, setStandaloneReady] = useState(false);
125
+
126
+ useEffect(() => {
127
+ if (context) return;
128
+ const refresh = () => {
129
+ setStandaloneProfile(readDeviceProfile(localStorage));
130
+ setStandaloneReady(true);
131
+ };
132
+ refresh();
133
+ window.addEventListener("storage", refresh);
134
+ return () => window.removeEventListener("storage", refresh);
135
+ }, [context]);
136
+
137
+ const standalone = useMemo<DeviceProfileContextValue>(
138
+ () => ({
139
+ profile: standaloneProfile,
140
+ completeProfile: isCompleteDeviceProfile(standaloneProfile)
141
+ ? standaloneProfile
142
+ : undefined,
143
+ ready: standaloneReady,
144
+ openProfileSetup: () => undefined,
145
+ clearProfile: () => {
146
+ clearDeviceProfile(localStorage);
147
+ setStandaloneProfile(undefined);
148
+ },
149
+ }),
150
+ [standaloneProfile, standaloneReady],
151
+ );
152
+
153
+ return context ?? standalone;
154
+ }
155
+
156
+ export function DeviceProfilePrompt({
157
+ purpose,
158
+ }: {
159
+ purpose: "comment" | "post";
160
+ }) {
161
+ const id = useId();
162
+ const { profile, ready, openProfileSetup } = useDeviceProfile();
163
+
164
+ if (!ready)
165
+ return (
166
+ <div className="profile-gate" aria-label="Loading profile" aria-busy />
167
+ );
168
+
169
+ const incomplete = Boolean(profile);
170
+ return (
171
+ <Empty className="profile-gate" aria-labelledby={id}>
172
+ <EmptyHeader>
173
+ <EmptyMedia variant="icon">
174
+ <UserRound aria-hidden="true" />
175
+ </EmptyMedia>
176
+ <EmptyTitle id={id}>
177
+ {incomplete ? "Complete your profile" : "Add your profile"}
178
+ </EmptyTitle>
179
+ <EmptyDescription>
180
+ A display name and valid email are required to{" "}
181
+ {purpose === "comment" ? "join the conversation" : "create a Post"}.
182
+ Your email stays private and is not verified.
183
+ </EmptyDescription>
184
+ </EmptyHeader>
185
+ <EmptyContent>
186
+ <Button type="button" onClick={openProfileSetup}>
187
+ {incomplete ? "Complete profile" : "Add profile"}
188
+ </Button>
189
+ </EmptyContent>
190
+ </Empty>
191
+ );
192
+ }
193
+
194
+ function ProfileSetupDialog({
195
+ initialProfile,
196
+ open,
197
+ onOpenChange,
198
+ onSave,
199
+ }: {
200
+ initialProfile?: DeviceProfile;
201
+ open: boolean;
202
+ onOpenChange: (open: boolean) => void;
203
+ onSave: (profile: CompleteDeviceProfile) => void;
204
+ }) {
205
+ const form = useForm({
206
+ defaultValues: {
207
+ name: initialProfile?.name ?? "",
208
+ email: initialProfile?.email ?? "",
209
+ },
210
+ validators: { onSubmit: profileSchema },
211
+ onSubmit: ({ value }) =>
212
+ onSave({ name: value.name.trim(), email: value.email.trim() }),
213
+ });
214
+
215
+ return (
216
+ <Dialog open={open} onOpenChange={onOpenChange}>
217
+ <DialogContent>
218
+ <DialogHeader>
219
+ <DialogTitle>Profile</DialogTitle>
220
+ <DialogDescription>
221
+ Saved only on this device. This is not sign-in and cannot be
222
+ recovered.
223
+ </DialogDescription>
224
+ </DialogHeader>
225
+ <form
226
+ noValidate
227
+ onSubmit={(event) => {
228
+ event.preventDefault();
229
+ form.handleSubmit();
230
+ }}
231
+ >
232
+ <FieldGroup>
233
+ <form.Field
234
+ name="name"
235
+ children={(field) => {
236
+ const isInvalid = field.state.meta.errors.length > 0;
237
+ return (
238
+ <Field data-invalid={isInvalid}>
239
+ <FieldLabel htmlFor={field.name}>Display name</FieldLabel>
240
+ <Input
241
+ id={field.name}
242
+ name={field.name}
243
+ value={field.state.value}
244
+ onBlur={field.handleBlur}
245
+ onChange={(event) =>
246
+ field.handleChange(event.currentTarget.value)
247
+ }
248
+ aria-invalid={isInvalid}
249
+ autoComplete="name"
250
+ required
251
+ />
252
+ {isInvalid ? (
253
+ <FieldError errors={field.state.meta.errors} />
254
+ ) : (
255
+ <FieldDescription>
256
+ Shown next to your public contributions.
257
+ </FieldDescription>
258
+ )}
259
+ </Field>
260
+ );
261
+ }}
262
+ />
263
+ <form.Field
264
+ name="email"
265
+ children={(field) => {
266
+ const isInvalid = field.state.meta.errors.length > 0;
267
+ return (
268
+ <Field data-invalid={isInvalid}>
269
+ <FieldLabel htmlFor={field.name}>Email</FieldLabel>
270
+ <Input
271
+ id={field.name}
272
+ name={field.name}
273
+ type="email"
274
+ value={field.state.value}
275
+ onBlur={field.handleBlur}
276
+ onChange={(event) =>
277
+ field.handleChange(event.currentTarget.value)
278
+ }
279
+ aria-invalid={isInvalid}
280
+ autoComplete="email"
281
+ required
282
+ />
283
+ {isInvalid ? (
284
+ <FieldError errors={field.state.meta.errors} />
285
+ ) : (
286
+ <FieldDescription>
287
+ Required for Posts and Comments. Never shown publicly.
288
+ </FieldDescription>
289
+ )}
290
+ </Field>
291
+ );
292
+ }}
293
+ />
294
+ </FieldGroup>
295
+ <DialogFooter>
296
+ <Button
297
+ type="button"
298
+ variant="ghost"
299
+ onClick={() => onOpenChange(false)}
300
+ >
301
+ Cancel
302
+ </Button>
303
+ <Button type="submit">Save profile</Button>
304
+ </DialogFooter>
305
+ </form>
306
+ </DialogContent>
307
+ </Dialog>
308
+ );
309
+ }
@@ -0,0 +1,95 @@
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 { ProfileMenu } from "./profile-menu";
6
+ import type { PortalFeatures, ProductConfiguration } from "@feedbax/config";
7
+ import feedbax from "../feedbax";
8
+
9
+ function Navigation({
10
+ feedbackContext,
11
+ features,
12
+ }: {
13
+ feedbackContext: boolean;
14
+ features: PortalFeatures;
15
+ }) {
16
+ const linkClassName =
17
+ "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";
18
+ return (
19
+ <>
20
+ <Link
21
+ aria-current={feedbackContext ? "page" : undefined}
22
+ className={cn(
23
+ linkClassName,
24
+ feedbackContext && "border-foreground text-foreground md:bg-muted",
25
+ )}
26
+ to="/"
27
+ activeOptions={{ exact: true }}
28
+ >
29
+ Feedback
30
+ </Link>
31
+ <Link className={linkClassName} to="/roadmap">
32
+ Roadmap
33
+ </Link>
34
+ {features.changelog ? (
35
+ <Link className={linkClassName} to="/changelog">
36
+ Changelog
37
+ </Link>
38
+ ) : null}
39
+ </>
40
+ );
41
+ }
42
+
43
+ export default function Header({
44
+ features = feedbax.features,
45
+ product = feedbax.product,
46
+ }: {
47
+ features?: PortalFeatures;
48
+ product?: ProductConfiguration;
49
+ }) {
50
+ const pathname = useRouterState({
51
+ select: (state) => state.location.pathname,
52
+ });
53
+ const needsPageSearch = pathname === "/submit" || pathname.startsWith("/p/");
54
+
55
+ return (
56
+ <>
57
+ <a
58
+ 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"
59
+ href="#main-content"
60
+ >
61
+ Skip to content
62
+ </a>
63
+ <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">
64
+ <a
65
+ className="inline-flex items-center gap-3 py-3 text-lg font-semibold md:py-0"
66
+ href="/"
67
+ aria-label={`${product.name} home`}
68
+ >
69
+ <img
70
+ className="size-10 border bg-muted/40 object-contain"
71
+ src={product.logo}
72
+ alt=""
73
+ aria-hidden="true"
74
+ />
75
+ {product.name}
76
+ </a>
77
+ <nav
78
+ 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"
79
+ aria-label="Public portal"
80
+ >
81
+ <Navigation feedbackContext={needsPageSearch} features={features} />
82
+ </nav>
83
+ <div className="flex items-center justify-self-end gap-2 md:col-start-3 md:row-start-1">
84
+ {needsPageSearch ? (
85
+ <CommandPaletteTrigger
86
+ className="size-11 px-0 md:size-10"
87
+ compact
88
+ />
89
+ ) : null}
90
+ <ProfileMenu />
91
+ </div>
92
+ </header>
93
+ </>
94
+ );
95
+ }
@@ -0,0 +1,10 @@
1
+ import { Loader2 } from "lucide-react";
2
+
3
+ export default function Loader() {
4
+ return (
5
+ <div className="portal-loading" role="status">
6
+ <Loader2 aria-hidden="true" />
7
+ <span>Loading published feedback…</span>
8
+ </div>
9
+ );
10
+ }
@@ -0,0 +1,89 @@
1
+ import { useTheme } from "next-themes";
2
+ import { UserCircle } from "lucide-react";
3
+
4
+ import { Avatar, AvatarFallback } from "@feedbax/ui/components/avatar";
5
+ import { Button } from "@feedbax/ui/components/button";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuGroup,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuRadioGroup,
13
+ DropdownMenuRadioItem,
14
+ DropdownMenuSeparator,
15
+ DropdownMenuTrigger,
16
+ } from "@feedbax/ui/components/dropdown-menu";
17
+ import { deriveDeviceProfileInitials } from "../browser-post-state";
18
+ import { useDeviceProfile } from "./device-profile-provider";
19
+
20
+ export function ProfileMenu() {
21
+ const { profile, openProfileSetup, clearProfile } = useDeviceProfile();
22
+
23
+ const initials = profile ? deriveDeviceProfileInitials(profile) : undefined;
24
+
25
+ return profile ? (
26
+ <DropdownMenu>
27
+ <DropdownMenuTrigger
28
+ render={
29
+ <Button
30
+ aria-label="Profile"
31
+ className="h-11 gap-2 px-3 md:h-10"
32
+ variant="outline"
33
+ type="button"
34
+ >
35
+ <Avatar className="size-5">
36
+ <AvatarFallback>{initials}</AvatarFallback>
37
+ </Avatar>
38
+ <span className="max-w-32 truncate">{profile.name}</span>
39
+ </Button>
40
+ }
41
+ />
42
+ <DropdownMenuContent align="end" className="w-56">
43
+ <DropdownMenuGroup>
44
+ <DropdownMenuLabel>
45
+ <span className="block truncate">{profile.name}</span>
46
+ {profile.email ? (
47
+ <span className="block truncate text-xs font-normal text-muted-foreground">
48
+ {profile.email}
49
+ </span>
50
+ ) : null}
51
+ </DropdownMenuLabel>
52
+ </DropdownMenuGroup>
53
+ <ThemeMenuItems />
54
+ <DropdownMenuSeparator />
55
+ <DropdownMenuItem onClick={openProfileSetup}>
56
+ Edit profile
57
+ </DropdownMenuItem>
58
+ <DropdownMenuItem onClick={clearProfile}>
59
+ Clear profile
60
+ </DropdownMenuItem>
61
+ </DropdownMenuContent>
62
+ </DropdownMenu>
63
+ ) : (
64
+ <Button
65
+ aria-label="Profile"
66
+ className="h-11 gap-2 px-3 md:h-10"
67
+ variant="outline"
68
+ type="button"
69
+ onClick={openProfileSetup}
70
+ >
71
+ <UserCircle aria-hidden="true" />
72
+ <span className="hidden sm:inline">Add profile</span>
73
+ </Button>
74
+ );
75
+ }
76
+
77
+ function ThemeMenuItems() {
78
+ const { theme, setTheme } = useTheme();
79
+ return (
80
+ <DropdownMenuRadioGroup
81
+ value={theme ?? "system"}
82
+ onValueChange={(value) => setTheme(value as string)}
83
+ >
84
+ <DropdownMenuRadioItem value="system">System</DropdownMenuRadioItem>
85
+ <DropdownMenuRadioItem value="light">Light</DropdownMenuRadioItem>
86
+ <DropdownMenuRadioItem value="dark">Dark</DropdownMenuRadioItem>
87
+ </DropdownMenuRadioGroup>
88
+ );
89
+ }
@@ -0,0 +1,185 @@
1
+ import * as React from "react";
2
+ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog";
3
+
4
+ import { cn } from "@feedbax/ui/lib/utils";
5
+ import { Button } from "@feedbax/ui/components/button";
6
+
7
+ function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
8
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
9
+ }
10
+
11
+ function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
12
+ return (
13
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
14
+ );
15
+ }
16
+
17
+ function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
18
+ return (
19
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
20
+ );
21
+ }
22
+
23
+ function AlertDialogOverlay({
24
+ className,
25
+ ...props
26
+ }: AlertDialogPrimitive.Backdrop.Props) {
27
+ return (
28
+ <AlertDialogPrimitive.Backdrop
29
+ data-slot="alert-dialog-overlay"
30
+ className={cn(
31
+ "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
32
+ className,
33
+ )}
34
+ {...props}
35
+ />
36
+ );
37
+ }
38
+
39
+ function AlertDialogContent({
40
+ className,
41
+ size = "default",
42
+ ...props
43
+ }: AlertDialogPrimitive.Popup.Props & {
44
+ size?: "default" | "sm";
45
+ }) {
46
+ return (
47
+ <AlertDialogPortal>
48
+ <AlertDialogOverlay />
49
+ <AlertDialogPrimitive.Popup
50
+ data-slot="alert-dialog-content"
51
+ data-size={size}
52
+ className={cn(
53
+ "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-none bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
54
+ className,
55
+ )}
56
+ {...props}
57
+ />
58
+ </AlertDialogPortal>
59
+ );
60
+ }
61
+
62
+ function AlertDialogHeader({
63
+ className,
64
+ ...props
65
+ }: React.ComponentProps<"div">) {
66
+ return (
67
+ <div
68
+ data-slot="alert-dialog-header"
69
+ className={cn(
70
+ "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
71
+ className,
72
+ )}
73
+ {...props}
74
+ />
75
+ );
76
+ }
77
+
78
+ function AlertDialogFooter({
79
+ className,
80
+ ...props
81
+ }: React.ComponentProps<"div">) {
82
+ return (
83
+ <div
84
+ data-slot="alert-dialog-footer"
85
+ className={cn(
86
+ "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
87
+ className,
88
+ )}
89
+ {...props}
90
+ />
91
+ );
92
+ }
93
+
94
+ function AlertDialogMedia({
95
+ className,
96
+ ...props
97
+ }: React.ComponentProps<"div">) {
98
+ return (
99
+ <div
100
+ data-slot="alert-dialog-media"
101
+ className={cn(
102
+ "mb-2 inline-flex size-10 items-center justify-center rounded-none bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
103
+ className,
104
+ )}
105
+ {...props}
106
+ />
107
+ );
108
+ }
109
+
110
+ function AlertDialogTitle({
111
+ className,
112
+ ...props
113
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
114
+ return (
115
+ <AlertDialogPrimitive.Title
116
+ data-slot="alert-dialog-title"
117
+ className={cn(
118
+ "font-heading text-sm font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
119
+ className,
120
+ )}
121
+ {...props}
122
+ />
123
+ );
124
+ }
125
+
126
+ function AlertDialogDescription({
127
+ className,
128
+ ...props
129
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
130
+ return (
131
+ <AlertDialogPrimitive.Description
132
+ data-slot="alert-dialog-description"
133
+ className={cn(
134
+ "text-xs/relaxed text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
135
+ className,
136
+ )}
137
+ {...props}
138
+ />
139
+ );
140
+ }
141
+
142
+ function AlertDialogAction({
143
+ className,
144
+ ...props
145
+ }: React.ComponentProps<typeof Button>) {
146
+ return (
147
+ <Button
148
+ data-slot="alert-dialog-action"
149
+ className={cn(className)}
150
+ {...props}
151
+ />
152
+ );
153
+ }
154
+
155
+ function AlertDialogCancel({
156
+ className,
157
+ variant = "outline",
158
+ size = "default",
159
+ ...props
160
+ }: AlertDialogPrimitive.Close.Props &
161
+ Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
162
+ return (
163
+ <AlertDialogPrimitive.Close
164
+ data-slot="alert-dialog-cancel"
165
+ className={cn(className)}
166
+ render={<Button variant={variant} size={size} />}
167
+ {...props}
168
+ />
169
+ );
170
+ }
171
+
172
+ export {
173
+ AlertDialog,
174
+ AlertDialogAction,
175
+ AlertDialogCancel,
176
+ AlertDialogContent,
177
+ AlertDialogDescription,
178
+ AlertDialogFooter,
179
+ AlertDialogHeader,
180
+ AlertDialogMedia,
181
+ AlertDialogOverlay,
182
+ AlertDialogPortal,
183
+ AlertDialogTitle,
184
+ AlertDialogTrigger,
185
+ };