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