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,187 @@
1
+ import { ImagePlus, Loader2, X } from "lucide-react";
2
+ import { useId, useRef, useState } from "react";
3
+ import type { ImageAttachment } from "@feedbax/feedback";
4
+ import { Button } from "@feedbax/ui/components/button";
5
+ import { Input } from "@feedbax/ui/components/input";
6
+ import { useConnectedIdentity } from "./connected-identity-provider";
7
+ import feedbax from "../feedbax";
8
+
9
+ export function ImageAttachments({
10
+ value,
11
+ onChange,
12
+ onUploadingChange,
13
+ }: {
14
+ value: ImageAttachment[];
15
+ onChange: (images: ImageAttachment[]) => void;
16
+ onUploadingChange?: (pending: boolean) => void;
17
+ }) {
18
+ const identity = useConnectedIdentity();
19
+ if (!feedbax.connected?.images || !identity.displayName) return null;
20
+ return (
21
+ <ImageAttachmentInput
22
+ value={value}
23
+ onChange={onChange}
24
+ onUploadingChange={onUploadingChange}
25
+ />
26
+ );
27
+ }
28
+ function ImageAttachmentInput({
29
+ value,
30
+ onChange,
31
+ onUploadingChange,
32
+ }: {
33
+ value: ImageAttachment[];
34
+ onChange: (images: ImageAttachment[]) => void;
35
+ onUploadingChange?: (pending: boolean) => void;
36
+ }) {
37
+ const input = useRef<HTMLInputElement>(null);
38
+ const hintId = useId();
39
+ const [pending, setPending] = useState(false);
40
+ const [error, setError] = useState<string>();
41
+ async function upload(files: FileList | null) {
42
+ if (!files?.length) return;
43
+ setError(undefined);
44
+ const selected = Array.from(files);
45
+ if (selected.length + value.length > 4) {
46
+ setError("Choose up to four images.");
47
+ if (input.current) input.current.value = "";
48
+ return;
49
+ }
50
+ setPending(true);
51
+ onUploadingChange?.(true);
52
+ const next = [...value];
53
+ try {
54
+ for (const file of selected) {
55
+ if (file.size > 5 * 1024 * 1024)
56
+ throw new Error("Each image must be 5 MiB or smaller.");
57
+ if (!["image/jpeg", "image/png", "image/webp"].includes(file.type))
58
+ throw new Error("Choose a JPEG, PNG, or WebP image.");
59
+ const response = await fetch("/internal/images", {
60
+ method: "POST",
61
+ headers: { "Content-Type": file.type },
62
+ body: file,
63
+ });
64
+ const result = (await response.json()) as ImageAttachment & {
65
+ error?: string;
66
+ };
67
+ if (!response.ok)
68
+ throw new Error(result.error ?? "Couldn’t upload image.");
69
+ next.push(result);
70
+ onChange([...next]);
71
+ }
72
+ } catch (cause) {
73
+ setError(
74
+ cause instanceof Error ? cause.message : "Couldn’t upload image.",
75
+ );
76
+ } finally {
77
+ setPending(false);
78
+ onUploadingChange?.(false);
79
+ if (input.current) input.current.value = "";
80
+ }
81
+ }
82
+ async function remove(image: ImageAttachment) {
83
+ setError(undefined);
84
+ setPending(true);
85
+ onUploadingChange?.(true);
86
+ try {
87
+ const response = await fetch(`/internal/images/${image.id}`, {
88
+ method: "DELETE",
89
+ });
90
+ if (!response.ok) throw new Error("Couldn’t remove image. Try again.");
91
+ onChange(value.filter((item) => item.id !== image.id));
92
+ } catch (cause) {
93
+ setError(
94
+ cause instanceof Error ? cause.message : "Couldn’t remove image.",
95
+ );
96
+ } finally {
97
+ setPending(false);
98
+ onUploadingChange?.(false);
99
+ }
100
+ }
101
+ return (
102
+ <div className="space-y-3">
103
+ <Input
104
+ ref={input}
105
+ type="file"
106
+ accept="image/jpeg,image/png,image/webp"
107
+ multiple
108
+ className="sr-only"
109
+ tabIndex={-1}
110
+ aria-label="Choose image attachments"
111
+ onChange={(event) => void upload(event.currentTarget.files)}
112
+ />
113
+ <Button
114
+ type="button"
115
+ variant="outline"
116
+ size="sm"
117
+ disabled={pending || value.length >= 4}
118
+ aria-describedby={hintId}
119
+ onClick={() => input.current?.click()}
120
+ >
121
+ {pending ? (
122
+ <Loader2 className="animate-spin" aria-hidden="true" />
123
+ ) : (
124
+ <ImagePlus aria-hidden="true" />
125
+ )}
126
+ {pending ? "Uploading…" : "Add images"}
127
+ </Button>
128
+ <p id={hintId} className="text-xs text-muted-foreground">
129
+ Up to 4 images · JPEG, PNG, or WebP · 5 MiB each
130
+ </p>
131
+ {value.length ? (
132
+ <ul className="flex flex-wrap gap-3">
133
+ {value.map((image, index) => (
134
+ <li key={image.id} className="relative">
135
+ <img
136
+ src={image.src}
137
+ alt={`Attached image ${index + 1}`}
138
+ className="size-24 border bg-muted object-cover"
139
+ />
140
+ <Button
141
+ type="button"
142
+ variant="secondary"
143
+ size="icon-xs"
144
+ className="absolute top-1 right-1"
145
+ aria-label={`Remove image ${index + 1}`}
146
+ disabled={pending}
147
+ onClick={() => void remove(image)}
148
+ >
149
+ <X aria-hidden="true" />
150
+ </Button>
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ ) : null}
155
+ {error ? (
156
+ <p role="alert" className="text-sm text-destructive">
157
+ {error}
158
+ </p>
159
+ ) : null}
160
+ </div>
161
+ );
162
+ }
163
+ export function ImageGallery({ images }: { images?: ImageAttachment[] }) {
164
+ if (!images?.length) return null;
165
+ return (
166
+ <ul className="my-4 grid grid-cols-2 gap-3" aria-label="Attached images">
167
+ {images.map((image, index) => (
168
+ <li key={image.id}>
169
+ <a
170
+ href={image.src}
171
+ target="_blank"
172
+ rel="noopener noreferrer"
173
+ aria-label={`Open attached image ${index + 1}`}
174
+ className="block focus-visible:outline-2 focus-visible:outline-ring"
175
+ >
176
+ <img
177
+ src={image.src}
178
+ alt={`Attached image ${index + 1}`}
179
+ loading="lazy"
180
+ className="max-h-64 w-full border bg-muted/30 object-contain"
181
+ />
182
+ </a>
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ );
187
+ }
@@ -0,0 +1,53 @@
1
+ import { Skeleton } from "@feedbax/ui/components/skeleton";
2
+
3
+ export default function Loader({
4
+ label = "Loading content",
5
+ }: { label?: string } = {}) {
6
+ return (
7
+ <div
8
+ className="portal-loading"
9
+ role="status"
10
+ aria-label={label}
11
+ aria-busy="true"
12
+ >
13
+ <span className="sr-only">{label}…</span>
14
+ <div
15
+ aria-hidden="true"
16
+ className="mx-auto grid w-full max-w-7xl gap-10 px-6 py-10 md:grid-cols-[1fr_16rem] md:px-10"
17
+ >
18
+ <div className="space-y-8">
19
+ <div className="space-y-3">
20
+ <Skeleton className="h-8 w-40" />
21
+ <Skeleton className="h-4 w-64 max-w-full" />
22
+ </div>
23
+ <div className="flex gap-3">
24
+ <Skeleton className="h-10 w-24" />
25
+ <Skeleton className="h-10 w-24" />
26
+ </div>
27
+ <div className="divide-y border-y">
28
+ {[0, 1, 2].map((row) => (
29
+ <div
30
+ key={row}
31
+ data-post-skeleton-row
32
+ className="flex items-start gap-5 py-7"
33
+ >
34
+ <Skeleton className="size-10 shrink-0" />
35
+ <div className="flex-1 space-y-3">
36
+ <Skeleton className="h-4 w-3/5" />
37
+ <Skeleton className="h-3 w-4/5" />
38
+ <Skeleton className="h-3 w-24" />
39
+ </div>
40
+ </div>
41
+ ))}
42
+ </div>
43
+ </div>
44
+ <div className="hidden space-y-6 pt-3 md:block">
45
+ <Skeleton className="h-4 w-20" />
46
+ <Skeleton className="h-40 w-full" />
47
+ <Skeleton className="h-4 w-20" />
48
+ <Skeleton className="h-28 w-full" />
49
+ </div>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
@@ -0,0 +1,232 @@
1
+ import { Bell, Loader2 } from "lucide-react";
2
+ import { useState } from "react";
3
+ import {
4
+ useInfiniteQuery,
5
+ useQuery,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
8
+ import { Button } from "@feedbax/ui/components/button";
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogDescription,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ DialogTrigger,
16
+ } from "@feedbax/ui/components/dialog";
17
+ import { Label } from "@feedbax/ui/components/label";
18
+ import { Switch } from "@feedbax/ui/components/switch";
19
+ import { useConnectedIdentity } from "./connected-identity-provider";
20
+ import {
21
+ notificationQuery,
22
+ participationRequest,
23
+ preferencesQuery,
24
+ type EmailPreferences,
25
+ } from "../participation-client";
26
+ import { formatPublicDate } from "../public-date";
27
+
28
+ export function NotificationInbox() {
29
+ const identity = useConnectedIdentity();
30
+ if (!identity.enabled || !identity.displayName) return null;
31
+ return <ConnectedNotificationInbox />;
32
+ }
33
+ function ConnectedNotificationInbox() {
34
+ const identity = useConnectedIdentity();
35
+ const [open, setOpen] = useState(false);
36
+ const [error, setError] = useState<string>();
37
+ const [pending, setPending] = useState(false);
38
+ const queryClient = useQueryClient();
39
+ const enabled = identity.enabled && Boolean(identity.displayName);
40
+ const inbox = useInfiniteQuery({
41
+ ...notificationQuery,
42
+ enabled,
43
+ refetchInterval: enabled ? 60000 : false,
44
+ });
45
+ const preferences = useQuery({
46
+ ...preferencesQuery(),
47
+ enabled: enabled && open,
48
+ });
49
+ if (!enabled) return null;
50
+ const items = inbox.data?.pages.flatMap((page) => page.items) ?? [];
51
+ const unread = inbox.data?.pages[0]?.unreadCount ?? 0;
52
+ async function read(id?: string) {
53
+ setError(undefined);
54
+ try {
55
+ await participationRequest("notifications", id ? { id } : {});
56
+ await queryClient.invalidateQueries({
57
+ queryKey: notificationQuery.queryKey,
58
+ });
59
+ } catch (cause) {
60
+ setError(
61
+ cause instanceof Error
62
+ ? cause.message
63
+ : "Couldn’t mark updates as read.",
64
+ );
65
+ }
66
+ }
67
+ async function email(enabled: boolean) {
68
+ setError(undefined);
69
+ setPending(true);
70
+ try {
71
+ const next = await participationRequest<EmailPreferences>("preferences", {
72
+ emailEnabled: enabled,
73
+ });
74
+ queryClient.setQueryData(preferencesQuery().queryKey, next);
75
+ } catch (cause) {
76
+ setError(
77
+ cause instanceof Error
78
+ ? cause.message
79
+ : "Couldn’t save email preferences.",
80
+ );
81
+ } finally {
82
+ setPending(false);
83
+ }
84
+ }
85
+ return (
86
+ <Dialog open={open} onOpenChange={setOpen}>
87
+ <DialogTrigger
88
+ render={
89
+ <Button
90
+ variant="ghost"
91
+ size="icon"
92
+ aria-label={
93
+ unread ? `Notifications, ${unread} unread` : "Notifications"
94
+ }
95
+ className="relative"
96
+ />
97
+ }
98
+ >
99
+ <Bell aria-hidden="true" />
100
+ {unread ? (
101
+ <span className="absolute top-1 right-1 size-2 rounded-full bg-primary" />
102
+ ) : null}
103
+ </DialogTrigger>
104
+ <DialogContent className="max-h-[85svh] gap-0 overflow-hidden p-0 sm:max-w-lg">
105
+ <DialogHeader className="border-b p-5">
106
+ <DialogTitle className="text-lg">Notifications</DialogTitle>
107
+ <DialogDescription className="text-sm">
108
+ Updates on Posts you follow.
109
+ </DialogDescription>
110
+ </DialogHeader>
111
+ <div className="flex items-center justify-between gap-3 border-b px-5 py-3">
112
+ <span className="text-sm text-muted-foreground">{unread} unread</span>
113
+ <Button
114
+ size="sm"
115
+ variant="ghost"
116
+ disabled={!unread}
117
+ onClick={() => void read()}
118
+ >
119
+ Mark all read
120
+ </Button>
121
+ </div>
122
+ <div className="max-h-[45svh] overflow-y-auto">
123
+ {inbox.isPending ? (
124
+ <p role="status" className="flex items-center gap-2 p-5 text-sm">
125
+ <Loader2 className="size-4 animate-spin" />
126
+ Loading updates…
127
+ </p>
128
+ ) : inbox.isError ? (
129
+ <div className="space-y-3 p-5">
130
+ <p className="text-sm">Couldn’t load updates.</p>
131
+ <Button
132
+ size="sm"
133
+ variant="outline"
134
+ onClick={() => void inbox.refetch()}
135
+ >
136
+ Try again
137
+ </Button>
138
+ </div>
139
+ ) : items.length ? (
140
+ <ul className="divide-y">
141
+ {items.map((item) => (
142
+ <li
143
+ key={item.id}
144
+ className={item.read ? "p-5" : "bg-muted/40 p-5"}
145
+ >
146
+ <a
147
+ href={`/p/${encodeURIComponent(item.slug)}`}
148
+ className="block space-y-1 text-sm hover:underline"
149
+ onClick={() => {
150
+ void read(item.id);
151
+ setOpen(false);
152
+ }}
153
+ >
154
+ <span className="block font-medium">{item.title}</span>
155
+ <span className="block text-muted-foreground">
156
+ {item.message}
157
+ </span>
158
+ <time
159
+ className="block text-xs text-muted-foreground"
160
+ dateTime={new Date(item.createdAt).toISOString()}
161
+ >
162
+ {formatPublicDate(new Date(item.createdAt))}
163
+ </time>
164
+ </a>
165
+ {!item.read ? (
166
+ <Button
167
+ size="sm"
168
+ variant="ghost"
169
+ className="mt-2"
170
+ onClick={() => void read(item.id)}
171
+ >
172
+ Mark read
173
+ </Button>
174
+ ) : null}
175
+ </li>
176
+ ))}
177
+ </ul>
178
+ ) : (
179
+ <p className="p-5 text-sm text-muted-foreground">
180
+ Follow a Post to receive updates here.
181
+ </p>
182
+ )}
183
+ {inbox.hasNextPage ? (
184
+ <Button
185
+ variant="outline"
186
+ size="sm"
187
+ className="m-5"
188
+ disabled={inbox.isFetchingNextPage}
189
+ onClick={() => void inbox.fetchNextPage()}
190
+ >
191
+ {inbox.isFetchingNextPage ? "Loading…" : "View more"}
192
+ </Button>
193
+ ) : null}
194
+ </div>
195
+ <div className="space-y-2 border-t p-5">
196
+ <div className="flex items-center justify-between gap-4">
197
+ <Label htmlFor="notification-email">Email notifications</Label>
198
+ <Switch
199
+ id="notification-email"
200
+ checked={preferences.data?.emailEnabled ?? false}
201
+ disabled={pending || !preferences.data?.emailAvailable}
202
+ onCheckedChange={email}
203
+ />
204
+ </div>
205
+ {preferences.isError ? (
206
+ <Button
207
+ size="sm"
208
+ variant="ghost"
209
+ onClick={() => void preferences.refetch()}
210
+ >
211
+ Retry email preferences
212
+ </Button>
213
+ ) : !preferences.data?.emailAvailable && !preferences.isPending ? (
214
+ <p className="text-xs text-muted-foreground">
215
+ Email requires a verified address from your product account and a
216
+ configured sender.
217
+ </p>
218
+ ) : (
219
+ <p className="text-xs text-muted-foreground">
220
+ You can unsubscribe at any time.
221
+ </p>
222
+ )}
223
+ {error ? (
224
+ <p role="alert" className="text-sm text-destructive">
225
+ {error}
226
+ </p>
227
+ ) : null}
228
+ </div>
229
+ </DialogContent>
230
+ </Dialog>
231
+ );
232
+ }
@@ -0,0 +1,14 @@
1
+ export function PortalFooter() {
2
+ return (
3
+ <footer className="mt-auto flex justify-end border-t px-6 py-4 text-xs text-muted-foreground lg:px-10">
4
+ <a
5
+ href="https://github.com/jessequartey/feedbax"
6
+ target="_blank"
7
+ rel="noopener noreferrer"
8
+ className="transition-colors hover:text-foreground"
9
+ >
10
+ Powered by Feedbax
11
+ </a>
12
+ </footer>
13
+ );
14
+ }
@@ -0,0 +1,119 @@
1
+ import { useConnectedIdentity } from "./connected-identity-provider";
2
+ import { useTheme } from "next-themes";
3
+ import { UserCircle } from "lucide-react";
4
+
5
+ import { Avatar, AvatarFallback } from "@feedbax/ui/components/avatar";
6
+ import { Button } from "@feedbax/ui/components/button";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuGroup,
11
+ DropdownMenuItem,
12
+ DropdownMenuLabel,
13
+ DropdownMenuRadioGroup,
14
+ DropdownMenuRadioItem,
15
+ DropdownMenuSeparator,
16
+ DropdownMenuTrigger,
17
+ } from "@feedbax/ui/components/dropdown-menu";
18
+ import { deriveDeviceProfileInitials } from "../browser-post-state";
19
+ import { useDeviceProfile } from "./device-profile-provider";
20
+
21
+ export function ProfileMenu() {
22
+ const { profile, openProfileSetup, clearProfile } = useDeviceProfile();
23
+
24
+ const identity = useConnectedIdentity();
25
+
26
+ const initials = profile ? deriveDeviceProfileInitials(profile) : undefined;
27
+
28
+ if (identity.enabled)
29
+ return (
30
+ <div>
31
+ <Button variant="ghost" onClick={openProfileSetup}>
32
+ Contact profile
33
+ </Button>
34
+ {identity.displayName ? (
35
+ <>
36
+ <span>{identity.displayName}</span>
37
+ <Button variant="outline" onClick={() => void identity.signOut()}>
38
+ Sign out
39
+ </Button>
40
+ </>
41
+ ) : (
42
+ <Button
43
+ variant="outline"
44
+ disabled={!identity.ready}
45
+ onClick={() => {
46
+ window.location.href = "/internal/identity/start";
47
+ }}
48
+ >
49
+ Sign in
50
+ </Button>
51
+ )}
52
+ {identity.error ? <p role="alert">{identity.error}</p> : null}
53
+ </div>
54
+ );
55
+ return profile ? (
56
+ <DropdownMenu>
57
+ <DropdownMenuTrigger
58
+ render={
59
+ <Button
60
+ aria-label="Profile"
61
+ className="h-11 gap-2 px-3 md:h-10"
62
+ variant="outline"
63
+ type="button"
64
+ >
65
+ <Avatar className="size-5">
66
+ <AvatarFallback>{initials}</AvatarFallback>
67
+ </Avatar>
68
+ <span className="max-w-32 truncate">{profile.name}</span>
69
+ </Button>
70
+ }
71
+ />
72
+ <DropdownMenuContent align="end" className="w-56">
73
+ <DropdownMenuGroup>
74
+ <DropdownMenuLabel>
75
+ <span className="block truncate">{profile.name}</span>
76
+ {profile.email ? (
77
+ <span className="block truncate text-xs font-normal text-muted-foreground">
78
+ {profile.email}
79
+ </span>
80
+ ) : null}
81
+ </DropdownMenuLabel>
82
+ </DropdownMenuGroup>
83
+ <ThemeMenuItems />
84
+ <DropdownMenuSeparator />
85
+ <DropdownMenuItem onClick={openProfileSetup}>
86
+ Edit profile
87
+ </DropdownMenuItem>
88
+ <DropdownMenuItem onClick={clearProfile}>
89
+ Clear profile
90
+ </DropdownMenuItem>
91
+ </DropdownMenuContent>
92
+ </DropdownMenu>
93
+ ) : (
94
+ <Button
95
+ aria-label="Profile"
96
+ className="h-11 gap-2 px-3 md:h-10"
97
+ variant="outline"
98
+ type="button"
99
+ onClick={openProfileSetup}
100
+ >
101
+ <UserCircle aria-hidden="true" />
102
+ <span className="hidden sm:inline">Add profile</span>
103
+ </Button>
104
+ );
105
+ }
106
+
107
+ function ThemeMenuItems() {
108
+ const { theme, setTheme } = useTheme();
109
+ return (
110
+ <DropdownMenuRadioGroup
111
+ value={theme ?? "system"}
112
+ onValueChange={(value) => setTheme(value as string)}
113
+ >
114
+ <DropdownMenuRadioItem value="system">System</DropdownMenuRadioItem>
115
+ <DropdownMenuRadioItem value="light">Light</DropdownMenuRadioItem>
116
+ <DropdownMenuRadioItem value="dark">Dark</DropdownMenuRadioItem>
117
+ </DropdownMenuRadioGroup>
118
+ );
119
+ }