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,772 @@
1
+ import { ImageAttachments, ImageGallery } from "./components/image-attachments";
2
+ import { FollowPostButton } from "./components/follow-post-button";
3
+ import {
4
+ useConnectedIdentity,
5
+ ConnectedSignInPrompt,
6
+ } from "./components/connected-identity-provider";
7
+ import { configuredTurnstileSiteKey } from "./turnstile-configuration";
8
+ import type { Comment, CommentThreadPage, PublicPost } from "@feedbax/feedback";
9
+ import type { PortalFeatures } from "@feedbax/config";
10
+ import { Skeleton } from "@feedbax/ui/components/skeleton";
11
+ import { Button } from "@feedbax/ui/components/button";
12
+ import { Textarea } from "@feedbax/ui/components/textarea";
13
+ import { ArrowLeft } from "lucide-react";
14
+ import {
15
+ type FormEvent,
16
+ type ReactNode,
17
+ useEffect,
18
+ useId,
19
+ useRef,
20
+ useState,
21
+ } from "react";
22
+ import type { CreatedComment } from "@feedbax/feedback";
23
+ import { PostStatusBadge, PostTypeBadge } from "./post-badges";
24
+ import { formatPublicDate } from "./public-date";
25
+ import feedbax from "./feedbax";
26
+ import { VoteToggle } from "./vote-toggle";
27
+ import { mergeCommentThreads } from "./comment-thread-page";
28
+ import type { PortalCommentRequest } from "./portal-comments";
29
+ import { isValidCommentEmail } from "./comment-profile";
30
+ import {
31
+ DeviceProfilePrompt,
32
+ useDeviceProfile,
33
+ } from "./components/device-profile-provider";
34
+
35
+ const emptyCommentPage: CommentThreadPage = { items: [] };
36
+ const commentCapabilitiesKey = "feedbax:comment-capabilities";
37
+
38
+ export type ConfirmedPortalComment = CreatedComment & {
39
+ commentCapability?: string;
40
+ commentCapabilityExpiresAt?: number;
41
+ };
42
+
43
+ export function PublicPostDetail({
44
+ post,
45
+ display = "page",
46
+ published = true,
47
+ summaryActions,
48
+ summaryMarker,
49
+ features = feedbax.features,
50
+ comments = emptyCommentPage,
51
+ commentsLoading = false,
52
+ commentsLoadError = false,
53
+ retryComments,
54
+ loadMore,
55
+ submitComment,
56
+ mutateComment,
57
+ turnstileSiteKey = configuredTurnstileSiteKey(),
58
+ }: {
59
+ post: PublicPost;
60
+ display?: "overlay" | "page";
61
+ published?: boolean;
62
+ summaryActions?: ReactNode;
63
+ summaryMarker?: ReactNode;
64
+ features?: PortalFeatures;
65
+ comments?: CommentThreadPage;
66
+ commentsLoading?: boolean;
67
+ commentsLoadError?: boolean;
68
+ retryComments?: () => void;
69
+ loadMore?: () => Promise<void>;
70
+ submitComment?: (
71
+ input: PortalCommentRequest,
72
+ ) => Promise<ConfirmedPortalComment>;
73
+ mutateComment?: (input: {
74
+ action: "edit" | "delete";
75
+ commentId: string;
76
+ commentCapability: string;
77
+ body?: string;
78
+ }) => Promise<unknown>;
79
+ turnstileSiteKey?: string;
80
+ }) {
81
+ const id = useId();
82
+ const headingId = `${id}-post-heading`;
83
+ const commentsHeadingId = `${id}-post-comments-heading`;
84
+ const detailsHeadingId = `${id}-post-details-heading`;
85
+ const [localComments, setLocalComments] = useState(comments.items);
86
+ const [commentError, setCommentError] = useState<string>();
87
+ const [images, setImages] = useState<
88
+ import("@feedbax/feedback").ImageAttachment[]
89
+ >([]);
90
+ const [uploading, setUploading] = useState(false);
91
+ const [submittingComment, setSubmittingComment] = useState(false);
92
+ const [replyingTo, setReplyingTo] = useState<string>();
93
+ const [editingComment, setEditingComment] = useState<{
94
+ id: string;
95
+ body: string;
96
+ }>();
97
+ const [commentCapabilities, setCommentCapabilities] = useState<
98
+ Record<string, StoredCommentCapability>
99
+ >({});
100
+ const turnstileContainer = useRef<HTMLDivElement>(null);
101
+ const composerInput = useRef<HTMLTextAreaElement>(null);
102
+ const turnstileWidget = useRef<string | undefined>(undefined);
103
+ const { completeProfile, ready: profileReady } = useDeviceProfile();
104
+ const identity = useConnectedIdentity();
105
+ const canComment = identity.enabled
106
+ ? Boolean(identity.displayName)
107
+ : Boolean(completeProfile);
108
+
109
+ useEffect(() => {
110
+ setLocalComments((current) =>
111
+ mergeCommentThreads(
112
+ comments.items,
113
+ current
114
+ .map((thread) => ({
115
+ ...thread,
116
+ comments: thread.comments.filter((comment) =>
117
+ comment.id.startsWith("optimistic-"),
118
+ ),
119
+ }))
120
+ .filter((thread) => thread.comments.length > 0),
121
+ ),
122
+ );
123
+ }, [comments.items]);
124
+
125
+ useEffect(() => {
126
+ const refresh = () => {
127
+ const now = Date.now();
128
+ const current = readStoredCommentCapabilities(window.localStorage);
129
+ setCommentCapabilities(
130
+ Object.fromEntries(
131
+ Object.entries(current).filter(([, value]) => value.expiresAt > now),
132
+ ),
133
+ );
134
+ };
135
+ refresh();
136
+ window.addEventListener("storage", refresh);
137
+ const timer = window.setInterval(refresh, 1_000);
138
+ return () => {
139
+ window.removeEventListener("storage", refresh);
140
+ window.clearInterval(timer);
141
+ };
142
+ }, []);
143
+
144
+ useEffect(() => {
145
+ if (
146
+ !submitComment ||
147
+ !turnstileSiteKey ||
148
+ sessionStorage.getItem("feedbax:participation-pass")
149
+ )
150
+ return;
151
+ const render = () => {
152
+ if (
153
+ !turnstileWidget.current &&
154
+ window.turnstile &&
155
+ turnstileContainer.current
156
+ )
157
+ turnstileWidget.current = window.turnstile.render(
158
+ turnstileContainer.current,
159
+ { sitekey: turnstileSiteKey },
160
+ );
161
+ };
162
+ const existing = document.querySelector<HTMLScriptElement>(
163
+ "script[data-turnstile]",
164
+ );
165
+ if (existing) {
166
+ if (window.turnstile) render();
167
+ else existing.addEventListener("load", render, { once: true });
168
+ return () => existing.removeEventListener("load", render);
169
+ }
170
+ const script = document.createElement("script");
171
+ script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js";
172
+ script.async = true;
173
+ script.defer = true;
174
+ script.dataset.turnstile = "true";
175
+ script.addEventListener("load", render, { once: true });
176
+ document.head.append(script);
177
+ return () => script.removeEventListener("load", render);
178
+ }, [commentError, submitComment, turnstileSiteKey]);
179
+
180
+ async function createComment(event: FormEvent<HTMLFormElement>) {
181
+ event.preventDefault();
182
+ if (!submitComment) return;
183
+ const form = new FormData(event.currentTarget);
184
+ const body = String(form.get("body") ?? "").trim();
185
+ const profile = completeProfile;
186
+ if (
187
+ !canComment ||
188
+ (!identity.enabled && (!profile || !isValidCommentEmail(profile.email)))
189
+ ) {
190
+ setCommentError(
191
+ "Add a display name and valid email to your Device Profile before commenting.",
192
+ );
193
+ return;
194
+ }
195
+ if (!body || uploading || submittingComment) return;
196
+ setSubmittingComment(true);
197
+ const optimisticId = `optimistic-${crypto.randomUUID()}`;
198
+ const discussionId = replyingTo ?? optimisticId;
199
+ const optimistic: CreatedComment = {
200
+ id: optimisticId,
201
+ discussionId,
202
+ body,
203
+ author: {
204
+ kind: "participant",
205
+ displayName: identity.enabled ? identity.displayName! : profile!.name,
206
+ },
207
+ createdAt: new Date(),
208
+ };
209
+ setCommentError(undefined);
210
+ setLocalComments((current) => addComment(current, optimistic));
211
+ event.currentTarget.reset();
212
+ setReplyingTo(undefined);
213
+ try {
214
+ const turnstileToken = form.get("cf-turnstile-response");
215
+ const confirmed = await submitComment({
216
+ slug: post.slug,
217
+ ...(images.length ? { imageIds: images.map((image) => image.id) } : {}),
218
+ body,
219
+ displayName: identity.enabled ? identity.displayName! : profile!.name,
220
+ email: identity.enabled ? "" : profile!.email,
221
+ ...(replyingTo ? { discussionId: replyingTo } : {}),
222
+ ...(turnstileToken ? { turnstileToken: String(turnstileToken) } : {}),
223
+ ...(sessionStorage.getItem("feedbax:participation-pass")
224
+ ? {
225
+ participationPass: sessionStorage.getItem(
226
+ "feedbax:participation-pass",
227
+ )!,
228
+ }
229
+ : {}),
230
+ });
231
+ setImages([]);
232
+ setLocalComments((current) =>
233
+ replaceComment(current, optimisticId, confirmed),
234
+ );
235
+ if (confirmed.commentCapability && confirmed.commentCapabilityExpiresAt)
236
+ setCommentCapabilities(
237
+ saveCommentCapability(
238
+ localStorage,
239
+ confirmed.id,
240
+ confirmed.commentCapability,
241
+ confirmed.commentCapabilityExpiresAt,
242
+ ),
243
+ );
244
+ } catch (error) {
245
+ setLocalComments((current) => removeComment(current, optimisticId));
246
+ setCommentError(
247
+ error instanceof Error
248
+ ? error.message
249
+ : "Comment could not be saved. Try again.",
250
+ );
251
+ } finally {
252
+ setSubmittingComment(false);
253
+ }
254
+ }
255
+
256
+ async function changeComment(
257
+ action: "edit" | "delete",
258
+ comment: Comment & { discussionId: string },
259
+ editedBody?: string,
260
+ ) {
261
+ if (!mutateComment) return;
262
+ const owned = readCommentCapability(localStorage, comment.id, Date.now());
263
+ if (!owned) return;
264
+ if (
265
+ action === "delete" &&
266
+ !window.confirm("Delete this Comment? This cannot be undone.")
267
+ )
268
+ return;
269
+ const body = action === "edit" ? editedBody?.trim() : undefined;
270
+ if (action === "edit" && !body) return;
271
+ const previous = localComments;
272
+ setCommentError(undefined);
273
+ setLocalComments((current) =>
274
+ action === "delete"
275
+ ? removeComment(current, comment.id)
276
+ : replaceComment(current, comment.id, { ...comment, body: body! }),
277
+ );
278
+ try {
279
+ await mutateComment({
280
+ action,
281
+ commentId: comment.id,
282
+ commentCapability: owned.capability,
283
+ ...(body ? { body } : {}),
284
+ });
285
+ if (action === "edit") setEditingComment(undefined);
286
+ if (action === "delete")
287
+ setCommentCapabilities(
288
+ removeStoredCommentCapability(localStorage, comment.id),
289
+ );
290
+ } catch (error) {
291
+ setLocalComments(previous);
292
+ if (action === "edit") setEditingComment(undefined);
293
+ setCommentError(
294
+ error instanceof Error
295
+ ? error.message
296
+ : "Comment could not be changed. Try again.",
297
+ );
298
+ }
299
+ }
300
+
301
+ return (
302
+ <main className="feedback-detail public-post-detail" data-display={display}>
303
+ {display === "page" ? (
304
+ <a className="feedback-detail-back" href="/">
305
+ <ArrowLeft aria-hidden="true" /> Back to Feedback
306
+ </a>
307
+ ) : null}
308
+ <article className="post-detail-layout" aria-labelledby={headingId}>
309
+ <div className="post-detail-primary">
310
+ <div className="feedback-detail-meta">
311
+ <PostTypeBadge type={post.type} />
312
+ <PostStatusBadge status={post.status} />
313
+ {summaryMarker}
314
+ </div>
315
+ <h1 id={headingId}>{post.title}</h1>
316
+ <p className="feedback-detail-description">{post.description}</p>
317
+ {post.images?.length &&
318
+ !published &&
319
+ identity.enabled &&
320
+ !identity.displayName ? (
321
+ <Button
322
+ nativeButton={false}
323
+ render={<a href="/internal/identity/start" />}
324
+ variant="outline"
325
+ size="sm"
326
+ >
327
+ Sign in to view images
328
+ </Button>
329
+ ) : (
330
+ <ImageGallery images={post.images} />
331
+ )}
332
+ {post.teamResponse ? (
333
+ <section
334
+ className="border-l-4 border-primary bg-muted/50 p-4"
335
+ aria-label="Product Team response"
336
+ >
337
+ <h2 className="text-sm font-semibold">Product Team response</h2>
338
+ <p className="mt-2 whitespace-pre-wrap">{post.teamResponse}</p>
339
+ {post.releaseNotesUrl ? (
340
+ <a
341
+ className="mt-3 inline-block text-sm font-medium underline underline-offset-4"
342
+ href={post.releaseNotesUrl}
343
+ >
344
+ Read release notes
345
+ </a>
346
+ ) : null}
347
+ </section>
348
+ ) : null}
349
+ {summaryActions ? (
350
+ <div className="post-detail-summary-actions">{summaryActions}</div>
351
+ ) : null}
352
+ <div className="post-detail-engagement flex flex-wrap items-center gap-3">
353
+ {features.voting && post.voteCount !== undefined ? (
354
+ <VoteToggle post={post} />
355
+ ) : null}
356
+ {published ? <FollowPostButton slug={post.slug} /> : null}
357
+ </div>
358
+ {features.comments ? (
359
+ <section
360
+ className="post-detail-comments"
361
+ aria-labelledby={commentsHeadingId}
362
+ >
363
+ <h2 id={commentsHeadingId}>Comments</h2>
364
+ {submitComment &&
365
+ (identity.enabled ? identity.ready : profileReady) &&
366
+ canComment ? (
367
+ <form
368
+ className="comment-composer"
369
+ aria-label="Comment composer"
370
+ onSubmit={createComment}
371
+ >
372
+ <label
373
+ className="comment-composer-label"
374
+ htmlFor={`${id}-comment-body`}
375
+ >
376
+ {replyingTo ? "Write a reply" : "Add a comment"}
377
+ </label>
378
+ <p className="comment-composer-hint">
379
+ {identity.enabled
380
+ ? `Posting as ${identity.displayName}.`
381
+ : "Your name is public. Your email stays private."}
382
+ </p>
383
+ <Textarea
384
+ ref={composerInput}
385
+ className="comment-composer-input"
386
+ id={`${id}-comment-body`}
387
+ name="body"
388
+ rows={3}
389
+ placeholder="Share context, an example, or a question…"
390
+ required
391
+ />
392
+ {turnstileSiteKey &&
393
+ !sessionStorage.getItem("feedbax:participation-pass") ? (
394
+ <div
395
+ ref={(element) => {
396
+ turnstileContainer.current = element;
397
+ if (
398
+ element &&
399
+ window.turnstile &&
400
+ !turnstileWidget.current
401
+ )
402
+ turnstileWidget.current = window.turnstile.render(
403
+ element,
404
+ { sitekey: turnstileSiteKey },
405
+ );
406
+ }}
407
+ className="cf-turnstile"
408
+ data-sitekey={turnstileSiteKey}
409
+ />
410
+ ) : null}
411
+ <ImageAttachments
412
+ value={images}
413
+ onChange={setImages}
414
+ onUploadingChange={setUploading}
415
+ />
416
+ <div className="comment-composer-actions">
417
+ <Button
418
+ type="submit"
419
+ disabled={uploading || submittingComment}
420
+ >
421
+ {submittingComment
422
+ ? "Posting…"
423
+ : replyingTo
424
+ ? "Post reply"
425
+ : "Post comment"}
426
+ </Button>
427
+ {replyingTo ? (
428
+ <Button
429
+ type="button"
430
+ variant="ghost"
431
+ onClick={() => setReplyingTo(undefined)}
432
+ >
433
+ Cancel reply
434
+ </Button>
435
+ ) : null}
436
+ </div>
437
+ </form>
438
+ ) : submitComment && identity.enabled ? (
439
+ <ConnectedSignInPrompt />
440
+ ) : submitComment ? (
441
+ <DeviceProfilePrompt purpose="comment" />
442
+ ) : null}
443
+ {commentError ? <p role="alert">{commentError}</p> : null}
444
+ <div
445
+ className="post-detail-thread-list"
446
+ role="region"
447
+ aria-label="Comment threads"
448
+ tabIndex={localComments.length ? 0 : undefined}
449
+ >
450
+ {commentsLoading && localComments.length === 0 ? (
451
+ <PostCommentsSkeleton />
452
+ ) : commentsLoadError &&
453
+ localComments.length === 0 ? null : localComments.length ===
454
+ 0 ? (
455
+ <p className="py-5 text-sm text-muted-foreground">
456
+ No comments yet
457
+ </p>
458
+ ) : (
459
+ <div className="comment-threads">
460
+ {localComments.map((thread) => (
461
+ <article className="comment-thread" key={thread.id}>
462
+ {thread.comments.map((comment, index) => {
463
+ const canChange = Boolean(
464
+ mutateComment && commentCapabilities[comment.id],
465
+ );
466
+ const canReply = Boolean(
467
+ index === 0 && submitComment && canComment,
468
+ );
469
+ return (
470
+ <div
471
+ className="comment"
472
+ data-reply={index > 0 || undefined}
473
+ key={comment.id}
474
+ >
475
+ <header className="comment-header">
476
+ <div className="comment-author">
477
+ <strong>{comment.author.displayName}</strong>
478
+ {comment.author.kind === "participant" ? (
479
+ <span>
480
+ {comment.author.verified
481
+ ? "Verified"
482
+ : "Unverified"}
483
+ </span>
484
+ ) : null}
485
+ </div>
486
+ <time
487
+ dateTime={comment.createdAt.toISOString()}
488
+ >
489
+ {formatPublicDate(comment.createdAt, "long")}
490
+ </time>
491
+ </header>
492
+ {editingComment?.id === comment.id ? (
493
+ <form
494
+ className="mt-3 space-y-2"
495
+ aria-label="Edit Comment"
496
+ onSubmit={(event) => {
497
+ event.preventDefault();
498
+ void changeComment(
499
+ "edit",
500
+ { ...comment, discussionId: thread.id },
501
+ editingComment.body,
502
+ );
503
+ }}
504
+ >
505
+ <Textarea
506
+ aria-label="Comment text"
507
+ value={editingComment.body}
508
+ onChange={(event) =>
509
+ setEditingComment({
510
+ id: comment.id,
511
+ body: event.target.value,
512
+ })
513
+ }
514
+ required
515
+ />
516
+ <div className="flex gap-2">
517
+ <Button size="sm" type="submit">
518
+ Save
519
+ </Button>
520
+ <Button
521
+ size="sm"
522
+ type="button"
523
+ variant="ghost"
524
+ onClick={() =>
525
+ setEditingComment(undefined)
526
+ }
527
+ >
528
+ Cancel
529
+ </Button>
530
+ </div>
531
+ </form>
532
+ ) : (
533
+ <>
534
+ <p className="comment-body">{comment.body}</p>
535
+ <ImageGallery images={comment.images} />
536
+ </>
537
+ )}
538
+ {canChange || canReply ? (
539
+ <div
540
+ className="comment-actions"
541
+ role="group"
542
+ aria-label={`Comment actions by ${comment.author.displayName}`}
543
+ >
544
+ {canReply ? (
545
+ <Button
546
+ type="button"
547
+ size="sm"
548
+ variant="ghost"
549
+ onClick={() => {
550
+ setReplyingTo(thread.id);
551
+ composerInput.current?.focus();
552
+ }}
553
+ >
554
+ Reply
555
+ </Button>
556
+ ) : null}
557
+ {canChange ? (
558
+ <>
559
+ <Button
560
+ type="button"
561
+ size="sm"
562
+ variant="ghost"
563
+ onClick={() =>
564
+ setEditingComment({
565
+ id: comment.id,
566
+ body: comment.body,
567
+ })
568
+ }
569
+ >
570
+ Edit
571
+ </Button>
572
+ <Button
573
+ type="button"
574
+ size="sm"
575
+ variant="ghost"
576
+ className="text-destructive hover:text-destructive"
577
+ onClick={() =>
578
+ void changeComment("delete", {
579
+ ...comment,
580
+ discussionId: thread.id,
581
+ })
582
+ }
583
+ >
584
+ Delete
585
+ </Button>
586
+ </>
587
+ ) : null}
588
+ </div>
589
+ ) : null}
590
+ </div>
591
+ );
592
+ })}
593
+ </article>
594
+ ))}
595
+ </div>
596
+ )}
597
+ {commentsLoadError ? (
598
+ <div className="space-y-3 py-4">
599
+ <p role="alert" className="text-sm text-muted-foreground">
600
+ Comments could not be loaded.
601
+ </p>
602
+ {retryComments ? (
603
+ <Button
604
+ type="button"
605
+ variant="outline"
606
+ size="sm"
607
+ onClick={retryComments}
608
+ >
609
+ Retry Comments
610
+ </Button>
611
+ ) : null}
612
+ </div>
613
+ ) : null}
614
+ {loadMore ? (
615
+ <Button
616
+ type="button"
617
+ variant="outline"
618
+ disabled={commentsLoading}
619
+ onClick={() => void loadMore()}
620
+ >
621
+ {commentsLoading ? "Loading Comments…" : "Load more"}
622
+ </Button>
623
+ ) : null}
624
+ </div>
625
+ </section>
626
+ ) : null}
627
+ </div>
628
+ <aside
629
+ className="post-detail-sidebar"
630
+ aria-labelledby={detailsHeadingId}
631
+ >
632
+ <h2 id={detailsHeadingId}>Details</h2>
633
+ <dl className="post-detail-details">
634
+ <div>
635
+ <dt>Status</dt>
636
+ <dd>
637
+ <PostStatusBadge status={post.status} />
638
+ </dd>
639
+ </div>
640
+ <div>
641
+ <dt>Post type</dt>
642
+ <dd>{post.type}</dd>
643
+ </div>
644
+ <div>
645
+ <dt>Submitted</dt>
646
+ <dd>
647
+ <time dateTime={post.createdAt.toISOString()}>
648
+ {formatPublicDate(post.createdAt, "long")}
649
+ </time>
650
+ </dd>
651
+ </div>
652
+ <div>
653
+ <dt>Updated</dt>
654
+ <dd>
655
+ <time dateTime={post.updatedAt.toISOString()}>
656
+ {formatPublicDate(post.updatedAt, "long")}
657
+ </time>
658
+ </dd>
659
+ </div>
660
+ </dl>
661
+ </aside>
662
+ </article>
663
+ </main>
664
+ );
665
+ }
666
+
667
+ function PostCommentsSkeleton() {
668
+ return (
669
+ <div
670
+ role="status"
671
+ aria-label="Loading Comments"
672
+ aria-busy="true"
673
+ className="space-y-5 py-6"
674
+ >
675
+ <span className="sr-only">Loading Comments…</span>
676
+ <div aria-hidden="true" className="space-y-4">
677
+ {[0, 1].map((row) => (
678
+ <div key={row} className="space-y-3">
679
+ <Skeleton className="h-3 w-24" />
680
+ <Skeleton className="h-3 w-full" />
681
+ <Skeleton className="h-3 w-2/3" />
682
+ </div>
683
+ ))}
684
+ </div>
685
+ </div>
686
+ );
687
+ }
688
+
689
+ export { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
690
+
691
+ function addComment(
692
+ threads: CommentThreadPage["items"],
693
+ comment: CreatedComment,
694
+ ) {
695
+ return mergeCommentThreads(threads, [
696
+ { id: comment.discussionId, comments: [comment] },
697
+ ]);
698
+ }
699
+
700
+ function removeComment(threads: CommentThreadPage["items"], commentId: string) {
701
+ return threads
702
+ .map((thread) => ({
703
+ ...thread,
704
+ comments: thread.comments.filter((comment) => comment.id !== commentId),
705
+ }))
706
+ .filter((thread) => thread.comments.length > 0);
707
+ }
708
+
709
+ function replaceComment(
710
+ threads: CommentThreadPage["items"],
711
+ optimisticId: string,
712
+ confirmed: CreatedComment,
713
+ ) {
714
+ return addComment(removeComment(threads, optimisticId), confirmed);
715
+ }
716
+
717
+ interface StoredCommentCapability {
718
+ capability: string;
719
+ expiresAt: number;
720
+ }
721
+
722
+ function readStoredCommentCapabilities(storage: Storage) {
723
+ try {
724
+ const value: unknown = JSON.parse(
725
+ storage.getItem(commentCapabilitiesKey) ?? "{}",
726
+ );
727
+ return value && typeof value === "object"
728
+ ? (value as Record<string, StoredCommentCapability>)
729
+ : {};
730
+ } catch {
731
+ return {};
732
+ }
733
+ }
734
+
735
+ function saveCommentCapability(
736
+ storage: Storage,
737
+ commentId: string,
738
+ capability: string,
739
+ expiresAt: number,
740
+ ) {
741
+ const capabilities = {
742
+ ...readStoredCommentCapabilities(storage),
743
+ [commentId]: { capability, expiresAt },
744
+ };
745
+ storage.setItem(commentCapabilitiesKey, JSON.stringify(capabilities));
746
+ return capabilities;
747
+ }
748
+
749
+ function readCommentCapability(
750
+ storage: Storage,
751
+ commentId: string,
752
+ now: number,
753
+ ) {
754
+ const value = readStoredCommentCapabilities(storage)[commentId];
755
+ if (
756
+ !value ||
757
+ typeof value.capability !== "string" ||
758
+ typeof value.expiresAt !== "number" ||
759
+ value.expiresAt <= now
760
+ ) {
761
+ if (value) removeStoredCommentCapability(storage, commentId);
762
+ return undefined;
763
+ }
764
+ return value;
765
+ }
766
+
767
+ function removeStoredCommentCapability(storage: Storage, commentId: string) {
768
+ const capabilities = readStoredCommentCapabilities(storage);
769
+ delete capabilities[commentId];
770
+ storage.setItem(commentCapabilitiesKey, JSON.stringify(capabilities));
771
+ return capabilities;
772
+ }