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
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
} from "@testing-library/react";
|
|
14
14
|
import {
|
|
15
15
|
createMemoryHistory,
|
|
16
|
-
|
|
16
|
+
createRootRouteWithContext,
|
|
17
17
|
createRouter,
|
|
18
18
|
Outlet,
|
|
19
19
|
RouterProvider,
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
vi,
|
|
29
29
|
} from "vitest";
|
|
30
30
|
|
|
31
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
32
|
+
|
|
31
33
|
import { loadPublicPost } from "./public-post-page";
|
|
32
34
|
import { createPublicPostRoute } from "./public-post-route";
|
|
33
35
|
import { PublicPostUnavailable } from "./public-post-unavailable";
|
|
@@ -76,7 +78,7 @@ describe("/p/$slug", () => {
|
|
|
76
78
|
expect(details.getByText("Submitted")).toBeTruthy();
|
|
77
79
|
expect(details.getByText("Updated")).toBeTruthy();
|
|
78
80
|
expect(history.location.pathname).toBe("/p/keyboard-first-search");
|
|
79
|
-
expect(screen.
|
|
81
|
+
expect(await screen.findByText("Please add Vim bindings.")).toBeTruthy();
|
|
80
82
|
expect(screen.getByText("Ari")).toBeTruthy();
|
|
81
83
|
expect(document.body.textContent).not.toContain("secret-id");
|
|
82
84
|
});
|
|
@@ -161,6 +163,30 @@ describe("/p/$slug", () => {
|
|
|
161
163
|
"native-cursor",
|
|
162
164
|
);
|
|
163
165
|
});
|
|
166
|
+
|
|
167
|
+
it("renders the full-page Post before a slow Comment request completes", async () => {
|
|
168
|
+
const feedback = createFeedbackModule({ initialItems: [publishedPost] });
|
|
169
|
+
let finish!: (page: CommentThreadPage) => void;
|
|
170
|
+
const loadComments = vi.fn(
|
|
171
|
+
() =>
|
|
172
|
+
new Promise<CommentThreadPage>((resolve) => {
|
|
173
|
+
finish = resolve;
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
const history = createMemoryHistory({
|
|
177
|
+
initialEntries: ["/p/keyboard-first-search"],
|
|
178
|
+
});
|
|
179
|
+
const router = createPostRouter({ feedback, history, loadComments });
|
|
180
|
+
render(<RouterProvider router={router} />);
|
|
181
|
+
expect(
|
|
182
|
+
await screen.findByRole("heading", { name: "Keyboard-first search" }),
|
|
183
|
+
).toBeTruthy();
|
|
184
|
+
expect(
|
|
185
|
+
screen.getByRole("status", { name: "Loading Comments" }),
|
|
186
|
+
).toBeTruthy();
|
|
187
|
+
finish({ items: [] });
|
|
188
|
+
expect(await screen.findByText("No comments yet")).toBeTruthy();
|
|
189
|
+
});
|
|
164
190
|
});
|
|
165
191
|
|
|
166
192
|
function createPostRouter({
|
|
@@ -172,7 +198,16 @@ function createPostRouter({
|
|
|
172
198
|
history: ReturnType<typeof createMemoryHistory>;
|
|
173
199
|
loadComments?: (slug: string, cursor?: string) => Promise<CommentThreadPage>;
|
|
174
200
|
}) {
|
|
175
|
-
const
|
|
201
|
+
const queryClient = new QueryClient({
|
|
202
|
+
defaultOptions: { queries: { retry: false } },
|
|
203
|
+
});
|
|
204
|
+
const rootRoute = createRootRouteWithContext<{ queryClient: QueryClient }>()({
|
|
205
|
+
component: () => (
|
|
206
|
+
<QueryClientProvider client={queryClient}>
|
|
207
|
+
<Outlet />
|
|
208
|
+
</QueryClientProvider>
|
|
209
|
+
),
|
|
210
|
+
});
|
|
176
211
|
const postRoute = createPublicPostRoute({
|
|
177
212
|
loadPost: (slug) => loadPublicPost({ feedback, slug }),
|
|
178
213
|
loadComments:
|
|
@@ -185,6 +220,7 @@ function createPostRouter({
|
|
|
185
220
|
getParentRoute: () => rootRoute,
|
|
186
221
|
} as never);
|
|
187
222
|
return createRouter({
|
|
223
|
+
context: { queryClient },
|
|
188
224
|
history,
|
|
189
225
|
routeTree: rootRoute.addChildren([postRoute]),
|
|
190
226
|
});
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import type { CommentThreadPage, PublicPost } from "@feedbax/feedback";
|
|
2
2
|
import { createFileRoute } from "@tanstack/react-router";
|
|
3
|
-
import {
|
|
3
|
+
import { useSuspenseQuery } from "@tanstack/react-query";
|
|
4
|
+
import { type ReactNode } from "react";
|
|
4
5
|
import type { PortalCommentRequest } from "./portal-comments";
|
|
5
|
-
import {
|
|
6
|
+
import { PostDetailContent } from "./post-detail-content";
|
|
6
7
|
import feedbax from "./feedbax";
|
|
7
8
|
import { createRouteHead } from "./portal-head";
|
|
8
9
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
PublicPostDetailSkeleton,
|
|
12
|
-
} from "./public-post-detail";
|
|
10
|
+
import { PublicPostDetailSkeleton } from "./public-post-detail-skeleton";
|
|
11
|
+
import { publicPostQuery } from "./post-detail-query";
|
|
13
12
|
|
|
14
13
|
export function createPublicPostRoute({
|
|
15
14
|
loadPost,
|
|
@@ -21,11 +20,11 @@ export function createPublicPostRoute({
|
|
|
21
20
|
renderUnavailable: (slug: string) => ReactNode;
|
|
22
21
|
}) {
|
|
23
22
|
const route = createFileRoute("/p/$slug")({
|
|
24
|
-
loader: async ({ params }) => {
|
|
25
|
-
const post = await
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return { post
|
|
23
|
+
loader: async ({ params, context }) => {
|
|
24
|
+
const post = await context.queryClient.ensureQueryData(
|
|
25
|
+
publicPostQuery(params.slug, loadPost),
|
|
26
|
+
);
|
|
27
|
+
return { post };
|
|
29
28
|
},
|
|
30
29
|
head: ({ loaderData, params }) =>
|
|
31
30
|
loaderData?.post
|
|
@@ -45,26 +44,14 @@ export function createPublicPostRoute({
|
|
|
45
44
|
});
|
|
46
45
|
|
|
47
46
|
function PostRoute() {
|
|
48
|
-
const data = route.useLoaderData();
|
|
49
47
|
const { slug } = route.useParams();
|
|
50
|
-
const
|
|
51
|
-
return
|
|
52
|
-
<
|
|
53
|
-
post={
|
|
54
|
-
|
|
48
|
+
const { data: post } = useSuspenseQuery(publicPostQuery(slug, loadPost));
|
|
49
|
+
return post ? (
|
|
50
|
+
<PostDetailContent
|
|
51
|
+
post={post}
|
|
52
|
+
loadComments={loadComments}
|
|
55
53
|
submitComment={submitPublicComment}
|
|
56
54
|
mutateComment={mutatePublicComment}
|
|
57
|
-
loadMore={
|
|
58
|
-
loadComments && comments.nextCursor
|
|
59
|
-
? async () => {
|
|
60
|
-
const next = await loadComments(slug, comments.nextCursor);
|
|
61
|
-
setComments({
|
|
62
|
-
items: mergeCommentThreads(comments.items, next.items),
|
|
63
|
-
...(next.nextCursor ? { nextCursor: next.nextCursor } : {}),
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
: undefined
|
|
67
|
-
}
|
|
68
55
|
/>
|
|
69
56
|
) : (
|
|
70
57
|
renderUnavailable(slug)
|
|
@@ -128,7 +128,7 @@ describe("/roadmap route", () => {
|
|
|
128
128
|
render(<RouterProvider router={router} />);
|
|
129
129
|
|
|
130
130
|
const loadMore = await screen.findByRole("button", {
|
|
131
|
-
name: "
|
|
131
|
+
name: "View more Planned Posts",
|
|
132
132
|
});
|
|
133
133
|
fireEvent.click(loadMore);
|
|
134
134
|
|
|
@@ -152,7 +152,7 @@ describe("/roadmap route", () => {
|
|
|
152
152
|
).toBeTruthy();
|
|
153
153
|
await waitFor(() =>
|
|
154
154
|
expect(
|
|
155
|
-
screen.queryByRole("button", { name: "
|
|
155
|
+
screen.queryByRole("button", { name: "View more Planned Posts" }),
|
|
156
156
|
).toBeNull(),
|
|
157
157
|
);
|
|
158
158
|
expect(
|
|
@@ -163,6 +163,42 @@ describe("/roadmap route", () => {
|
|
|
163
163
|
).toBe("1 more Planned Post loaded.");
|
|
164
164
|
});
|
|
165
165
|
|
|
166
|
+
it("reveals five cards at a time before requesting another server page", async () => {
|
|
167
|
+
const items = Array.from({ length: 12 }, (_, index) =>
|
|
168
|
+
post(`planned-${index}`, "Planned"),
|
|
169
|
+
);
|
|
170
|
+
const loadMore = vi.fn().mockResolvedValue({
|
|
171
|
+
items: [post("planned-12", "Planned")],
|
|
172
|
+
totalCount: 13,
|
|
173
|
+
});
|
|
174
|
+
render(
|
|
175
|
+
<PublicRoadmapView
|
|
176
|
+
roadmap={{
|
|
177
|
+
Planned: { items, totalCount: 13, nextCursor: "next-page" },
|
|
178
|
+
"In Progress": { items: [], totalCount: 0 },
|
|
179
|
+
Shipped: { items: [], totalCount: 0 },
|
|
180
|
+
}}
|
|
181
|
+
loadMore={loadMore}
|
|
182
|
+
/>,
|
|
183
|
+
);
|
|
184
|
+
expect(screen.getAllByRole("link")).toHaveLength(5);
|
|
185
|
+
const button = screen.getByRole("button", {
|
|
186
|
+
name: "View more Planned Posts",
|
|
187
|
+
});
|
|
188
|
+
fireEvent.click(button);
|
|
189
|
+
expect(screen.getAllByRole("link")).toHaveLength(10);
|
|
190
|
+
expect(loadMore).not.toHaveBeenCalled();
|
|
191
|
+
fireEvent.click(button);
|
|
192
|
+
expect(
|
|
193
|
+
await screen.findByRole("link", { name: /planned-12 / }),
|
|
194
|
+
).toBeTruthy();
|
|
195
|
+
expect(screen.getAllByRole("link")).toHaveLength(13);
|
|
196
|
+
expect(loadMore).toHaveBeenCalledOnce();
|
|
197
|
+
expect(
|
|
198
|
+
screen.queryByRole("button", { name: "View more Planned Posts" }),
|
|
199
|
+
).toBeNull();
|
|
200
|
+
});
|
|
201
|
+
|
|
166
202
|
it("shows one status column at a time behind mobile status tabs", async () => {
|
|
167
203
|
Object.defineProperty(window, "matchMedia", {
|
|
168
204
|
configurable: true,
|
|
@@ -50,6 +50,12 @@ const roadmapGroups = [
|
|
|
50
50
|
] as const;
|
|
51
51
|
|
|
52
52
|
const mobileMediaQuery = "(max-width: 1023px)";
|
|
53
|
+
const previewSize = 5;
|
|
54
|
+
const initialVisible = {
|
|
55
|
+
Planned: previewSize,
|
|
56
|
+
"In Progress": previewSize,
|
|
57
|
+
Shipped: previewSize,
|
|
58
|
+
};
|
|
53
59
|
|
|
54
60
|
export function PublicRoadmapView({
|
|
55
61
|
roadmap,
|
|
@@ -63,6 +69,7 @@ export function PublicRoadmapView({
|
|
|
63
69
|
const mobile = useMediaQuery(mobileMediaQuery);
|
|
64
70
|
const [activeStatus, setActiveStatus] = useState<RoadmapStatus>("Planned");
|
|
65
71
|
const [columns, setColumns] = useState(roadmap);
|
|
72
|
+
const [visible, setVisible] = useState(initialVisible);
|
|
66
73
|
const [loading, setLoading] = useState<Partial<Record<RoadmapStatus, true>>>(
|
|
67
74
|
{},
|
|
68
75
|
);
|
|
@@ -71,7 +78,10 @@ export function PublicRoadmapView({
|
|
|
71
78
|
);
|
|
72
79
|
const [loadStatusMessage, setLoadStatusMessage] = useState("");
|
|
73
80
|
|
|
74
|
-
useEffect(() =>
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
setColumns(roadmap);
|
|
83
|
+
setVisible(initialVisible);
|
|
84
|
+
}, [roadmap]);
|
|
75
85
|
|
|
76
86
|
const visibleGroups = mobile
|
|
77
87
|
? roadmapGroups.filter(({ status }) => status === activeStatus)
|
|
@@ -89,13 +99,26 @@ export function PublicRoadmapView({
|
|
|
89
99
|
return next;
|
|
90
100
|
});
|
|
91
101
|
try {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
102
|
+
const before = Math.min(
|
|
103
|
+
visible[query.status],
|
|
104
|
+
columns[query.status].items.length,
|
|
105
|
+
);
|
|
106
|
+
const target = before + previewSize;
|
|
107
|
+
let combined = columns[query.status];
|
|
108
|
+
const cursors = new Set<string>();
|
|
109
|
+
do {
|
|
110
|
+
if (!combined.nextCursor || cursors.has(combined.nextCursor)) break;
|
|
111
|
+
cursors.add(combined.nextCursor);
|
|
112
|
+
combined = appendRoadmapPage(
|
|
113
|
+
combined,
|
|
114
|
+
await loadMore({ status: query.status, cursor: combined.nextCursor }),
|
|
115
|
+
);
|
|
116
|
+
} while (combined.items.length < target && combined.nextCursor);
|
|
117
|
+
setColumns((current) => ({ ...current, [query.status]: combined }));
|
|
118
|
+
const count = Math.min(target, combined.items.length) - before;
|
|
119
|
+
setVisible((current) => ({ ...current, [query.status]: before + count }));
|
|
97
120
|
setLoadStatusMessage(
|
|
98
|
-
`${
|
|
121
|
+
`${count} more ${group.heading} ${count === 1 ? "Post" : "Posts"} loaded.`,
|
|
99
122
|
);
|
|
100
123
|
} catch {
|
|
101
124
|
setErrors((current) => ({ ...current, [query.status]: true }));
|
|
@@ -168,7 +191,7 @@ export function PublicRoadmapView({
|
|
|
168
191
|
<RoadmapEmptyState heading={heading} />
|
|
169
192
|
) : (
|
|
170
193
|
<ol className="grid gap-3">
|
|
171
|
-
{column.items.map((item) => (
|
|
194
|
+
{column.items.slice(0, visible[status]).map((item) => (
|
|
172
195
|
<RoadmapItem
|
|
173
196
|
item={item}
|
|
174
197
|
key={item.slug}
|
|
@@ -200,26 +223,36 @@ export function PublicRoadmapView({
|
|
|
200
223
|
</Button>
|
|
201
224
|
</div>
|
|
202
225
|
) : null}
|
|
203
|
-
{column.
|
|
226
|
+
{(column.items.length > visible[status] ||
|
|
227
|
+
(column.nextCursor && loadMore)) &&
|
|
228
|
+
!errors[status] ? (
|
|
204
229
|
<Button
|
|
205
|
-
aria-label={`
|
|
230
|
+
aria-label={`View more ${heading} Posts`}
|
|
206
231
|
className="mt-4 h-10 w-full gap-2"
|
|
207
232
|
disabled={loading[status] === true}
|
|
208
233
|
type="button"
|
|
209
234
|
variant="ghost"
|
|
210
|
-
onClick={() =>
|
|
211
|
-
|
|
212
|
-
status
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
235
|
+
onClick={() => {
|
|
236
|
+
if (
|
|
237
|
+
column.items.length - visible[status] >= previewSize ||
|
|
238
|
+
!column.nextCursor ||
|
|
239
|
+
!loadMore
|
|
240
|
+
) {
|
|
241
|
+
setVisible((current) => ({
|
|
242
|
+
...current,
|
|
243
|
+
[status]: current[status] + previewSize,
|
|
244
|
+
}));
|
|
245
|
+
} else if (column.nextCursor) {
|
|
246
|
+
void loadNextPage({ status, cursor: column.nextCursor });
|
|
247
|
+
}
|
|
248
|
+
}}
|
|
216
249
|
>
|
|
217
250
|
{loading[status] ? (
|
|
218
251
|
<>
|
|
219
252
|
<Spinner aria-hidden="true" /> Loading…
|
|
220
253
|
</>
|
|
221
254
|
) : (
|
|
222
|
-
"
|
|
255
|
+
"View more"
|
|
223
256
|
)}
|
|
224
257
|
</Button>
|
|
225
258
|
) : null}
|
|
@@ -306,12 +339,12 @@ function RoadmapIntro() {
|
|
|
306
339
|
return (
|
|
307
340
|
<section aria-labelledby="roadmap-heading">
|
|
308
341
|
<h1
|
|
309
|
-
className="text-
|
|
342
|
+
className="text-2xl font-semibold tracking-tight"
|
|
310
343
|
id="roadmap-heading"
|
|
311
344
|
>
|
|
312
345
|
Roadmap
|
|
313
346
|
</h1>
|
|
314
|
-
<p className="mt-2 text-
|
|
347
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
315
348
|
What we’re planning, building, and shipping.
|
|
316
349
|
</p>
|
|
317
350
|
</section>
|
|
@@ -342,15 +375,15 @@ function RoadmapItem({ item, masked }: { item: PublicPost; masked: boolean }) {
|
|
|
342
375
|
contextual={masked}
|
|
343
376
|
className="group block border bg-background/30 transition-colors duration-150 hover:bg-muted/30"
|
|
344
377
|
>
|
|
345
|
-
<article className="p-
|
|
346
|
-
<h3 className="text-
|
|
378
|
+
<article className="flex h-40 flex-col p-4">
|
|
379
|
+
<h3 className="line-clamp-2 min-h-10 text-sm font-medium leading-5 group-hover:text-foreground">
|
|
347
380
|
{item.title}
|
|
348
381
|
</h3>
|
|
349
|
-
<p className="mt-2 text-sm leading-
|
|
382
|
+
<p className="mt-2 line-clamp-2 min-h-10 text-sm leading-5 text-muted-foreground">
|
|
350
383
|
{item.description}
|
|
351
384
|
</p>
|
|
352
385
|
<div
|
|
353
|
-
className="mt-
|
|
386
|
+
className="mt-auto flex items-center gap-3 pt-3 text-xs text-muted-foreground"
|
|
354
387
|
role="group"
|
|
355
388
|
aria-label="Post metadata"
|
|
356
389
|
>
|
|
@@ -365,6 +365,32 @@ describe("command palette", () => {
|
|
|
365
365
|
await waitFor(() => expect(history.location.pathname).toBe("/roadmap"));
|
|
366
366
|
});
|
|
367
367
|
|
|
368
|
+
it("keeps previous search results visible while a new term is loading", async () => {
|
|
369
|
+
let finish: ((page: PublicPostPage) => void) | undefined;
|
|
370
|
+
renderShell("/", {
|
|
371
|
+
searchPosts: (term) =>
|
|
372
|
+
term === "keyboard"
|
|
373
|
+
? Promise.resolve({ items: [palettePost()] })
|
|
374
|
+
: new Promise((resolve) => {
|
|
375
|
+
finish = resolve;
|
|
376
|
+
}),
|
|
377
|
+
});
|
|
378
|
+
await screen.findByRole("link", { name: "Feedback" });
|
|
379
|
+
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
|
380
|
+
const input = await screen.findByPlaceholderText("Search feedback…");
|
|
381
|
+
fireEvent.change(input, { target: { value: "keyboard" } });
|
|
382
|
+
await screen.findByText("Keyboard-first search");
|
|
383
|
+
fireEvent.change(input, { target: { value: "next search" } });
|
|
384
|
+
await waitFor(() => expect(finish).toBeTypeOf("function"));
|
|
385
|
+
expect(screen.getByText("Keyboard-first search")).toBeTruthy();
|
|
386
|
+
finish?.({ items: [] });
|
|
387
|
+
await waitFor(() =>
|
|
388
|
+
expect(
|
|
389
|
+
screen.getByRole("status", { name: "Search status" }).textContent,
|
|
390
|
+
).toBe("No Posts match this search."),
|
|
391
|
+
);
|
|
392
|
+
});
|
|
393
|
+
|
|
368
394
|
it("shows an empty state when no Posts match", async () => {
|
|
369
395
|
renderShell("/");
|
|
370
396
|
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { Route as rootRouteImport } from './routes/__root'
|
|
12
12
|
import { Route as IndexRouteImport } from './routes/index'
|
|
13
13
|
import { Route as ChangelogRouteImport } from './routes/changelog'
|
|
14
|
+
import { Route as EmailPreferencesRouteImport } from './routes/email-preferences'
|
|
14
15
|
import { Route as RoadmapRouteImport } from './routes/roadmap'
|
|
15
16
|
import { Route as SubmitRouteImport } from './routes/submit'
|
|
16
17
|
import { Route as PSlugRouteImport } from './routes/p.$slug'
|
|
@@ -25,6 +26,11 @@ const ChangelogRoute = ChangelogRouteImport.update({
|
|
|
25
26
|
path: '/changelog',
|
|
26
27
|
getParentRoute: () => rootRouteImport,
|
|
27
28
|
} as any)
|
|
29
|
+
const EmailPreferencesRoute = EmailPreferencesRouteImport.update({
|
|
30
|
+
id: '/email-preferences',
|
|
31
|
+
path: '/email-preferences',
|
|
32
|
+
getParentRoute: () => rootRouteImport,
|
|
33
|
+
} as any)
|
|
28
34
|
const RoadmapRoute = RoadmapRouteImport.update({
|
|
29
35
|
id: '/roadmap',
|
|
30
36
|
path: '/roadmap',
|
|
@@ -44,6 +50,7 @@ const PSlugRoute = PSlugRouteImport.update({
|
|
|
44
50
|
export interface FileRoutesByFullPath {
|
|
45
51
|
'/': typeof IndexRoute
|
|
46
52
|
'/changelog': typeof ChangelogRoute
|
|
53
|
+
'/email-preferences': typeof EmailPreferencesRoute
|
|
47
54
|
'/roadmap': typeof RoadmapRoute
|
|
48
55
|
'/submit': typeof SubmitRoute
|
|
49
56
|
'/p/$slug': typeof PSlugRoute
|
|
@@ -51,6 +58,7 @@ export interface FileRoutesByFullPath {
|
|
|
51
58
|
export interface FileRoutesByTo {
|
|
52
59
|
'/': typeof IndexRoute
|
|
53
60
|
'/changelog': typeof ChangelogRoute
|
|
61
|
+
'/email-preferences': typeof EmailPreferencesRoute
|
|
54
62
|
'/roadmap': typeof RoadmapRoute
|
|
55
63
|
'/submit': typeof SubmitRoute
|
|
56
64
|
'/p/$slug': typeof PSlugRoute
|
|
@@ -59,21 +67,42 @@ export interface FileRoutesById {
|
|
|
59
67
|
__root__: typeof rootRouteImport
|
|
60
68
|
'/': typeof IndexRoute
|
|
61
69
|
'/changelog': typeof ChangelogRoute
|
|
70
|
+
'/email-preferences': typeof EmailPreferencesRoute
|
|
62
71
|
'/roadmap': typeof RoadmapRoute
|
|
63
72
|
'/submit': typeof SubmitRoute
|
|
64
73
|
'/p/$slug': typeof PSlugRoute
|
|
65
74
|
}
|
|
66
75
|
export interface FileRouteTypes {
|
|
67
76
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
68
|
-
fullPaths:
|
|
77
|
+
fullPaths:
|
|
78
|
+
| '/'
|
|
79
|
+
| '/changelog'
|
|
80
|
+
| '/email-preferences'
|
|
81
|
+
| '/roadmap'
|
|
82
|
+
| '/submit'
|
|
83
|
+
| '/p/$slug'
|
|
69
84
|
fileRoutesByTo: FileRoutesByTo
|
|
70
|
-
to:
|
|
71
|
-
|
|
85
|
+
to:
|
|
86
|
+
| '/'
|
|
87
|
+
| '/changelog'
|
|
88
|
+
| '/email-preferences'
|
|
89
|
+
| '/roadmap'
|
|
90
|
+
| '/submit'
|
|
91
|
+
| '/p/$slug'
|
|
92
|
+
id:
|
|
93
|
+
| '__root__'
|
|
94
|
+
| '/'
|
|
95
|
+
| '/changelog'
|
|
96
|
+
| '/email-preferences'
|
|
97
|
+
| '/roadmap'
|
|
98
|
+
| '/submit'
|
|
99
|
+
| '/p/$slug'
|
|
72
100
|
fileRoutesById: FileRoutesById
|
|
73
101
|
}
|
|
74
102
|
export interface RootRouteChildren {
|
|
75
103
|
IndexRoute: typeof IndexRoute
|
|
76
104
|
ChangelogRoute: typeof ChangelogRoute
|
|
105
|
+
EmailPreferencesRoute: typeof EmailPreferencesRoute
|
|
77
106
|
RoadmapRoute: typeof RoadmapRoute
|
|
78
107
|
SubmitRoute: typeof SubmitRoute
|
|
79
108
|
PSlugRoute: typeof PSlugRoute
|
|
@@ -95,6 +124,13 @@ declare module '@tanstack/react-router' {
|
|
|
95
124
|
preLoaderRoute: typeof ChangelogRouteImport
|
|
96
125
|
parentRoute: typeof rootRouteImport
|
|
97
126
|
}
|
|
127
|
+
'/email-preferences': {
|
|
128
|
+
id: '/email-preferences'
|
|
129
|
+
path: '/email-preferences'
|
|
130
|
+
fullPath: '/email-preferences'
|
|
131
|
+
preLoaderRoute: typeof EmailPreferencesRouteImport
|
|
132
|
+
parentRoute: typeof rootRouteImport
|
|
133
|
+
}
|
|
98
134
|
'/roadmap': {
|
|
99
135
|
id: '/roadmap'
|
|
100
136
|
path: '/roadmap'
|
|
@@ -122,6 +158,7 @@ declare module '@tanstack/react-router' {
|
|
|
122
158
|
const rootRouteChildren: RootRouteChildren = {
|
|
123
159
|
IndexRoute: IndexRoute,
|
|
124
160
|
ChangelogRoute: ChangelogRoute,
|
|
161
|
+
EmailPreferencesRoute: EmailPreferencesRoute,
|
|
125
162
|
RoadmapRoute: RoadmapRoute,
|
|
126
163
|
SubmitRoute: SubmitRoute,
|
|
127
164
|
PSlugRoute: PSlugRoute,
|
|
@@ -129,3 +166,12 @@ const rootRouteChildren: RootRouteChildren = {
|
|
|
129
166
|
export const routeTree = rootRouteImport
|
|
130
167
|
._addFileChildren(rootRouteChildren)
|
|
131
168
|
._addFileTypes<FileRouteTypes>()
|
|
169
|
+
|
|
170
|
+
import type { getRouter } from './router.tsx'
|
|
171
|
+
import type { createStart } from '@tanstack/react-start'
|
|
172
|
+
declare module '@tanstack/react-start' {
|
|
173
|
+
interface Register {
|
|
174
|
+
ssr: true
|
|
175
|
+
router: Awaited<ReturnType<typeof getRouter>>
|
|
176
|
+
}
|
|
177
|
+
}
|
package/template/src/router.tsx
CHANGED
|
@@ -11,6 +11,8 @@ export const getRouter = () => {
|
|
|
11
11
|
scrollRestoration: true,
|
|
12
12
|
defaultPreloadStaleTime: 0,
|
|
13
13
|
context: { queryClient },
|
|
14
|
+
defaultPendingMs: 250,
|
|
15
|
+
defaultPendingMinMs: 200,
|
|
14
16
|
defaultPendingComponent: () => <Loader />,
|
|
15
17
|
defaultNotFoundComponent: () => <div>Not Found</div>,
|
|
16
18
|
});
|
|
@@ -11,6 +11,8 @@ import { ThemeProvider } from "next-themes";
|
|
|
11
11
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
12
12
|
|
|
13
13
|
import Header from "../components/header";
|
|
14
|
+
import { PortalFooter } from "../components/portal-footer";
|
|
15
|
+
import { LazyPostDetailOverlay as PostDetailOverlay } from "../lazy-post-detail-overlay";
|
|
14
16
|
import { DeviceProfileProvider } from "../components/device-profile-provider";
|
|
15
17
|
import {
|
|
16
18
|
CommandPaletteProvider,
|
|
@@ -39,11 +41,6 @@ const CreatePostOverlay = lazy(() =>
|
|
|
39
41
|
default: module.LazyCreatePostOverlay,
|
|
40
42
|
})),
|
|
41
43
|
);
|
|
42
|
-
const PostDetailOverlay = lazy(() =>
|
|
43
|
-
import("../lazy-post-detail-overlay").then((module) => ({
|
|
44
|
-
default: module.LazyPostDetailOverlay,
|
|
45
|
-
})),
|
|
46
|
-
);
|
|
47
44
|
|
|
48
45
|
export type RouterAppContext = {
|
|
49
46
|
queryClient: import("@tanstack/react-query").QueryClient;
|
|
@@ -97,6 +94,7 @@ function RootDocument() {
|
|
|
97
94
|
<div id="main-content" tabIndex={-1}>
|
|
98
95
|
<Outlet />
|
|
99
96
|
</div>
|
|
97
|
+
<PortalFooter />
|
|
100
98
|
<OptionalPortalSurfaces />
|
|
101
99
|
</div>
|
|
102
100
|
</CommandPaletteProvider>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createFileRoute, notFound } from "@tanstack/react-router";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Button } from "@feedbax/ui/components/button";
|
|
4
|
+
import feedbax from "../feedbax";
|
|
5
|
+
export const Route = createFileRoute("/email-preferences")({
|
|
6
|
+
validateSearch: (search: Record<string, unknown>) => ({
|
|
7
|
+
token: typeof search.token === "string" ? search.token : "",
|
|
8
|
+
}),
|
|
9
|
+
beforeLoad: () => {
|
|
10
|
+
if (!feedbax.connected?.email) throw notFound();
|
|
11
|
+
},
|
|
12
|
+
head: () => ({
|
|
13
|
+
meta: [
|
|
14
|
+
{ name: "robots", content: "noindex,nofollow" },
|
|
15
|
+
{ name: "referrer", content: "no-referrer" },
|
|
16
|
+
],
|
|
17
|
+
}),
|
|
18
|
+
component: EmailPreferencesPage,
|
|
19
|
+
});
|
|
20
|
+
function EmailPreferencesPage() {
|
|
21
|
+
const { token } = Route.useSearch();
|
|
22
|
+
const [pending, setPending] = useState(false);
|
|
23
|
+
const [done, setDone] = useState(false);
|
|
24
|
+
const [error, setError] = useState<string>();
|
|
25
|
+
async function unsubscribe() {
|
|
26
|
+
setPending(true);
|
|
27
|
+
setError(undefined);
|
|
28
|
+
try {
|
|
29
|
+
const response = await fetch(
|
|
30
|
+
`/internal/connected/unsubscribe?token=${encodeURIComponent(token)}`,
|
|
31
|
+
{ method: "POST" },
|
|
32
|
+
);
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
throw new Error(
|
|
35
|
+
"This link could not be used. Try again or change email preferences in your notification inbox.",
|
|
36
|
+
);
|
|
37
|
+
setDone(true);
|
|
38
|
+
} catch (cause) {
|
|
39
|
+
setError(
|
|
40
|
+
cause instanceof Error
|
|
41
|
+
? cause.message
|
|
42
|
+
: "Couldn’t update email preferences.",
|
|
43
|
+
);
|
|
44
|
+
} finally {
|
|
45
|
+
setPending(false);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return (
|
|
49
|
+
<main className="mx-auto max-w-lg space-y-5 px-6 py-16">
|
|
50
|
+
<h1 className="text-2xl font-semibold tracking-tight">
|
|
51
|
+
{done ? "Email notifications are off" : "Stop email notifications"}
|
|
52
|
+
</h1>
|
|
53
|
+
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
54
|
+
{done
|
|
55
|
+
? "You can turn them on again in your notification inbox."
|
|
56
|
+
: "You will keep receiving updates inside Feedbax."}
|
|
57
|
+
</p>
|
|
58
|
+
{done ? (
|
|
59
|
+
<Button nativeButton={false} render={<a href="/" />} variant="outline">
|
|
60
|
+
Back to feedback
|
|
61
|
+
</Button>
|
|
62
|
+
) : (
|
|
63
|
+
<Button
|
|
64
|
+
onClick={unsubscribe}
|
|
65
|
+
disabled={pending || !/^[a-f0-9]{32}\.[A-Za-z0-9_-]{43}$/.test(token)}
|
|
66
|
+
>
|
|
67
|
+
{pending ? "Updating…" : "Stop email notifications"}
|
|
68
|
+
</Button>
|
|
69
|
+
)}
|
|
70
|
+
{error ? (
|
|
71
|
+
<p role="alert" className="text-sm text-destructive">
|
|
72
|
+
{error}
|
|
73
|
+
</p>
|
|
74
|
+
) : null}
|
|
75
|
+
</main>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BrowserCapabilityAuthorizationError,
|
|
3
3
|
VoteEligibilityError,
|
|
4
|
+
ImageAttachmentError,
|
|
4
5
|
} from "@feedbax/feedback";
|
|
5
6
|
|
|
6
7
|
export async function runSafePublicRead<Value>(
|
|
@@ -32,7 +33,8 @@ export async function runSafePortalMutation<Value>(
|
|
|
32
33
|
if (
|
|
33
34
|
error instanceof ActionablePortalFailure ||
|
|
34
35
|
error instanceof BrowserCapabilityAuthorizationError ||
|
|
35
|
-
error instanceof VoteEligibilityError
|
|
36
|
+
error instanceof VoteEligibilityError ||
|
|
37
|
+
error instanceof ImageAttachmentError
|
|
36
38
|
) {
|
|
37
39
|
throw error;
|
|
38
40
|
}
|