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
package/template/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feedbax-installation",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": true,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -27,18 +27,20 @@
|
|
|
27
27
|
"sonner": "2.0.8",
|
|
28
28
|
"tailwindcss": "4.3.3",
|
|
29
29
|
"zod": "4.4.3",
|
|
30
|
-
"@base-ui/react": "1.
|
|
30
|
+
"@base-ui/react": "1.9.0",
|
|
31
31
|
"@fontsource-variable/geist": "5.3.0",
|
|
32
|
-
"@shadcn/react": "0.3.
|
|
32
|
+
"@shadcn/react": "0.3.1",
|
|
33
33
|
"class-variance-authority": "0.7.1",
|
|
34
34
|
"clsx": "2.1.1",
|
|
35
35
|
"cmdk": "^1.1.1",
|
|
36
36
|
"shadcn": "latest",
|
|
37
37
|
"tailwind-merge": "3.6.0",
|
|
38
|
-
"tw-animate-css": "1.4.0"
|
|
38
|
+
"tw-animate-css": "1.4.0",
|
|
39
|
+
"drizzle-orm": "0.45.2"
|
|
39
40
|
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@cloudflare/vite-plugin": "1.53.0",
|
|
43
|
+
"@cloudflare/workers-types": "^5.20261014.1",
|
|
42
44
|
"@tanstack/react-router-devtools": "1.167.1",
|
|
43
45
|
"@tanstack/router-cli": "1.167.32",
|
|
44
46
|
"@testing-library/dom": "10.4.1",
|
|
@@ -48,6 +50,7 @@
|
|
|
48
50
|
"@types/react-dom": "19.2.4",
|
|
49
51
|
"@vitejs/plugin-react": "6.1.0",
|
|
50
52
|
"jsdom": "29.1.1",
|
|
53
|
+
"miniflare": "4.20260730.0",
|
|
51
54
|
"typescript": "6.0.3",
|
|
52
55
|
"vite": "8.2.2",
|
|
53
56
|
"web-vitals": "5.3.0",
|
|
@@ -56,8 +59,9 @@
|
|
|
56
59
|
"tailwindcss": "4.3.3",
|
|
57
60
|
"vitest": "4.1.11",
|
|
58
61
|
"@eslint/js": "10.0.1",
|
|
62
|
+
"@shadcn/lint": "0.2.1",
|
|
59
63
|
"@types/node": "22.20.1",
|
|
60
|
-
"feedbax": "0.2.
|
|
64
|
+
"feedbax": "0.2.1",
|
|
61
65
|
"eslint": "10.8.1",
|
|
62
66
|
"globals": "17.11.0",
|
|
63
67
|
"prettier": "3.9.6",
|
|
@@ -146,6 +146,7 @@ export function AuthorizedDraftPost({ slug }: { slug: string }) {
|
|
|
146
146
|
) : (
|
|
147
147
|
<PublicPostDetail
|
|
148
148
|
post={post}
|
|
149
|
+
published={false}
|
|
149
150
|
display={overlaid ? "overlay" : "page"}
|
|
150
151
|
features={{ voting: false, comments: false, changelog: true }}
|
|
151
152
|
summaryMarker={<Badge variant="outline">Draft</Badge>}
|
|
@@ -3,9 +3,8 @@ import type {
|
|
|
3
3
|
PublicChangelogEntry,
|
|
4
4
|
} from "@feedbax/changelog";
|
|
5
5
|
import { Badge } from "@feedbax/ui/components/badge";
|
|
6
|
-
import { Button } from "@feedbax/ui/components/button";
|
|
6
|
+
import { Button, buttonVariants } from "@feedbax/ui/components/button";
|
|
7
7
|
import { ButtonGroup } from "@feedbax/ui/components/button-group";
|
|
8
|
-
import { cn } from "@feedbax/ui/lib/utils";
|
|
9
8
|
import { useRouterState } from "@tanstack/react-router";
|
|
10
9
|
import { ChevronDown, Link2 } from "lucide-react";
|
|
11
10
|
import { useEffect, useMemo, useState } from "react";
|
|
@@ -80,21 +79,24 @@ export function ChangelogPage({
|
|
|
80
79
|
]);
|
|
81
80
|
|
|
82
81
|
return (
|
|
83
|
-
<main className="
|
|
84
|
-
<header className="
|
|
82
|
+
<main className="mx-auto w-full max-w-[96rem] px-6 py-10 lg:px-10">
|
|
83
|
+
<header className="flex flex-wrap items-start justify-between gap-5">
|
|
85
84
|
<div>
|
|
86
|
-
<h1>Changelog</h1>
|
|
87
|
-
<p
|
|
85
|
+
<h1 className="text-2xl font-semibold tracking-tight">Changelog</h1>
|
|
86
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
87
|
+
Product updates, fixes, and improvements.
|
|
88
|
+
</p>
|
|
88
89
|
</div>
|
|
90
|
+
<CommandPaletteTrigger className="h-10" />
|
|
89
91
|
</header>
|
|
90
|
-
|
|
91
|
-
<ButtonGroup className="
|
|
92
|
+
{labels.length ? (
|
|
93
|
+
<ButtonGroup className="mt-6 flex-wrap" aria-label="Filter updates">
|
|
92
94
|
{[undefined, ...labels].map((label) => (
|
|
93
95
|
<Button
|
|
94
|
-
className="h-11 md:h-8"
|
|
95
96
|
key={label ?? "all"}
|
|
96
97
|
type="button"
|
|
97
|
-
|
|
98
|
+
size="sm"
|
|
99
|
+
variant={selectedLabel === label ? "secondary" : "outline"}
|
|
98
100
|
aria-pressed={selectedLabel === label}
|
|
99
101
|
disabled={loading}
|
|
100
102
|
onClick={() => void replaceForLabel(label)}
|
|
@@ -103,23 +105,30 @@ export function ChangelogPage({
|
|
|
103
105
|
</Button>
|
|
104
106
|
))}
|
|
105
107
|
</ButtonGroup>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
) : null}
|
|
109
|
+
<ol
|
|
110
|
+
className="changelog-timeline"
|
|
111
|
+
aria-label="Product updates"
|
|
112
|
+
aria-busy={loading}
|
|
113
|
+
>
|
|
109
114
|
{page.items.map((entry) => (
|
|
110
115
|
<TimelineEntry entry={entry} key={entry.slug} />
|
|
111
116
|
))}
|
|
112
117
|
</ol>
|
|
118
|
+
{!page.items.length && !loading ? (
|
|
119
|
+
<p className="py-12 text-sm text-muted-foreground">
|
|
120
|
+
No updates published yet.
|
|
121
|
+
</p>
|
|
122
|
+
) : null}
|
|
113
123
|
{page.nextCursor ? (
|
|
114
|
-
<div className="
|
|
124
|
+
<div className="mx-auto mt-8 flex max-w-5xl justify-center">
|
|
115
125
|
<Button
|
|
116
|
-
className="h-11 min-w-64 gap-2 text-sm md:h-10"
|
|
117
126
|
type="button"
|
|
118
127
|
variant="outline"
|
|
119
128
|
disabled={loading}
|
|
120
129
|
onClick={() => void loadMore()}
|
|
121
130
|
>
|
|
122
|
-
Load more updates
|
|
131
|
+
{loading ? "Loading updates…" : "Load more updates"}
|
|
123
132
|
<ChevronDown aria-hidden="true" />
|
|
124
133
|
</Button>
|
|
125
134
|
</div>
|
|
@@ -135,28 +144,46 @@ function TimelineEntry({ entry }: { entry: PublicChangelogEntry }) {
|
|
|
135
144
|
<time dateTime={entry.date}>{formatDate(entry.date)}</time>
|
|
136
145
|
<span className="changelog-timeline-marker" aria-hidden="true" />
|
|
137
146
|
<article id={entry.slug} tabIndex={-1}>
|
|
138
|
-
|
|
139
|
-
<div className="
|
|
140
|
-
|
|
141
|
-
{
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<div className="changelog-entry-title">
|
|
146
|
-
<h2>{entry.title}</h2>
|
|
147
|
-
<a href={`#${entry.slug}`} aria-label={`Link to ${entry.title}`}>
|
|
148
|
-
<Link2 aria-hidden="true" />
|
|
149
|
-
</a>
|
|
150
|
-
</div>
|
|
151
|
-
<p className="changelog-entry-summary">{entry.summary}</p>
|
|
152
|
-
<div className="changelog-entry-body">
|
|
153
|
-
<p>{entry.body}</p>
|
|
154
|
-
</div>
|
|
147
|
+
{entry.labels.length ? (
|
|
148
|
+
<div className="mb-3 flex flex-wrap gap-2" aria-label="Labels">
|
|
149
|
+
{entry.labels.map((label) => (
|
|
150
|
+
<Badge key={label} variant="outline">
|
|
151
|
+
{label}
|
|
152
|
+
</Badge>
|
|
153
|
+
))}
|
|
155
154
|
</div>
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
155
|
+
) : null}
|
|
156
|
+
<div className="flex items-start justify-between gap-3">
|
|
157
|
+
<h2 className="text-lg font-semibold tracking-tight">
|
|
158
|
+
{entry.title}
|
|
159
|
+
</h2>
|
|
160
|
+
<a
|
|
161
|
+
className={buttonVariants({ variant: "ghost", size: "icon-sm" })}
|
|
162
|
+
href={`#${entry.slug}`}
|
|
163
|
+
aria-label={`Link to ${entry.title}`}
|
|
164
|
+
>
|
|
165
|
+
<Link2 aria-hidden="true" />
|
|
166
|
+
</a>
|
|
167
|
+
</div>
|
|
168
|
+
<p className="mt-3 text-sm leading-relaxed">{entry.summary}</p>
|
|
169
|
+
<div className="changelog-entry-body">
|
|
170
|
+
{entry.body
|
|
171
|
+
.trim()
|
|
172
|
+
.split(/\n\s*\n/)
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
.map((paragraph, index) => (
|
|
175
|
+
<p key={index}>{paragraph}</p>
|
|
176
|
+
))}
|
|
159
177
|
</div>
|
|
178
|
+
{image ? (
|
|
179
|
+
<img
|
|
180
|
+
className="mt-6 w-full border"
|
|
181
|
+
src={image.src}
|
|
182
|
+
alt={image.alt}
|
|
183
|
+
width={760}
|
|
184
|
+
height={420}
|
|
185
|
+
/>
|
|
186
|
+
) : null}
|
|
160
187
|
</article>
|
|
161
188
|
</li>
|
|
162
189
|
);
|
|
@@ -190,24 +217,6 @@ function useUnexpiredImage(image: PublicChangelogEntry["image"]) {
|
|
|
190
217
|
: undefined;
|
|
191
218
|
}
|
|
192
219
|
|
|
193
|
-
function Label({ label }: { label: string }) {
|
|
194
|
-
const tone = label.toLowerCase();
|
|
195
|
-
return (
|
|
196
|
-
<Badge
|
|
197
|
-
className={cn(
|
|
198
|
-
"changelog-label",
|
|
199
|
-
tone === "new feature" && "is-new",
|
|
200
|
-
tone === "improved" && "is-improved",
|
|
201
|
-
tone === "fixed" && "is-fixed",
|
|
202
|
-
)}
|
|
203
|
-
variant="outline"
|
|
204
|
-
>
|
|
205
|
-
<span aria-hidden="true" />
|
|
206
|
-
{label}
|
|
207
|
-
</Badge>
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
220
|
function formatDate(date: string) {
|
|
212
221
|
const [year, month, day] = date.split("-").map(Number);
|
|
213
222
|
return new Intl.DateTimeFormat("en-US", {
|
|
@@ -6,6 +6,12 @@ declare module "cloudflare:workers" {
|
|
|
6
6
|
}>;
|
|
7
7
|
};
|
|
8
8
|
export const env: {
|
|
9
|
+
FEEDBAX_UPDATES?: import("@cloudflare/workers-types").Queue<string>;
|
|
10
|
+
FEEDBAX_EMAIL?: import("@feedbax/feedback").CloudflareEmailBinding;
|
|
11
|
+
EMAIL_PROVIDER_SECRET?: string;
|
|
12
|
+
FEEDBAX_IMAGES?: import("@cloudflare/workers-types").R2Bucket;
|
|
13
|
+
FEEDBAX_DB?: import("@feedbax/feedback").ConnectedDatabase;
|
|
14
|
+
IDENTITY_HANDOFF_SECRET?: string;
|
|
9
15
|
TRUSTED_SUBMISSIONS_RATE_LIMITER: {
|
|
10
16
|
limit(options: { key: string }): Promise<{ success: boolean }>;
|
|
11
17
|
};
|
|
@@ -41,7 +41,7 @@ describe("Comment creation", () => {
|
|
|
41
41
|
});
|
|
42
42
|
expect(composer.classList.contains("comment-composer")).toBe(true);
|
|
43
43
|
expect(screen.getByLabelText("Add a comment").getAttribute("rows")).toBe(
|
|
44
|
-
"
|
|
44
|
+
"3",
|
|
45
45
|
);
|
|
46
46
|
expect(
|
|
47
47
|
screen.getByPlaceholderText("Share context, an example, or a question…"),
|
|
@@ -147,6 +147,47 @@ describe("Comment creation", () => {
|
|
|
147
147
|
expect(await screen.findByLabelText("Add a comment")).toBeTruthy();
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
it("does not duplicate a native Comment that arrives before its submission confirmation", async () => {
|
|
151
|
+
saveDeviceProfile(localStorage, {
|
|
152
|
+
name: "Ari",
|
|
153
|
+
email: "private@example.com",
|
|
154
|
+
});
|
|
155
|
+
const confirmed = {
|
|
156
|
+
id: "native-comment",
|
|
157
|
+
discussionId: "native-thread",
|
|
158
|
+
body: "One comment",
|
|
159
|
+
author: { kind: "participant" as const, displayName: "Ari" },
|
|
160
|
+
createdAt: new Date(),
|
|
161
|
+
};
|
|
162
|
+
let finish!: (value: typeof confirmed) => void;
|
|
163
|
+
const submitComment = vi.fn(
|
|
164
|
+
() =>
|
|
165
|
+
new Promise<typeof confirmed>((resolve) => {
|
|
166
|
+
finish = resolve;
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
const view = renderCommentDetail({ submitComment });
|
|
170
|
+
fireEvent.change(await screen.findByLabelText("Add a comment"), {
|
|
171
|
+
target: { value: confirmed.body },
|
|
172
|
+
});
|
|
173
|
+
fireEvent.click(screen.getByRole("button", { name: "Post comment" }));
|
|
174
|
+
view.rerender(
|
|
175
|
+
<DeviceProfileProvider>
|
|
176
|
+
<PublicPostDetail
|
|
177
|
+
post={post}
|
|
178
|
+
submitComment={submitComment}
|
|
179
|
+
comments={{
|
|
180
|
+
items: [{ id: confirmed.discussionId, comments: [confirmed] }],
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
</DeviceProfileProvider>,
|
|
184
|
+
);
|
|
185
|
+
finish(confirmed);
|
|
186
|
+
await waitFor(() =>
|
|
187
|
+
expect(screen.getAllByText("One comment")).toHaveLength(1),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
150
191
|
it("rolls back a rejected Comment and shows an actionable error", async () => {
|
|
151
192
|
saveDeviceProfile(localStorage, {
|
|
152
193
|
name: "Ari",
|
|
@@ -10,7 +10,17 @@ export function mergeCommentThreads(
|
|
|
10
10
|
merged.set(
|
|
11
11
|
thread.id,
|
|
12
12
|
existing
|
|
13
|
-
? {
|
|
13
|
+
? {
|
|
14
|
+
...existing,
|
|
15
|
+
comments: [
|
|
16
|
+
...new Map(
|
|
17
|
+
[...existing.comments, ...thread.comments].map((comment) => [
|
|
18
|
+
comment.id,
|
|
19
|
+
comment,
|
|
20
|
+
]),
|
|
21
|
+
).values(),
|
|
22
|
+
],
|
|
23
|
+
}
|
|
14
24
|
: thread,
|
|
15
25
|
);
|
|
16
26
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
|
-
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useInfiniteQuery, keepPreviousData } from "@tanstack/react-query";
|
|
3
3
|
import { useRouter } from "@tanstack/react-router";
|
|
4
4
|
import type { PublicPostPage } from "@feedbax/feedback";
|
|
5
5
|
import type { PortalFeatures } from "@feedbax/config";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { Button } from "@feedbax/ui/components/button";
|
|
8
|
+
import { Kbd, KbdGroup } from "@feedbax/ui/components/kbd";
|
|
9
|
+
import { Spinner } from "@feedbax/ui/components/spinner";
|
|
8
10
|
import {
|
|
9
11
|
Command,
|
|
10
12
|
CommandDialog,
|
|
@@ -13,16 +15,8 @@ import {
|
|
|
13
15
|
CommandInput,
|
|
14
16
|
CommandItem,
|
|
15
17
|
CommandList,
|
|
16
|
-
CommandSeparator,
|
|
17
18
|
} from "@feedbax/ui/components/command";
|
|
18
|
-
import {
|
|
19
|
-
FileText,
|
|
20
|
-
LayoutList,
|
|
21
|
-
LoaderCircle,
|
|
22
|
-
Map,
|
|
23
|
-
Newspaper,
|
|
24
|
-
Plus,
|
|
25
|
-
} from "lucide-react";
|
|
19
|
+
import { FileText, LayoutList, Map, Newspaper, Plus } from "lucide-react";
|
|
26
20
|
import { publicPostsQuery } from "../post-queries";
|
|
27
21
|
import { useCommandPalette } from "./command-palette-context";
|
|
28
22
|
import feedbax from "../feedbax";
|
|
@@ -64,14 +58,19 @@ export function CommandPalette({
|
|
|
64
58
|
(search) => searchPosts(search.search ?? ""),
|
|
65
59
|
),
|
|
66
60
|
enabled: open && deferredTerm.trim().length > 0,
|
|
61
|
+
placeholderData: keepPreviousData,
|
|
67
62
|
});
|
|
68
63
|
const posts = query.data?.pages.flatMap((page) => page.items) ?? [];
|
|
64
|
+
const hasTerm = term.trim().length > 0;
|
|
65
|
+
const settling = term.trim() !== deferredTerm.trim();
|
|
69
66
|
const showEmpty =
|
|
70
67
|
open &&
|
|
71
68
|
deferredTerm.trim().length > 0 &&
|
|
72
69
|
!query.isPending &&
|
|
70
|
+
!query.isError &&
|
|
71
|
+
!settling &&
|
|
73
72
|
posts.length === 0;
|
|
74
|
-
const searching = open &&
|
|
73
|
+
const searching = open && hasTerm && (settling || query.isFetching);
|
|
75
74
|
const searchStatus = searching
|
|
76
75
|
? "Searching Posts…"
|
|
77
76
|
: showEmpty
|
|
@@ -83,7 +82,7 @@ export function CommandPalette({
|
|
|
83
82
|
useEffect(() => {
|
|
84
83
|
const firstPost = posts[0];
|
|
85
84
|
if (firstPost) setSelection(`post-${firstPost.slug}`);
|
|
86
|
-
}, [posts]);
|
|
85
|
+
}, [posts[0]?.slug]);
|
|
87
86
|
|
|
88
87
|
function getCurrentRouteOptions() {
|
|
89
88
|
const currentRoute = router.state.matches.at(-1);
|
|
@@ -136,24 +135,45 @@ export function CommandPalette({
|
|
|
136
135
|
onValueChange={setTerm}
|
|
137
136
|
placeholder="Search feedback…"
|
|
138
137
|
/>
|
|
139
|
-
<CommandList
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
Searching Posts…
|
|
146
|
-
</CommandItem>
|
|
147
|
-
</CommandGroup>
|
|
138
|
+
<CommandList
|
|
139
|
+
aria-busy={searching || undefined}
|
|
140
|
+
className="min-h-28 max-h-96 p-2"
|
|
141
|
+
>
|
|
142
|
+
{showEmpty ? (
|
|
143
|
+
<CommandEmpty>No Posts match this search.</CommandEmpty>
|
|
148
144
|
) : null}
|
|
149
|
-
{
|
|
145
|
+
{query.isError && hasTerm ? (
|
|
146
|
+
<div
|
|
147
|
+
role="alert"
|
|
148
|
+
className="space-y-3 px-4 py-8 text-center text-sm text-muted-foreground"
|
|
149
|
+
>
|
|
150
|
+
<p>Search is unavailable. Try again.</p>
|
|
151
|
+
<Button variant="outline" onClick={() => void query.refetch()}>
|
|
152
|
+
Retry search
|
|
153
|
+
</Button>
|
|
154
|
+
</div>
|
|
155
|
+
) : null}
|
|
156
|
+
{searching ? (
|
|
157
|
+
<div
|
|
158
|
+
role="status"
|
|
159
|
+
className={
|
|
160
|
+
posts.length
|
|
161
|
+
? "flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground"
|
|
162
|
+
: "flex items-center justify-center gap-2 px-4 py-8 text-sm text-muted-foreground"
|
|
163
|
+
}
|
|
164
|
+
>
|
|
165
|
+
<Spinner aria-hidden="true" />
|
|
166
|
+
Searching Posts…
|
|
167
|
+
</div>
|
|
168
|
+
) : null}
|
|
169
|
+
{hasTerm && !query.isError && posts.length > 0 ? (
|
|
150
170
|
<CommandGroup heading="Posts">
|
|
151
171
|
{posts.map((post) => (
|
|
152
172
|
<CommandItem
|
|
153
173
|
key={post.slug}
|
|
154
174
|
value={`post-${post.slug}`}
|
|
155
175
|
onSelect={() => openPost(post.slug)}
|
|
156
|
-
className="min-h-
|
|
176
|
+
className="min-h-14 gap-3"
|
|
157
177
|
>
|
|
158
178
|
<FileText aria-hidden="true" />
|
|
159
179
|
<div className="min-w-0 flex-1">
|
|
@@ -164,17 +184,14 @@ export function CommandPalette({
|
|
|
164
184
|
{post.description}
|
|
165
185
|
</div>
|
|
166
186
|
</div>
|
|
167
|
-
<div className="
|
|
168
|
-
<
|
|
169
|
-
<
|
|
170
|
-
{post.status}
|
|
171
|
-
</Badge>
|
|
187
|
+
<div className="shrink-0 text-right text-xs text-muted-foreground">
|
|
188
|
+
<span className="block">{post.status}</span>
|
|
189
|
+
<span className="mt-1 hidden sm:block">{post.type}</span>
|
|
172
190
|
</div>
|
|
173
191
|
</CommandItem>
|
|
174
192
|
))}
|
|
175
193
|
</CommandGroup>
|
|
176
194
|
) : null}
|
|
177
|
-
{!term.trim() ? <CommandSeparator /> : null}
|
|
178
195
|
{!term.trim() ? (
|
|
179
196
|
<CommandGroup heading="Navigate">
|
|
180
197
|
<CommandItem
|
|
@@ -228,6 +245,18 @@ export function CommandPalette({
|
|
|
228
245
|
</CommandGroup>
|
|
229
246
|
) : null}
|
|
230
247
|
</CommandList>
|
|
248
|
+
<div className="flex items-center justify-between gap-4 border-t px-4 py-3 text-xs text-muted-foreground">
|
|
249
|
+
<span className="flex items-center gap-1.5">
|
|
250
|
+
<KbdGroup>
|
|
251
|
+
<Kbd>↑</Kbd>
|
|
252
|
+
<Kbd>↓</Kbd>
|
|
253
|
+
</KbdGroup>{" "}
|
|
254
|
+
to navigate <Kbd>↵</Kbd> to open
|
|
255
|
+
</span>
|
|
256
|
+
<span className="flex items-center gap-1.5">
|
|
257
|
+
<Kbd>Esc</Kbd> to close
|
|
258
|
+
</span>
|
|
259
|
+
</div>
|
|
231
260
|
<div aria-label="Search status" className="sr-only" role="status">
|
|
232
261
|
{searchStatus}
|
|
233
262
|
</div>
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useState,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
8
|
+
import feedbax from "../feedbax";
|
|
9
|
+
import { Button } from "@feedbax/ui/components/button";
|
|
10
|
+
import {
|
|
11
|
+
Item,
|
|
12
|
+
ItemActions,
|
|
13
|
+
ItemContent,
|
|
14
|
+
ItemDescription,
|
|
15
|
+
ItemTitle,
|
|
16
|
+
} from "@feedbax/ui/components/item";
|
|
17
|
+
|
|
18
|
+
type IdentityState = {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
ready: boolean;
|
|
21
|
+
displayName?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
signOut: () => Promise<void>;
|
|
24
|
+
};
|
|
25
|
+
const IdentityContext = createContext<IdentityState>({
|
|
26
|
+
enabled: Boolean(feedbax.connected),
|
|
27
|
+
ready: !feedbax.connected,
|
|
28
|
+
signOut: async () => {},
|
|
29
|
+
});
|
|
30
|
+
export function useConnectedIdentity() {
|
|
31
|
+
return useContext(IdentityContext);
|
|
32
|
+
}
|
|
33
|
+
export function ConnectedIdentityProvider({
|
|
34
|
+
children,
|
|
35
|
+
}: {
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
}) {
|
|
38
|
+
const [state, setState] = useState<{
|
|
39
|
+
ready: boolean;
|
|
40
|
+
displayName?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
}>({ ready: !feedbax.connected });
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!feedbax.connected) return;
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
fetch("/internal/identity/session", {
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
cache: "no-store",
|
|
49
|
+
})
|
|
50
|
+
.then(async (response) => {
|
|
51
|
+
if (!response.ok) throw new Error();
|
|
52
|
+
const value = await response.json();
|
|
53
|
+
setState({ ready: true, displayName: value.participant?.displayName });
|
|
54
|
+
})
|
|
55
|
+
.catch(() => {
|
|
56
|
+
if (!controller.signal.aborted)
|
|
57
|
+
setState({
|
|
58
|
+
ready: true,
|
|
59
|
+
error: "Sign-in is temporarily unavailable.",
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
return () => controller.abort();
|
|
63
|
+
}, []);
|
|
64
|
+
async function signOut() {
|
|
65
|
+
const response = await fetch("/internal/identity/logout", {
|
|
66
|
+
method: "POST",
|
|
67
|
+
});
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
setState((current) => ({
|
|
70
|
+
...current,
|
|
71
|
+
error: "Sign-out failed. Try again.",
|
|
72
|
+
}));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
window.location.reload();
|
|
76
|
+
}
|
|
77
|
+
return (
|
|
78
|
+
<IdentityContext.Provider
|
|
79
|
+
value={{ enabled: Boolean(feedbax.connected), ...state, signOut }}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</IdentityContext.Provider>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
export function ConnectedSignInPrompt() {
|
|
86
|
+
const identity = useConnectedIdentity();
|
|
87
|
+
return (
|
|
88
|
+
<Item variant="muted" size="sm" className="my-4">
|
|
89
|
+
<ItemContent className="min-w-36">
|
|
90
|
+
<ItemTitle>Join the conversation</ItemTitle>
|
|
91
|
+
<ItemDescription className="line-clamp-none">
|
|
92
|
+
{identity.error ?? "Sign in with your product account to comment."}
|
|
93
|
+
</ItemDescription>
|
|
94
|
+
</ItemContent>
|
|
95
|
+
<ItemActions>
|
|
96
|
+
<Button
|
|
97
|
+
nativeButton={false}
|
|
98
|
+
render={<a href="/internal/identity/start" />}
|
|
99
|
+
variant="outline"
|
|
100
|
+
size="sm"
|
|
101
|
+
disabled={!identity.ready}
|
|
102
|
+
>
|
|
103
|
+
Sign in
|
|
104
|
+
</Button>
|
|
105
|
+
</ItemActions>
|
|
106
|
+
</Item>
|
|
107
|
+
);
|
|
108
|
+
}
|