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.
- package/README.md +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -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/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- 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 +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -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/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -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/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -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 +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -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/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -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 +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- 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 +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- 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 +19 -0
- package/template/src/portal-server-entry.ts +13 -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-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- 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 +70 -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/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- 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 {
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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: {
|
|
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
|
|
183
|
-
email: profile
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
<
|
|
449
|
-
|
|
450
|
-
|
|
411
|
+
<h2
|
|
412
|
+
id={commentsHeadingId}
|
|
413
|
+
className={
|
|
414
|
+
published && feedbax.history ? "sr-only" : undefined
|
|
415
|
+
}
|
|
451
416
|
>
|
|
452
|
-
|
|
453
|
-
</
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
<
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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="
|
|
493
|
-
|
|
686
|
+
variant="outline"
|
|
687
|
+
disabled={commentsLoading}
|
|
688
|
+
onClick={() => void loadMore()}
|
|
494
689
|
>
|
|
495
|
-
|
|
690
|
+
{commentsLoading ? "Loading Comments…" : "Load more"}
|
|
496
691
|
</Button>
|
|
497
692
|
) : null}
|
|
498
693
|
</div>
|
|
499
|
-
</
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
743
|
+
function PostCommentsSkeleton() {
|
|
556
744
|
return (
|
|
557
|
-
<
|
|
558
|
-
|
|
559
|
-
aria-label="Loading
|
|
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
|
-
<
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
<
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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;
|