create-feedbax 0.2.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 (99) hide show
  1. package/dist/cli.js +2042 -16
  2. package/dist/index.js +1997 -5
  3. package/package.json +1 -1
  4. package/template/.feedbax-template.sha256 +1 -1
  5. package/template/AGENTS.md +13 -0
  6. package/template/CONNECTED.md +404 -0
  7. package/template/README.md +58 -0
  8. package/template/components.json +1 -1
  9. package/template/eslint.config.mjs +23 -0
  10. package/template/migrations/0001_connected.sql +6 -0
  11. package/template/migrations/0002_participation.sql +8 -0
  12. package/template/migrations/0003_images.sql +3 -0
  13. package/template/migrations/0004_followed_status.sql +9 -0
  14. package/template/package.json +9 -5
  15. package/template/src/authorized-draft-post.tsx +1 -0
  16. package/template/src/changelog-page.tsx +63 -54
  17. package/template/src/cloudflare-workers.d.ts +6 -0
  18. package/template/src/comment-creation.test.tsx +42 -1
  19. package/template/src/comment-thread-page.ts +11 -1
  20. package/template/src/components/command-palette.tsx +59 -30
  21. package/template/src/components/connected-identity-provider.tsx +108 -0
  22. package/template/src/components/device-profile-provider.tsx +41 -26
  23. package/template/src/components/follow-post-button.tsx +89 -0
  24. package/template/src/components/header.tsx +2 -0
  25. package/template/src/components/image-attachments.tsx +187 -0
  26. package/template/src/components/loader.tsx +48 -5
  27. package/template/src/components/notification-inbox.tsx +232 -0
  28. package/template/src/components/portal-footer.tsx +14 -0
  29. package/template/src/components/profile-menu.tsx +30 -0
  30. package/template/src/components/ui/components/command.tsx +30 -29
  31. package/template/src/components/ui/components/message-scroller.tsx +2 -2
  32. package/template/src/components/ui/components/scroll-area.tsx +0 -1
  33. package/template/src/components/ui/components/switch.tsx +29 -0
  34. package/template/src/components/ui/styles/globals.css +1 -1
  35. package/template/src/connected-identity.test.ts +144 -0
  36. package/template/src/connected-identity.ts +122 -0
  37. package/template/src/connected-participation.test.tsx +67 -0
  38. package/template/src/connected-runtime.test.ts +241 -0
  39. package/template/src/connected-runtime.ts +176 -0
  40. package/template/src/connected-social.test.tsx +158 -0
  41. package/template/src/feedback-cache-invalidation.ts +1 -0
  42. package/template/src/feedback-runtime.ts +79 -12
  43. package/template/src/feedbax/config/index.test.ts +28 -0
  44. package/template/src/feedbax/config/index.ts +93 -0
  45. package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
  46. package/template/src/feedbax/feedback/connected.test.ts +272 -0
  47. package/template/src/feedbax/feedback/connected.ts +215 -0
  48. package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
  49. package/template/src/feedbax/feedback/email-delivery.ts +66 -0
  50. package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
  51. package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
  52. package/template/src/feedbax/feedback/image-feedback.ts +101 -0
  53. package/template/src/feedbax/feedback/images.test.ts +223 -0
  54. package/template/src/feedbax/feedback/images.ts +324 -0
  55. package/template/src/feedbax/feedback/index.ts +55 -1
  56. package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
  57. package/template/src/feedbax/feedback/participation.test.ts +653 -0
  58. package/template/src/feedbax/feedback/participation.ts +502 -0
  59. package/template/src/images-runtime.test.ts +133 -0
  60. package/template/src/images-runtime.ts +125 -0
  61. package/template/src/index.css +97 -440
  62. package/template/src/lazy-post-detail-overlay.tsx +13 -2
  63. package/template/src/overlay-post-detail.test.tsx +59 -8
  64. package/template/src/overlay-post-detail.tsx +20 -69
  65. package/template/src/participation-client.ts +63 -0
  66. package/template/src/participation-runtime.test.ts +35 -0
  67. package/template/src/participation-runtime.ts +255 -0
  68. package/template/src/portal-comments.ts +24 -5
  69. package/template/src/portal-feedback-form.tsx +18 -3
  70. package/template/src/portal-feedback-runtime.ts +3 -0
  71. package/template/src/portal-feedback-server-function.ts +6 -3
  72. package/template/src/portal-feedback-submission.ts +10 -0
  73. package/template/src/portal-server-entry.ts +4 -0
  74. package/template/src/post-creation-flow.tsx +2 -2
  75. package/template/src/post-detail-content.test.tsx +233 -0
  76. package/template/src/post-detail-content.tsx +162 -0
  77. package/template/src/post-detail-query.ts +34 -0
  78. package/template/src/post-results.tsx +1 -1
  79. package/template/src/public-cache-policy.test.ts +11 -4
  80. package/template/src/public-cache-policy.ts +1 -1
  81. package/template/src/public-feedback-index.tsx +9 -39
  82. package/template/src/public-feedback-page.test.tsx +65 -0
  83. package/template/src/public-feedback-page.ts +12 -1
  84. package/template/src/public-post-detail-skeleton.tsx +54 -0
  85. package/template/src/public-post-detail.tsx +298 -208
  86. package/template/src/public-post-route.test.tsx +39 -3
  87. package/template/src/public-post-route.tsx +15 -28
  88. package/template/src/public-roadmap-route.test.tsx +38 -2
  89. package/template/src/public-roadmap-view.tsx +56 -23
  90. package/template/src/public-shell.test.tsx +26 -0
  91. package/template/src/routeTree.gen.ts +49 -3
  92. package/template/src/router.tsx +2 -0
  93. package/template/src/routes/__root.tsx +3 -5
  94. package/template/src/routes/email-preferences.tsx +77 -0
  95. package/template/src/routes/submit.tsx +1 -1
  96. package/template/src/safe-public-failure.ts +3 -1
  97. package/template/src/server.ts +41 -1
  98. package/template/src/trusted-feedback-handler.test.ts +1 -1
  99. package/template/src/vote-toggle.tsx +42 -9
@@ -1,16 +1,16 @@
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";
1
7
  import { configuredTurnstileSiteKey } from "./turnstile-configuration";
2
8
  import type { Comment, CommentThreadPage, PublicPost } from "@feedbax/feedback";
3
9
  import type { PortalFeatures } from "@feedbax/config";
4
- import {
5
- Empty,
6
- EmptyHeader,
7
- EmptyMedia,
8
- EmptyTitle,
9
- } from "@feedbax/ui/components/empty";
10
10
  import { Skeleton } from "@feedbax/ui/components/skeleton";
11
11
  import { Button } from "@feedbax/ui/components/button";
12
12
  import { Textarea } from "@feedbax/ui/components/textarea";
13
- import { ArrowLeft, MessageCircle } from "lucide-react";
13
+ import { ArrowLeft } from "lucide-react";
14
14
  import {
15
15
  type FormEvent,
16
16
  type ReactNode,
@@ -24,6 +24,7 @@ import { PostStatusBadge, PostTypeBadge } from "./post-badges";
24
24
  import { formatPublicDate } from "./public-date";
25
25
  import feedbax from "./feedbax";
26
26
  import { VoteToggle } from "./vote-toggle";
27
+ import { mergeCommentThreads } from "./comment-thread-page";
27
28
  import type { PortalCommentRequest } from "./portal-comments";
28
29
  import { isValidCommentEmail } from "./comment-profile";
29
30
  import {
@@ -42,10 +43,14 @@ export type ConfirmedPortalComment = CreatedComment & {
42
43
  export function PublicPostDetail({
43
44
  post,
44
45
  display = "page",
46
+ published = true,
45
47
  summaryActions,
46
48
  summaryMarker,
47
49
  features = feedbax.features,
48
50
  comments = emptyCommentPage,
51
+ commentsLoading = false,
52
+ commentsLoadError = false,
53
+ retryComments,
49
54
  loadMore,
50
55
  submitComment,
51
56
  mutateComment,
@@ -53,10 +58,14 @@ export function PublicPostDetail({
53
58
  }: {
54
59
  post: PublicPost;
55
60
  display?: "overlay" | "page";
61
+ published?: boolean;
56
62
  summaryActions?: ReactNode;
57
63
  summaryMarker?: ReactNode;
58
64
  features?: PortalFeatures;
59
65
  comments?: CommentThreadPage;
66
+ commentsLoading?: boolean;
67
+ commentsLoadError?: boolean;
68
+ retryComments?: () => void;
60
69
  loadMore?: () => Promise<void>;
61
70
  submitComment?: (
62
71
  input: PortalCommentRequest,
@@ -75,6 +84,11 @@ export function PublicPostDetail({
75
84
  const detailsHeadingId = `${id}-post-details-heading`;
76
85
  const [localComments, setLocalComments] = useState(comments.items);
77
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);
78
92
  const [replyingTo, setReplyingTo] = useState<string>();
79
93
  const [editingComment, setEditingComment] = useState<{
80
94
  id: string;
@@ -84,12 +98,27 @@ export function PublicPostDetail({
84
98
  Record<string, StoredCommentCapability>
85
99
  >({});
86
100
  const turnstileContainer = useRef<HTMLDivElement>(null);
101
+ const composerInput = useRef<HTMLTextAreaElement>(null);
87
102
  const turnstileWidget = useRef<string | undefined>(undefined);
88
103
  const { completeProfile, ready: profileReady } = useDeviceProfile();
104
+ const identity = useConnectedIdentity();
105
+ const canComment = identity.enabled
106
+ ? Boolean(identity.displayName)
107
+ : Boolean(completeProfile);
89
108
 
90
109
  useEffect(() => {
91
110
  setLocalComments((current) =>
92
- mergeConfirmedComments(current, comments.items),
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
+ ),
93
122
  );
94
123
  }, [comments.items]);
95
124
 
@@ -154,20 +183,27 @@ export function PublicPostDetail({
154
183
  const form = new FormData(event.currentTarget);
155
184
  const body = String(form.get("body") ?? "").trim();
156
185
  const profile = completeProfile;
157
- if (!profile || !isValidCommentEmail(profile.email)) {
186
+ if (
187
+ !canComment ||
188
+ (!identity.enabled && (!profile || !isValidCommentEmail(profile.email)))
189
+ ) {
158
190
  setCommentError(
159
191
  "Add a display name and valid email to your Device Profile before commenting.",
160
192
  );
161
193
  return;
162
194
  }
163
- if (!body) return;
195
+ if (!body || uploading || submittingComment) return;
196
+ setSubmittingComment(true);
164
197
  const optimisticId = `optimistic-${crypto.randomUUID()}`;
165
198
  const discussionId = replyingTo ?? optimisticId;
166
199
  const optimistic: CreatedComment = {
167
200
  id: optimisticId,
168
201
  discussionId,
169
202
  body,
170
- author: { kind: "participant", displayName: profile.name },
203
+ author: {
204
+ kind: "participant",
205
+ displayName: identity.enabled ? identity.displayName! : profile!.name,
206
+ },
171
207
  createdAt: new Date(),
172
208
  };
173
209
  setCommentError(undefined);
@@ -178,9 +214,10 @@ export function PublicPostDetail({
178
214
  const turnstileToken = form.get("cf-turnstile-response");
179
215
  const confirmed = await submitComment({
180
216
  slug: post.slug,
217
+ ...(images.length ? { imageIds: images.map((image) => image.id) } : {}),
181
218
  body,
182
- displayName: profile.name,
183
- email: profile.email,
219
+ displayName: identity.enabled ? identity.displayName! : profile!.name,
220
+ email: identity.enabled ? "" : profile!.email,
184
221
  ...(replyingTo ? { discussionId: replyingTo } : {}),
185
222
  ...(turnstileToken ? { turnstileToken: String(turnstileToken) } : {}),
186
223
  ...(sessionStorage.getItem("feedbax:participation-pass")
@@ -191,6 +228,7 @@ export function PublicPostDetail({
191
228
  }
192
229
  : {}),
193
230
  });
231
+ setImages([]);
194
232
  setLocalComments((current) =>
195
233
  replaceComment(current, optimisticId, confirmed),
196
234
  );
@@ -210,6 +248,8 @@ export function PublicPostDetail({
210
248
  ? error.message
211
249
  : "Comment could not be saved. Try again.",
212
250
  );
251
+ } finally {
252
+ setSubmittingComment(false);
213
253
  }
214
254
  }
215
255
 
@@ -274,6 +314,21 @@ export function PublicPostDetail({
274
314
  </div>
275
315
  <h1 id={headingId}>{post.title}</h1>
276
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
+ )}
277
332
  {post.teamResponse ? (
278
333
  <section
279
334
  className="border-l-4 border-primary bg-muted/50 p-4"
@@ -294,152 +349,21 @@ export function PublicPostDetail({
294
349
  {summaryActions ? (
295
350
  <div className="post-detail-summary-actions">{summaryActions}</div>
296
351
  ) : null}
297
- {features.voting && post.voteCount !== undefined ? (
298
- <div className="post-detail-engagement">
352
+ <div className="post-detail-engagement flex flex-wrap items-center gap-3">
353
+ {features.voting && post.voteCount !== undefined ? (
299
354
  <VoteToggle post={post} />
300
- </div>
301
- ) : null}
355
+ ) : null}
356
+ {published ? <FollowPostButton slug={post.slug} /> : null}
357
+ </div>
302
358
  {features.comments ? (
303
359
  <section
304
360
  className="post-detail-comments"
305
361
  aria-labelledby={commentsHeadingId}
306
362
  >
307
363
  <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 ? (
364
+ {submitComment &&
365
+ (identity.enabled ? identity.ready : profileReady) &&
366
+ canComment ? (
443
367
  <form
444
368
  className="comment-composer"
445
369
  aria-label="Comment composer"
@@ -452,14 +376,16 @@ export function PublicPostDetail({
452
376
  {replyingTo ? "Write a reply" : "Add a comment"}
453
377
  </label>
454
378
  <p className="comment-composer-hint">
455
- Your display name is public. Your email is used only to
456
- unlock commenting and is never shown.
379
+ {identity.enabled
380
+ ? `Posting as ${identity.displayName}.`
381
+ : "Your name is public. Your email stays private."}
457
382
  </p>
458
383
  <Textarea
384
+ ref={composerInput}
459
385
  className="comment-composer-input"
460
386
  id={`${id}-comment-body`}
461
387
  name="body"
462
- rows={4}
388
+ rows={3}
463
389
  placeholder="Share context, an example, or a question…"
464
390
  required
465
391
  />
@@ -482,9 +408,21 @@ export function PublicPostDetail({
482
408
  data-sitekey={turnstileSiteKey}
483
409
  />
484
410
  ) : null}
411
+ <ImageAttachments
412
+ value={images}
413
+ onChange={setImages}
414
+ onUploadingChange={setUploading}
415
+ />
485
416
  <div className="comment-composer-actions">
486
- <Button type="submit">
487
- {replyingTo ? "Post reply" : "Post comment"}
417
+ <Button
418
+ type="submit"
419
+ disabled={uploading || submittingComment}
420
+ >
421
+ {submittingComment
422
+ ? "Posting…"
423
+ : replyingTo
424
+ ? "Post reply"
425
+ : "Post comment"}
488
426
  </Button>
489
427
  {replyingTo ? (
490
428
  <Button
@@ -497,19 +435,193 @@ export function PublicPostDetail({
497
435
  ) : null}
498
436
  </div>
499
437
  </form>
438
+ ) : submitComment && identity.enabled ? (
439
+ <ConnectedSignInPrompt />
500
440
  ) : submitComment ? (
501
441
  <DeviceProfilePrompt purpose="comment" />
502
442
  ) : null}
503
443
  {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}
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>
513
625
  </section>
514
626
  ) : null}
515
627
  </div>
@@ -552,39 +664,37 @@ export function PublicPostDetail({
552
664
  );
553
665
  }
554
666
 
555
- export function PublicPostDetailSkeleton() {
667
+ function PostCommentsSkeleton() {
556
668
  return (
557
- <main
558
- className="feedback-detail post-detail-skeleton"
559
- aria-label="Loading Post details"
669
+ <div
670
+ role="status"
671
+ aria-label="Loading Comments"
560
672
  aria-busy="true"
673
+ className="space-y-5 py-6"
561
674
  >
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>
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
+ ))}
570
684
  </div>
571
- <span className="sr-only">Loading Post details…</span>
572
- </main>
685
+ </div>
573
686
  );
574
687
  }
575
688
 
689
+ export { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
690
+
576
691
  function addComment(
577
692
  threads: CommentThreadPage["items"],
578
693
  comment: CreatedComment,
579
694
  ) {
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] }];
695
+ return mergeCommentThreads(threads, [
696
+ { id: comment.discussionId, comments: [comment] },
697
+ ]);
588
698
  }
589
699
 
590
700
  function removeComment(threads: CommentThreadPage["items"], commentId: string) {
@@ -604,26 +714,6 @@ function replaceComment(
604
714
  return addComment(removeComment(threads, optimisticId), confirmed);
605
715
  }
606
716
 
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
717
  interface StoredCommentCapability {
628
718
  capability: string;
629
719
  expiresAt: number;