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.
- package/dist/cli.js +2042 -16
- package/dist/index.js +1997 -5
- package/package.json +1 -1
- package/template/.feedbax-template.sha256 +1 -1
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/README.md +58 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/package.json +9 -5
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/cloudflare-workers.d.ts +6 -0
- package/template/src/comment-creation.test.tsx +42 -1
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +108 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +89 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/profile-menu.tsx +30 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/connected-identity.test.ts +144 -0
- package/template/src/connected-identity.ts +122 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +241 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +1 -0
- package/template/src/feedback-runtime.ts +79 -12
- package/template/src/feedbax/config/index.test.ts +28 -0
- package/template/src/feedbax/config/index.ts +93 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +206 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +215 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +101 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +324 -0
- package/template/src/feedbax/feedback/index.ts +55 -1
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +97 -440
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +24 -5
- package/template/src/portal-feedback-form.tsx +18 -3
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +10 -0
- package/template/src/portal-server-entry.ts +4 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +298 -208
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +49 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +41 -1
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- 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
|
|
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
|
-
|
|
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 (
|
|
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: {
|
|
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
|
|
183
|
-
email: profile
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
{
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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={
|
|
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
|
|
487
|
-
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
667
|
+
function PostCommentsSkeleton() {
|
|
556
668
|
return (
|
|
557
|
-
<
|
|
558
|
-
|
|
559
|
-
aria-label="Loading
|
|
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
|
-
<
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
<
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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;
|