create-feedbax 0.2.0 → 0.2.2

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 (143) hide show
  1. package/README.md +17 -5
  2. package/dist/cli.js +2610 -291
  3. package/dist/index.js +2375 -200
  4. package/package.json +7 -7
  5. package/template/.feedbax-template.sha256 +1 -1
  6. package/template/.gitignore.template +1 -0
  7. package/template/.npmignore.template +1 -0
  8. package/template/.nvmrc +1 -0
  9. package/template/AGENTS.md +13 -0
  10. package/template/CONNECTED.md +418 -0
  11. package/template/README.md +97 -0
  12. package/template/components.json +1 -1
  13. package/template/eslint.config.mjs +23 -0
  14. package/template/feedbax-vite.ts +79 -0
  15. package/template/migrations/0001_connected.sql +6 -0
  16. package/template/migrations/0002_participation.sql +8 -0
  17. package/template/migrations/0003_images.sql +3 -0
  18. package/template/migrations/0004_followed_status.sql +9 -0
  19. package/template/migrations/0005_github.sql +4 -0
  20. package/template/migrations/0006_integration.sql +3 -0
  21. package/template/migrations/0007_ownership.sql +2 -0
  22. package/template/migrations/0008_integration_recovery.sql +5 -0
  23. package/template/migrations/0009_observed_history.sql +3 -0
  24. package/template/package.json +39 -34
  25. package/template/src/authorized-draft-post.tsx +1 -0
  26. package/template/src/changelog-page.tsx +63 -54
  27. package/template/src/changelog-runtime.ts +2 -1
  28. package/template/src/cloudflare-workers.d.ts +9 -0
  29. package/template/src/comment-creation.test.tsx +95 -7
  30. package/template/src/comment-thread-page.ts +11 -1
  31. package/template/src/components/command-palette.tsx +59 -30
  32. package/template/src/components/connected-identity-provider.tsx +135 -0
  33. package/template/src/components/device-profile-provider.tsx +41 -26
  34. package/template/src/components/follow-post-button.tsx +78 -0
  35. package/template/src/components/header.tsx +2 -0
  36. package/template/src/components/image-attachments.tsx +187 -0
  37. package/template/src/components/loader.tsx +48 -5
  38. package/template/src/components/notification-inbox.tsx +232 -0
  39. package/template/src/components/portal-footer.tsx +14 -0
  40. package/template/src/components/post-history.tsx +101 -0
  41. package/template/src/components/profile-menu.tsx +37 -0
  42. package/template/src/components/ui/components/command.tsx +30 -29
  43. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  44. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  45. package/template/src/components/ui/components/switch.tsx +29 -0
  46. package/template/src/components/ui/styles/globals.css +1 -1
  47. package/template/src/configured-feedback.test.ts +82 -0
  48. package/template/src/configured-feedback.ts +61 -0
  49. package/template/src/connected-identity.test.ts +148 -0
  50. package/template/src/connected-identity.ts +127 -0
  51. package/template/src/connected-participation.test.tsx +67 -0
  52. package/template/src/connected-runtime.test.ts +244 -0
  53. package/template/src/connected-runtime.ts +176 -0
  54. package/template/src/connected-social.test.tsx +158 -0
  55. package/template/src/feedback-cache-invalidation.ts +3 -0
  56. package/template/src/feedback-runtime.ts +123 -13
  57. package/template/src/feedbax/changelog/index.test.ts +35 -0
  58. package/template/src/feedbax/changelog/index.ts +5 -1
  59. package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
  60. package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
  61. package/template/src/feedbax/config/index.test.ts +73 -1
  62. package/template/src/feedbax/config/index.ts +205 -9
  63. package/template/src/feedbax/feedback/comment-storage.ts +38 -0
  64. package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
  65. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  66. package/template/src/feedbax/feedback/connected.ts +225 -0
  67. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  68. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  69. package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
  70. package/template/src/feedbax/feedback/github-comments.ts +390 -0
  71. package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
  72. package/template/src/feedbax/feedback/github-feedback.ts +581 -0
  73. package/template/src/feedbax/feedback/github-request.ts +120 -0
  74. package/template/src/feedbax/feedback/github-service-client.ts +56 -0
  75. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  76. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  77. package/template/src/feedbax/feedback/image-feedback.ts +114 -0
  78. package/template/src/feedbax/feedback/images.test.ts +223 -0
  79. package/template/src/feedbax/feedback/images.ts +350 -0
  80. package/template/src/feedbax/feedback/index.ts +226 -16
  81. package/template/src/feedbax/feedback/integration-auth.ts +138 -0
  82. package/template/src/feedbax/feedback/notion-comments.ts +2 -35
  83. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  84. package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
  85. package/template/src/feedbax/feedback/observed-history.ts +219 -0
  86. package/template/src/feedbax/feedback/ownership.ts +170 -0
  87. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  88. package/template/src/feedbax/feedback/participation.ts +502 -0
  89. package/template/src/history-runtime.test.ts +80 -0
  90. package/template/src/history-runtime.ts +37 -0
  91. package/template/src/images-runtime.test.ts +133 -0
  92. package/template/src/images-runtime.ts +125 -0
  93. package/template/src/index.css +103 -440
  94. package/template/src/integration-runtime.test.ts +365 -0
  95. package/template/src/integration-runtime.ts +289 -0
  96. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  97. package/template/src/my-feedback-page.test.tsx +152 -0
  98. package/template/src/my-feedback-page.tsx +398 -0
  99. package/template/src/my-feedback-runtime.test.ts +93 -0
  100. package/template/src/my-feedback-runtime.ts +154 -0
  101. package/template/src/overlay-post-detail.test.tsx +59 -8
  102. package/template/src/overlay-post-detail.tsx +20 -69
  103. package/template/src/participation-client.ts +63 -0
  104. package/template/src/participation-runtime.test.ts +35 -0
  105. package/template/src/participation-runtime.ts +255 -0
  106. package/template/src/portal-comments.ts +35 -5
  107. package/template/src/portal-feedback-form.tsx +29 -6
  108. package/template/src/portal-feedback-runtime.ts +3 -0
  109. package/template/src/portal-feedback-server-function.ts +6 -3
  110. package/template/src/portal-feedback-submission.ts +19 -0
  111. package/template/src/portal-server-entry.ts +13 -0
  112. package/template/src/post-creation-flow.tsx +2 -2
  113. package/template/src/post-detail-content.test.tsx +233 -0
  114. package/template/src/post-detail-content.tsx +162 -0
  115. package/template/src/post-detail-query.ts +34 -0
  116. package/template/src/post-results.tsx +1 -1
  117. package/template/src/public-cache-policy.test.ts +11 -4
  118. package/template/src/public-cache-policy.ts +1 -1
  119. package/template/src/public-feedback-index.tsx +9 -39
  120. package/template/src/public-feedback-page.test.tsx +65 -0
  121. package/template/src/public-feedback-page.ts +12 -1
  122. package/template/src/public-portal-error.tsx +1 -1
  123. package/template/src/public-post-detail-skeleton.tsx +54 -0
  124. package/template/src/public-post-detail.tsx +437 -271
  125. package/template/src/public-post-route.test.tsx +39 -3
  126. package/template/src/public-post-route.tsx +15 -28
  127. package/template/src/public-release-links.test.ts +120 -0
  128. package/template/src/public-release-links.ts +130 -0
  129. package/template/src/public-roadmap-route.test.tsx +38 -2
  130. package/template/src/public-roadmap-view.tsx +56 -23
  131. package/template/src/public-shell.test.tsx +26 -0
  132. package/template/src/routeTree.gen.ts +70 -3
  133. package/template/src/router.tsx +2 -0
  134. package/template/src/routes/__root.tsx +3 -5
  135. package/template/src/routes/email-preferences.tsx +77 -0
  136. package/template/src/routes/my-feedback.tsx +16 -0
  137. package/template/src/routes/submit.tsx +1 -1
  138. package/template/src/safe-public-failure.ts +3 -1
  139. package/template/src/server.ts +87 -2
  140. package/template/src/submission-intent.ts +26 -0
  141. package/template/src/trusted-feedback-handler.test.ts +1 -1
  142. package/template/src/vote-toggle.tsx +42 -9
  143. package/template/vite.config.ts +2 -0
@@ -1,16 +1,24 @@
1
+ import { submissionIntent } from "./submission-intent";
2
+ import { PostHistory } from "./components/post-history";
3
+ import { ImageAttachments, ImageGallery } from "./components/image-attachments";
4
+ import { FollowPostButton } from "./components/follow-post-button";
5
+ import {
6
+ useConnectedIdentity,
7
+ ConnectedSignInPrompt,
8
+ } from "./components/connected-identity-provider";
1
9
  import { configuredTurnstileSiteKey } from "./turnstile-configuration";
2
10
  import type { Comment, CommentThreadPage, PublicPost } from "@feedbax/feedback";
3
11
  import type { PortalFeatures } from "@feedbax/config";
4
- import {
5
- Empty,
6
- EmptyHeader,
7
- EmptyMedia,
8
- EmptyTitle,
9
- } from "@feedbax/ui/components/empty";
10
12
  import { Skeleton } from "@feedbax/ui/components/skeleton";
11
13
  import { Button } from "@feedbax/ui/components/button";
12
14
  import { Textarea } from "@feedbax/ui/components/textarea";
13
- import { ArrowLeft, MessageCircle } from "lucide-react";
15
+ import {
16
+ Tabs,
17
+ TabsContent,
18
+ TabsList,
19
+ TabsTrigger,
20
+ } from "@feedbax/ui/components/tabs";
21
+ import { ArrowLeft } from "lucide-react";
14
22
  import {
15
23
  type FormEvent,
16
24
  type ReactNode,
@@ -24,6 +32,7 @@ import { PostStatusBadge, PostTypeBadge } from "./post-badges";
24
32
  import { formatPublicDate } from "./public-date";
25
33
  import feedbax from "./feedbax";
26
34
  import { VoteToggle } from "./vote-toggle";
35
+ import { mergeCommentThreads } from "./comment-thread-page";
27
36
  import type { PortalCommentRequest } from "./portal-comments";
28
37
  import { isValidCommentEmail } from "./comment-profile";
29
38
  import {
@@ -42,10 +51,14 @@ export type ConfirmedPortalComment = CreatedComment & {
42
51
  export function PublicPostDetail({
43
52
  post,
44
53
  display = "page",
54
+ published = true,
45
55
  summaryActions,
46
56
  summaryMarker,
47
57
  features = feedbax.features,
48
58
  comments = emptyCommentPage,
59
+ commentsLoading = false,
60
+ commentsLoadError = false,
61
+ retryComments,
49
62
  loadMore,
50
63
  submitComment,
51
64
  mutateComment,
@@ -53,10 +66,14 @@ export function PublicPostDetail({
53
66
  }: {
54
67
  post: PublicPost;
55
68
  display?: "overlay" | "page";
69
+ published?: boolean;
56
70
  summaryActions?: ReactNode;
57
71
  summaryMarker?: ReactNode;
58
72
  features?: PortalFeatures;
59
73
  comments?: CommentThreadPage;
74
+ commentsLoading?: boolean;
75
+ commentsLoadError?: boolean;
76
+ retryComments?: () => void;
60
77
  loadMore?: () => Promise<void>;
61
78
  submitComment?: (
62
79
  input: PortalCommentRequest,
@@ -75,6 +92,11 @@ export function PublicPostDetail({
75
92
  const detailsHeadingId = `${id}-post-details-heading`;
76
93
  const [localComments, setLocalComments] = useState(comments.items);
77
94
  const [commentError, setCommentError] = useState<string>();
95
+ const [images, setImages] = useState<
96
+ import("@feedbax/feedback").ImageAttachment[]
97
+ >([]);
98
+ const [uploading, setUploading] = useState(false);
99
+ const [submittingComment, setSubmittingComment] = useState(false);
78
100
  const [replyingTo, setReplyingTo] = useState<string>();
79
101
  const [editingComment, setEditingComment] = useState<{
80
102
  id: string;
@@ -84,12 +106,27 @@ export function PublicPostDetail({
84
106
  Record<string, StoredCommentCapability>
85
107
  >({});
86
108
  const turnstileContainer = useRef<HTMLDivElement>(null);
109
+ const composerInput = useRef<HTMLTextAreaElement>(null);
87
110
  const turnstileWidget = useRef<string | undefined>(undefined);
88
111
  const { completeProfile, ready: profileReady } = useDeviceProfile();
112
+ const identity = useConnectedIdentity();
113
+ const canComment = identity.enabled
114
+ ? Boolean(identity.displayName)
115
+ : Boolean(completeProfile);
89
116
 
90
117
  useEffect(() => {
91
118
  setLocalComments((current) =>
92
- mergeConfirmedComments(current, comments.items),
119
+ mergeCommentThreads(
120
+ comments.items,
121
+ current
122
+ .map((thread) => ({
123
+ ...thread,
124
+ comments: thread.comments.filter((comment) =>
125
+ comment.id.startsWith("optimistic-"),
126
+ ),
127
+ }))
128
+ .filter((thread) => thread.comments.length > 0),
129
+ ),
93
130
  );
94
131
  }, [comments.items]);
95
132
 
@@ -151,36 +188,53 @@ export function PublicPostDetail({
151
188
  async function createComment(event: FormEvent<HTMLFormElement>) {
152
189
  event.preventDefault();
153
190
  if (!submitComment) return;
154
- const form = new FormData(event.currentTarget);
191
+ const composer = event.currentTarget;
192
+ const form = new FormData(composer);
155
193
  const body = String(form.get("body") ?? "").trim();
156
194
  const profile = completeProfile;
157
- if (!profile || !isValidCommentEmail(profile.email)) {
195
+ if (
196
+ !canComment ||
197
+ (!identity.enabled && (!profile || !isValidCommentEmail(profile.email)))
198
+ ) {
158
199
  setCommentError(
159
200
  "Add a display name and valid email to your Device Profile before commenting.",
160
201
  );
161
202
  return;
162
203
  }
163
- if (!body) return;
204
+ if (!body || uploading || submittingComment) return;
205
+ setSubmittingComment(true);
164
206
  const optimisticId = `optimistic-${crypto.randomUUID()}`;
165
207
  const discussionId = replyingTo ?? optimisticId;
166
208
  const optimistic: CreatedComment = {
167
209
  id: optimisticId,
168
210
  discussionId,
169
211
  body,
170
- author: { kind: "participant", displayName: profile.name },
212
+ author: {
213
+ kind: "participant",
214
+ displayName: identity.enabled ? identity.displayName! : profile!.name,
215
+ },
171
216
  createdAt: new Date(),
172
217
  };
173
218
  setCommentError(undefined);
174
219
  setLocalComments((current) => addComment(current, optimistic));
175
- event.currentTarget.reset();
176
- setReplyingTo(undefined);
177
220
  try {
178
221
  const turnstileToken = form.get("cf-turnstile-response");
222
+ const intent = await submissionIntent(sessionStorage, {
223
+ kind: "comment",
224
+ slug: post.slug,
225
+ discussionId: replyingTo ?? null,
226
+ body,
227
+ imageIds: images.map((image) => image.id),
228
+ session: identity.sessionKey ?? "browser",
229
+ displayName: identity.enabled ? identity.displayName : profile?.name,
230
+ });
179
231
  const confirmed = await submitComment({
232
+ submissionKey: intent.key,
180
233
  slug: post.slug,
234
+ ...(images.length ? { imageIds: images.map((image) => image.id) } : {}),
181
235
  body,
182
- displayName: profile.name,
183
- email: profile.email,
236
+ displayName: identity.enabled ? identity.displayName! : profile!.name,
237
+ email: identity.enabled ? "" : profile!.email,
184
238
  ...(replyingTo ? { discussionId: replyingTo } : {}),
185
239
  ...(turnstileToken ? { turnstileToken: String(turnstileToken) } : {}),
186
240
  ...(sessionStorage.getItem("feedbax:participation-pass")
@@ -191,9 +245,6 @@ export function PublicPostDetail({
191
245
  }
192
246
  : {}),
193
247
  });
194
- setLocalComments((current) =>
195
- replaceComment(current, optimisticId, confirmed),
196
- );
197
248
  if (confirmed.commentCapability && confirmed.commentCapabilityExpiresAt)
198
249
  setCommentCapabilities(
199
250
  saveCommentCapability(
@@ -203,6 +254,13 @@ export function PublicPostDetail({
203
254
  confirmed.commentCapabilityExpiresAt,
204
255
  ),
205
256
  );
257
+ intent.complete();
258
+ composer.reset();
259
+ setReplyingTo(undefined);
260
+ setImages([]);
261
+ setLocalComments((current) =>
262
+ replaceComment(current, optimisticId, confirmed),
263
+ );
206
264
  } catch (error) {
207
265
  setLocalComments((current) => removeComment(current, optimisticId));
208
266
  setCommentError(
@@ -210,6 +268,8 @@ export function PublicPostDetail({
210
268
  ? error.message
211
269
  : "Comment could not be saved. Try again.",
212
270
  );
271
+ } finally {
272
+ setSubmittingComment(false);
213
273
  }
214
274
  }
215
275
 
@@ -274,13 +334,30 @@ export function PublicPostDetail({
274
334
  </div>
275
335
  <h1 id={headingId}>{post.title}</h1>
276
336
  <p className="feedback-detail-description">{post.description}</p>
277
- {post.teamResponse ? (
337
+ {post.images?.length &&
338
+ !published &&
339
+ identity.enabled &&
340
+ !identity.displayName ? (
341
+ <Button
342
+ nativeButton={false}
343
+ render={<a href="/internal/identity/start" />}
344
+ variant="outline"
345
+ size="sm"
346
+ >
347
+ Sign in to view images
348
+ </Button>
349
+ ) : (
350
+ <ImageGallery images={post.images} />
351
+ )}
352
+ {post.teamResponse || post.releaseNotesUrl ? (
278
353
  <section
279
354
  className="border-l-4 border-primary bg-muted/50 p-4"
280
355
  aria-label="Product Team response"
281
356
  >
282
357
  <h2 className="text-sm font-semibold">Product Team response</h2>
283
- <p className="mt-2 whitespace-pre-wrap">{post.teamResponse}</p>
358
+ {post.teamResponse ? (
359
+ <p className="mt-2 whitespace-pre-wrap">{post.teamResponse}</p>
360
+ ) : null}
284
361
  {post.releaseNotesUrl ? (
285
362
  <a
286
363
  className="mt-3 inline-block text-sm font-medium underline underline-offset-4"
@@ -294,224 +371,335 @@ export function PublicPostDetail({
294
371
  {summaryActions ? (
295
372
  <div className="post-detail-summary-actions">{summaryActions}</div>
296
373
  ) : null}
297
- {features.voting && post.voteCount !== undefined ? (
298
- <div className="post-detail-engagement">
374
+ <div className="post-detail-engagement flex flex-wrap items-center gap-3">
375
+ {features.voting && post.voteCount !== undefined ? (
299
376
  <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}
377
+ ) : null}
378
+ {published ? <FollowPostButton slug={post.slug} /> : null}
379
+ </div>
380
+ <Tabs
381
+ defaultValue={features.comments ? "comments" : "activities"}
382
+ className="gap-5"
383
+ >
384
+ {published && feedbax.history ? (
385
+ <TabsList
386
+ variant="line"
387
+ activateOnFocus
388
+ aria-label="Post discussion"
389
+ className="h-10 gap-4 border-b p-0"
390
+ >
391
+ {features.comments ? (
392
+ <TabsTrigger value="comments" className="px-1 text-sm">
393
+ Comments
394
+ </TabsTrigger>
395
+ ) : null}
396
+ <TabsTrigger value="activities" className="px-1 text-sm">
397
+ Activities
398
+ </TabsTrigger>
399
+ </TabsList>
400
+ ) : null}
401
+ {features.comments ? (
402
+ <TabsContent value="comments" keepMounted>
403
+ <section
404
+ className={
405
+ published && feedbax.history
406
+ ? "post-detail-comments post-detail-comments-tab"
407
+ : "post-detail-comments"
408
+ }
409
+ aria-labelledby={commentsHeadingId}
447
410
  >
448
- <label
449
- className="comment-composer-label"
450
- htmlFor={`${id}-comment-body`}
411
+ <h2
412
+ id={commentsHeadingId}
413
+ className={
414
+ published && feedbax.history ? "sr-only" : undefined
415
+ }
451
416
  >
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
- />
417
+ Comments
418
+ </h2>
419
+ {submitComment &&
420
+ (identity.enabled ? identity.ready : profileReady) &&
421
+ canComment ? (
422
+ <form
423
+ className="comment-composer"
424
+ aria-label="Comment composer"
425
+ onSubmit={createComment}
426
+ >
427
+ <label
428
+ className="comment-composer-label"
429
+ htmlFor={`${id}-comment-body`}
430
+ >
431
+ {replyingTo ? "Write a reply" : "Add a comment"}
432
+ </label>
433
+ <p className="comment-composer-hint">
434
+ {identity.enabled
435
+ ? `Posting as ${identity.displayName}.`
436
+ : "Your name is public. Your email stays private."}
437
+ </p>
438
+ <Textarea
439
+ ref={composerInput}
440
+ className="comment-composer-input"
441
+ id={`${id}-comment-body`}
442
+ name="body"
443
+ rows={3}
444
+ placeholder="Share context, an example, or a question…"
445
+ required
446
+ />
447
+ {turnstileSiteKey &&
448
+ !sessionStorage.getItem("feedbax:participation-pass") ? (
449
+ <div
450
+ ref={(element) => {
451
+ turnstileContainer.current = element;
452
+ if (
453
+ element &&
454
+ window.turnstile &&
455
+ !turnstileWidget.current
456
+ )
457
+ turnstileWidget.current = window.turnstile.render(
458
+ element,
459
+ { sitekey: turnstileSiteKey },
460
+ );
461
+ }}
462
+ className="cf-turnstile"
463
+ data-sitekey={turnstileSiteKey}
464
+ />
465
+ ) : null}
466
+ <ImageAttachments
467
+ value={images}
468
+ onChange={setImages}
469
+ onUploadingChange={setUploading}
470
+ />
471
+ <div className="comment-composer-actions">
472
+ <Button
473
+ type="submit"
474
+ disabled={uploading || submittingComment}
475
+ >
476
+ {submittingComment
477
+ ? "Posting…"
478
+ : replyingTo
479
+ ? "Post reply"
480
+ : "Post comment"}
481
+ </Button>
482
+ {replyingTo ? (
483
+ <Button
484
+ type="button"
485
+ variant="ghost"
486
+ onClick={() => setReplyingTo(undefined)}
487
+ >
488
+ Cancel reply
489
+ </Button>
490
+ ) : null}
491
+ </div>
492
+ </form>
493
+ ) : submitComment && identity.enabled ? (
494
+ <ConnectedSignInPrompt />
495
+ ) : submitComment ? (
496
+ <DeviceProfilePrompt purpose="comment" />
484
497
  ) : null}
485
- <div className="comment-composer-actions">
486
- <Button type="submit">
487
- {replyingTo ? "Post reply" : "Post comment"}
488
- </Button>
489
- {replyingTo ? (
498
+ {commentError ? <p role="alert">{commentError}</p> : null}
499
+ <div
500
+ className="post-detail-thread-list"
501
+ role="region"
502
+ aria-label="Comment threads"
503
+ tabIndex={localComments.length ? 0 : undefined}
504
+ >
505
+ {commentsLoading && localComments.length === 0 ? (
506
+ <PostCommentsSkeleton />
507
+ ) : commentsLoadError &&
508
+ localComments.length ===
509
+ 0 ? null : localComments.length === 0 ? (
510
+ <p className="py-5 text-sm text-muted-foreground">
511
+ No comments yet
512
+ </p>
513
+ ) : (
514
+ <div className="comment-threads">
515
+ {localComments.map((thread) => (
516
+ <article className="comment-thread" key={thread.id}>
517
+ {thread.comments.map((comment, index) => {
518
+ const canChange = Boolean(
519
+ mutateComment &&
520
+ commentCapabilities[comment.id],
521
+ );
522
+ const canReply = Boolean(
523
+ index === 0 && submitComment && canComment,
524
+ );
525
+ return (
526
+ <div
527
+ className="comment"
528
+ data-reply={index > 0 || undefined}
529
+ key={comment.id}
530
+ >
531
+ <header className="comment-header">
532
+ <div className="comment-author">
533
+ <strong>
534
+ {comment.author.displayName}
535
+ </strong>
536
+ {comment.author.kind === "participant" ? (
537
+ <span>
538
+ {comment.author.verified
539
+ ? "Verified"
540
+ : "Unverified"}
541
+ </span>
542
+ ) : null}
543
+ </div>
544
+ <time
545
+ dateTime={comment.createdAt.toISOString()}
546
+ >
547
+ {formatPublicDate(
548
+ comment.createdAt,
549
+ "long",
550
+ )}
551
+ </time>
552
+ </header>
553
+ {editingComment?.id === comment.id ? (
554
+ <form
555
+ className="mt-3 space-y-2"
556
+ aria-label="Edit Comment"
557
+ onSubmit={(event) => {
558
+ event.preventDefault();
559
+ void changeComment(
560
+ "edit",
561
+ {
562
+ ...comment,
563
+ discussionId: thread.id,
564
+ },
565
+ editingComment.body,
566
+ );
567
+ }}
568
+ >
569
+ <Textarea
570
+ aria-label="Comment text"
571
+ value={editingComment.body}
572
+ onChange={(event) =>
573
+ setEditingComment({
574
+ id: comment.id,
575
+ body: event.target.value,
576
+ })
577
+ }
578
+ required
579
+ />
580
+ <div className="flex gap-2">
581
+ <Button size="sm" type="submit">
582
+ Save
583
+ </Button>
584
+ <Button
585
+ size="sm"
586
+ type="button"
587
+ variant="ghost"
588
+ onClick={() =>
589
+ setEditingComment(undefined)
590
+ }
591
+ >
592
+ Cancel
593
+ </Button>
594
+ </div>
595
+ </form>
596
+ ) : (
597
+ <>
598
+ <p className="comment-body">
599
+ {comment.body}
600
+ </p>
601
+ <ImageGallery images={comment.images} />
602
+ </>
603
+ )}
604
+ {canChange || canReply ? (
605
+ <div
606
+ className="comment-actions"
607
+ role="group"
608
+ aria-label={`Comment actions by ${comment.author.displayName}`}
609
+ >
610
+ {canReply ? (
611
+ <Button
612
+ type="button"
613
+ size="sm"
614
+ variant="ghost"
615
+ onClick={() => {
616
+ setReplyingTo(thread.id);
617
+ composerInput.current?.focus();
618
+ }}
619
+ >
620
+ Reply
621
+ </Button>
622
+ ) : null}
623
+ {canChange ? (
624
+ <>
625
+ <Button
626
+ type="button"
627
+ size="sm"
628
+ variant="ghost"
629
+ onClick={() =>
630
+ setEditingComment({
631
+ id: comment.id,
632
+ body: comment.body,
633
+ })
634
+ }
635
+ >
636
+ Edit
637
+ </Button>
638
+ <Button
639
+ type="button"
640
+ size="sm"
641
+ variant="ghost"
642
+ className="text-destructive hover:text-destructive"
643
+ onClick={() =>
644
+ void changeComment("delete", {
645
+ ...comment,
646
+ discussionId: thread.id,
647
+ })
648
+ }
649
+ >
650
+ Delete
651
+ </Button>
652
+ </>
653
+ ) : null}
654
+ </div>
655
+ ) : null}
656
+ </div>
657
+ );
658
+ })}
659
+ </article>
660
+ ))}
661
+ </div>
662
+ )}
663
+ {commentsLoadError ? (
664
+ <div className="space-y-3 py-4">
665
+ <p
666
+ role="alert"
667
+ className="text-sm text-muted-foreground"
668
+ >
669
+ Comments could not be loaded.
670
+ </p>
671
+ {retryComments ? (
672
+ <Button
673
+ type="button"
674
+ variant="outline"
675
+ size="sm"
676
+ onClick={retryComments}
677
+ >
678
+ Retry Comments
679
+ </Button>
680
+ ) : null}
681
+ </div>
682
+ ) : null}
683
+ {loadMore ? (
490
684
  <Button
491
685
  type="button"
492
- variant="ghost"
493
- onClick={() => setReplyingTo(undefined)}
686
+ variant="outline"
687
+ disabled={commentsLoading}
688
+ onClick={() => void loadMore()}
494
689
  >
495
- Cancel reply
690
+ {commentsLoading ? "Loading Comments…" : "Load more"}
496
691
  </Button>
497
692
  ) : null}
498
693
  </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}
694
+ </section>
695
+ </TabsContent>
696
+ ) : null}
697
+ {published && feedbax.history ? (
698
+ <TabsContent value="activities" keepMounted>
699
+ <PostHistory slug={post.slug} />
700
+ </TabsContent>
701
+ ) : null}
702
+ </Tabs>
515
703
  </div>
516
704
  <aside
517
705
  className="post-detail-sidebar"
@@ -552,39 +740,37 @@ export function PublicPostDetail({
552
740
  );
553
741
  }
554
742
 
555
- export function PublicPostDetailSkeleton() {
743
+ function PostCommentsSkeleton() {
556
744
  return (
557
- <main
558
- className="feedback-detail post-detail-skeleton"
559
- aria-label="Loading Post details"
745
+ <div
746
+ role="status"
747
+ aria-label="Loading Comments"
560
748
  aria-busy="true"
749
+ className="space-y-5 py-6"
561
750
  >
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>
751
+ <span className="sr-only">Loading Comments…</span>
752
+ <div aria-hidden="true" className="space-y-4">
753
+ {[0, 1].map((row) => (
754
+ <div key={row} className="space-y-3">
755
+ <Skeleton className="h-3 w-24" />
756
+ <Skeleton className="h-3 w-full" />
757
+ <Skeleton className="h-3 w-2/3" />
758
+ </div>
759
+ ))}
570
760
  </div>
571
- <span className="sr-only">Loading Post details…</span>
572
- </main>
761
+ </div>
573
762
  );
574
763
  }
575
764
 
765
+ export { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
766
+
576
767
  function addComment(
577
768
  threads: CommentThreadPage["items"],
578
769
  comment: CreatedComment,
579
770
  ) {
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] }];
771
+ return mergeCommentThreads(threads, [
772
+ { id: comment.discussionId, comments: [comment] },
773
+ ]);
588
774
  }
589
775
 
590
776
  function removeComment(threads: CommentThreadPage["items"], commentId: string) {
@@ -604,26 +790,6 @@ function replaceComment(
604
790
  return addComment(removeComment(threads, optimisticId), confirmed);
605
791
  }
606
792
 
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
793
  interface StoredCommentCapability {
628
794
  capability: string;
629
795
  expiresAt: number;