create-feedbax 0.1.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/LICENSE +201 -21
- package/README.md +11 -1
- package/dist/cli.js +3283 -22
- package/dist/index.js +3394 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +404 -0
- package/template/LICENSE +201 -0
- package/template/README.md +56 -5
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +48 -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 +74 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +205 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +228 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +24 -0
- package/template/src/comment-creation.test.tsx +311 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +28 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +275 -0
- package/template/src/components/connected-identity-provider.tsx +108 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +324 -0
- package/template/src/components/follow-post-button.tsx +89 -0
- package/template/src/components/header.tsx +97 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +53 -0
- 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 +119 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +195 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +51 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- 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/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +98 -0
- package/template/src/feedback-runtime.ts +105 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +139 -0
- package/template/src/feedbax/config/index.ts +364 -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/feedback-storage.ts +43 -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.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +996 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +1795 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +21 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +181 -0
- package/template/src/overlay-post-detail.tsx +43 -0
- 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.test.ts +375 -0
- package/template/src/portal-comments.ts +403 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +530 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +58 -0
- package/template/src/portal-feedback-server-function.ts +96 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +224 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +68 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +148 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +94 -0
- package/template/src/public-feedback-page.test.tsx +355 -0
- package/template/src/public-feedback-page.ts +86 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +772 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +250 -0
- package/template/src/public-post-route.tsx +158 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +254 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +411 -0
- package/template/src/public-shell.test.tsx +544 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +177 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +27 -0
- package/template/src/routes/__root.tsx +114 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +47 -0
- package/template/src/server.ts +151 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +244 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { InfiniteQueryObserver, QueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { PublicPostPage } from "@feedbax/feedback";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { publicPostsQuery } from "./post-queries";
|
|
6
|
+
import { authorizedDraftPostsQuery } from "./authorized-draft-query";
|
|
7
|
+
import { capabilitiesKey, readCapabilities } from "./browser-post-state";
|
|
8
|
+
|
|
9
|
+
vi.mock("./public-feedback-server-function", () => ({
|
|
10
|
+
getPublicPostPage: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
describe("public Posts query", () => {
|
|
14
|
+
it("reuses the loader page and retains it when another cursor page loads", async () => {
|
|
15
|
+
const pages: Record<string, PublicPostPage> = {
|
|
16
|
+
first: {
|
|
17
|
+
items: [post("first-post")],
|
|
18
|
+
nextCursor: "opaque-cursor",
|
|
19
|
+
},
|
|
20
|
+
"opaque-cursor": { items: [post("second-post")] },
|
|
21
|
+
};
|
|
22
|
+
const fetchPage = vi.fn(async ({ cursor }: { cursor?: string }) =>
|
|
23
|
+
cursor ? pages[cursor]! : pages.first!,
|
|
24
|
+
);
|
|
25
|
+
const client = new QueryClient();
|
|
26
|
+
const options = publicPostsQuery({ sort: "trending" }, fetchPage);
|
|
27
|
+
|
|
28
|
+
await client.ensureInfiniteQueryData(options);
|
|
29
|
+
expect(fetchPage).toHaveBeenCalledTimes(1);
|
|
30
|
+
|
|
31
|
+
const observer = new InfiniteQueryObserver(client, options);
|
|
32
|
+
await observer.fetchNextPage();
|
|
33
|
+
|
|
34
|
+
expect(fetchPage).toHaveBeenCalledTimes(2);
|
|
35
|
+
expect(client.getQueryData(options.queryKey)).toMatchObject({
|
|
36
|
+
pages: [pages.first, pages["opaque-cursor"]],
|
|
37
|
+
pageParams: [undefined, "opaque-cursor"],
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("authorized Draft Posts query", () => {
|
|
43
|
+
it("keeps Browser Capability secrets out of its key and cached result", async () => {
|
|
44
|
+
const secret = "private-browser-capability";
|
|
45
|
+
const capabilities = {
|
|
46
|
+
"draft-id": {
|
|
47
|
+
id: "draft-id",
|
|
48
|
+
slug: "private-draft",
|
|
49
|
+
browserCapability: secret,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const fetchDraft = vi.fn(async () => draft("draft-id"));
|
|
53
|
+
const client = new QueryClient();
|
|
54
|
+
const options = authorizedDraftPostsQuery(capabilities, fetchDraft);
|
|
55
|
+
|
|
56
|
+
await client.ensureQueryData(options);
|
|
57
|
+
|
|
58
|
+
expect(fetchDraft).toHaveBeenCalledWith({
|
|
59
|
+
data: {
|
|
60
|
+
id: "draft-id",
|
|
61
|
+
slug: "private-draft",
|
|
62
|
+
browserCapability: secret,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
expect(JSON.stringify(options.queryKey)).not.toContain(secret);
|
|
66
|
+
expect(JSON.stringify(client.getQueryData(options.queryKey))).not.toContain(
|
|
67
|
+
secret,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("removes only capabilities proven invalid while retaining valid drafts", async () => {
|
|
72
|
+
const storage = memoryStorage({
|
|
73
|
+
[capabilitiesKey]: JSON.stringify({
|
|
74
|
+
valid: {
|
|
75
|
+
id: "valid",
|
|
76
|
+
slug: "valid-draft",
|
|
77
|
+
browserCapability: "valid-secret",
|
|
78
|
+
},
|
|
79
|
+
expired: {
|
|
80
|
+
id: "expired",
|
|
81
|
+
slug: "expired-draft",
|
|
82
|
+
browserCapability: "expired-secret",
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
const fetchDraft = vi.fn(async ({ data }: { data: { id: string } }) => {
|
|
87
|
+
if (data.id === "expired") {
|
|
88
|
+
throw new Error("Browser Capability did not authorize this draft.");
|
|
89
|
+
}
|
|
90
|
+
return draft(data.id);
|
|
91
|
+
});
|
|
92
|
+
const client = new QueryClient();
|
|
93
|
+
|
|
94
|
+
const drafts = await client.ensureQueryData(
|
|
95
|
+
authorizedDraftPostsQuery(readCapabilities(storage), fetchDraft, storage),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
expect(drafts.map((item) => item.id)).toEqual(["valid"]);
|
|
99
|
+
expect(Object.keys(readCapabilities(storage))).toEqual(["valid"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps cached drafts visible when a private retrieval fails transiently", async () => {
|
|
103
|
+
const capabilities = {
|
|
104
|
+
valid: {
|
|
105
|
+
id: "valid",
|
|
106
|
+
slug: "valid-draft",
|
|
107
|
+
browserCapability: "valid-secret",
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const options = authorizedDraftPostsQuery(capabilities, async () => {
|
|
111
|
+
throw new Error("Post service unavailable");
|
|
112
|
+
});
|
|
113
|
+
const cached = [draft("valid")];
|
|
114
|
+
const client = new QueryClient();
|
|
115
|
+
client.setQueryData(options.queryKey, cached);
|
|
116
|
+
await client.invalidateQueries({ queryKey: options.queryKey });
|
|
117
|
+
|
|
118
|
+
await expect(client.fetchQuery(options)).rejects.toThrow(
|
|
119
|
+
"Post service unavailable",
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(client.getQueryData(options.queryKey)).toEqual(cached);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function post(slug: string) {
|
|
127
|
+
return {
|
|
128
|
+
slug,
|
|
129
|
+
title: slug,
|
|
130
|
+
description: `${slug} description`,
|
|
131
|
+
type: "Feature Request" as const,
|
|
132
|
+
status: "New" as const,
|
|
133
|
+
createdAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
134
|
+
updatedAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function draft(id: string) {
|
|
139
|
+
return {
|
|
140
|
+
id,
|
|
141
|
+
slug: `${id}-draft`,
|
|
142
|
+
title: `${id} draft`,
|
|
143
|
+
description: `${id} description`,
|
|
144
|
+
type: "Feature Request" as const,
|
|
145
|
+
status: "New" as const,
|
|
146
|
+
submitter: {},
|
|
147
|
+
createdAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
148
|
+
updatedAt: new Date("2026-08-22T10:00:00.000Z"),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function memoryStorage(initial: Record<string, string>) {
|
|
153
|
+
const values = new Map(Object.entries(initial));
|
|
154
|
+
return {
|
|
155
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
156
|
+
setItem: (key: string, value: string) => values.set(key, value),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { infiniteQueryOptions } from "@tanstack/react-query";
|
|
2
|
+
import type { PublicPostQuery, PublicPostPage } from "@feedbax/feedback";
|
|
3
|
+
|
|
4
|
+
export type FetchPublicPostsPage = (
|
|
5
|
+
search: PublicPostQuery,
|
|
6
|
+
) => Promise<PublicPostPage>;
|
|
7
|
+
|
|
8
|
+
export const publicPostsQueryKey = ["public-posts"] as const;
|
|
9
|
+
|
|
10
|
+
export function publicPostsQuery(
|
|
11
|
+
search: PublicPostQuery,
|
|
12
|
+
fetchPage: FetchPublicPostsPage,
|
|
13
|
+
) {
|
|
14
|
+
const querySearch = { ...search, cursor: undefined };
|
|
15
|
+
return infiniteQueryOptions({
|
|
16
|
+
queryKey: [...publicPostsQueryKey, querySearch] as const,
|
|
17
|
+
initialPageParam: undefined as string | undefined,
|
|
18
|
+
queryFn: ({ pageParam }) =>
|
|
19
|
+
fetchPage({
|
|
20
|
+
...querySearch,
|
|
21
|
+
...(pageParam ? { cursor: pageParam } : {}),
|
|
22
|
+
}),
|
|
23
|
+
getNextPageParam: (page) => page.nextCursor,
|
|
24
|
+
staleTime: 30_000,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { DraftPost, PublicPost } from "@feedbax/feedback";
|
|
2
|
+
import type { PortalFeatures } from "@feedbax/config";
|
|
3
|
+
import { Badge } from "@feedbax/ui/components/badge";
|
|
4
|
+
import { Button } from "@feedbax/ui/components/button";
|
|
5
|
+
import {
|
|
6
|
+
Empty,
|
|
7
|
+
EmptyContent,
|
|
8
|
+
EmptyDescription,
|
|
9
|
+
EmptyHeader,
|
|
10
|
+
EmptyMedia,
|
|
11
|
+
EmptyTitle,
|
|
12
|
+
} from "@feedbax/ui/components/empty";
|
|
13
|
+
import { SearchX } from "lucide-react";
|
|
14
|
+
|
|
15
|
+
import { PostLink } from "./masked-post-link";
|
|
16
|
+
import { PostStatusBadge, PostTypeBadge } from "./post-badges";
|
|
17
|
+
import feedbax from "./feedbax";
|
|
18
|
+
import { VoteToggle } from "./vote-toggle";
|
|
19
|
+
|
|
20
|
+
export function PostResults({
|
|
21
|
+
drafts = [],
|
|
22
|
+
initialItems,
|
|
23
|
+
nextCursor,
|
|
24
|
+
loadMore,
|
|
25
|
+
loadingMore = false,
|
|
26
|
+
loadMoreError = false,
|
|
27
|
+
maskPostLinks = false,
|
|
28
|
+
features = feedbax.features,
|
|
29
|
+
}: {
|
|
30
|
+
drafts?: DraftPost[];
|
|
31
|
+
initialItems: PublicPost[];
|
|
32
|
+
nextCursor?: string;
|
|
33
|
+
loadMore?: () => void;
|
|
34
|
+
loadingMore?: boolean;
|
|
35
|
+
loadMoreError?: boolean;
|
|
36
|
+
maskPostLinks?: boolean;
|
|
37
|
+
features?: PortalFeatures;
|
|
38
|
+
}) {
|
|
39
|
+
if (!drafts.length && !initialItems.length) {
|
|
40
|
+
return (
|
|
41
|
+
<Empty className="min-h-72 border">
|
|
42
|
+
<EmptyHeader>
|
|
43
|
+
<EmptyMedia variant="icon">
|
|
44
|
+
<SearchX aria-hidden="true" />
|
|
45
|
+
</EmptyMedia>
|
|
46
|
+
<EmptyTitle>No Posts match this view.</EmptyTitle>
|
|
47
|
+
<EmptyDescription>
|
|
48
|
+
Try another Board or remove a Status filter.
|
|
49
|
+
</EmptyDescription>
|
|
50
|
+
</EmptyHeader>
|
|
51
|
+
<EmptyContent>
|
|
52
|
+
<Button
|
|
53
|
+
render={<a href="/" />}
|
|
54
|
+
nativeButton={false}
|
|
55
|
+
variant="outline"
|
|
56
|
+
>
|
|
57
|
+
Clear filters
|
|
58
|
+
</Button>
|
|
59
|
+
</EmptyContent>
|
|
60
|
+
</Empty>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<ol className="divide-y overflow-hidden border bg-card/30">
|
|
67
|
+
{drafts.map((post) => (
|
|
68
|
+
<PostResultRow
|
|
69
|
+
key={post.id}
|
|
70
|
+
post={post}
|
|
71
|
+
draft
|
|
72
|
+
maskPostLinks={maskPostLinks}
|
|
73
|
+
features={features}
|
|
74
|
+
/>
|
|
75
|
+
))}
|
|
76
|
+
{initialItems.map((post) => (
|
|
77
|
+
<PostResultRow
|
|
78
|
+
key={post.slug}
|
|
79
|
+
post={post}
|
|
80
|
+
maskPostLinks={maskPostLinks}
|
|
81
|
+
features={features}
|
|
82
|
+
/>
|
|
83
|
+
))}
|
|
84
|
+
</ol>
|
|
85
|
+
{loadMoreError ? (
|
|
86
|
+
<div role="alert" className="mt-6 flex items-center gap-3 border p-4">
|
|
87
|
+
<p className="mr-auto text-sm">Couldn’t load more Posts.</p>
|
|
88
|
+
<Button type="button" onClick={loadMore}>
|
|
89
|
+
Try again
|
|
90
|
+
</Button>
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
{nextCursor ? (
|
|
94
|
+
<Button
|
|
95
|
+
className="mt-6"
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={loadMore}
|
|
98
|
+
disabled={loadingMore || loadMoreError || !loadMore}
|
|
99
|
+
>
|
|
100
|
+
{loadingMore ? "Loading…" : "Load More"}
|
|
101
|
+
</Button>
|
|
102
|
+
) : null}
|
|
103
|
+
</>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function PostResultRow({
|
|
108
|
+
post,
|
|
109
|
+
draft = false,
|
|
110
|
+
maskPostLinks,
|
|
111
|
+
features,
|
|
112
|
+
}: {
|
|
113
|
+
post: DraftPost | PublicPost;
|
|
114
|
+
draft?: boolean;
|
|
115
|
+
maskPostLinks: boolean;
|
|
116
|
+
features: PortalFeatures;
|
|
117
|
+
}) {
|
|
118
|
+
const votingEligible =
|
|
119
|
+
features.voting &&
|
|
120
|
+
!draft &&
|
|
121
|
+
"voteCount" in post &&
|
|
122
|
+
post.voteCount !== undefined;
|
|
123
|
+
return (
|
|
124
|
+
<li className="relative">
|
|
125
|
+
{votingEligible ? (
|
|
126
|
+
<div className="absolute top-5 left-5 z-10 flex h-[4.75rem] w-16 items-center justify-center sm:w-[4.75rem]">
|
|
127
|
+
<VoteToggle post={post} />
|
|
128
|
+
</div>
|
|
129
|
+
) : null}
|
|
130
|
+
<PostLink
|
|
131
|
+
className={draft ? "post-link draft-post-link" : "post-link"}
|
|
132
|
+
slug={post.slug}
|
|
133
|
+
contextual={maskPostLinks}
|
|
134
|
+
>
|
|
135
|
+
<article
|
|
136
|
+
className={`relative grid ${features.voting ? "grid-cols-[4rem_minmax(0,1fr)] sm:grid-cols-[4.75rem_minmax(0,1fr)]" : "grid-cols-1"} items-start gap-4 p-5 transition-colors duration-150 hover:bg-muted/30 sm:items-center`}
|
|
137
|
+
>
|
|
138
|
+
{features.voting ? (
|
|
139
|
+
<span className="h-[4.75rem]" aria-hidden="true" />
|
|
140
|
+
) : null}
|
|
141
|
+
<span className="min-w-0 sm:pr-16">
|
|
142
|
+
<span className="block text-base font-medium text-foreground sm:text-lg">
|
|
143
|
+
{post.title}
|
|
144
|
+
</span>
|
|
145
|
+
<span className="mt-1 line-clamp-2 break-words text-sm leading-relaxed text-muted-foreground">
|
|
146
|
+
{post.description}
|
|
147
|
+
</span>
|
|
148
|
+
<span
|
|
149
|
+
className="mt-3 flex flex-wrap items-center gap-2 text-xs text-muted-foreground"
|
|
150
|
+
role="group"
|
|
151
|
+
aria-label="Post metadata"
|
|
152
|
+
>
|
|
153
|
+
<PostTypeBadge type={post.type} />
|
|
154
|
+
{draft ? (
|
|
155
|
+
<Badge variant="outline">Draft</Badge>
|
|
156
|
+
) : (
|
|
157
|
+
<PostStatusBadge status={post.status} />
|
|
158
|
+
)}
|
|
159
|
+
</span>
|
|
160
|
+
</span>
|
|
161
|
+
</article>
|
|
162
|
+
</PostLink>
|
|
163
|
+
</li>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyPublicCachePolicy,
|
|
5
|
+
canonicalPublicRequest,
|
|
6
|
+
} from "./public-cache-policy";
|
|
7
|
+
|
|
8
|
+
describe("public portal cache policy", () => {
|
|
9
|
+
it("marks only successful public projections with the agreed browser and edge freshness", () => {
|
|
10
|
+
const response = applyPublicCachePolicy(
|
|
11
|
+
new Request("https://feedback.example.com/roadmap"),
|
|
12
|
+
Response.json({ lanes: [] }),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
expect(response.headers.get("cache-control")).toBe("public, max-age=30");
|
|
16
|
+
expect(response.headers.get("cloudflare-cdn-cache-control")).toBe(
|
|
17
|
+
"public, max-age=120, stale-while-revalidate=600, stale-if-error=86400",
|
|
18
|
+
);
|
|
19
|
+
expect(response.headers.get("cache-tag")).toBe(
|
|
20
|
+
"feedbax-public,feedbax-roadmap",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("caches Changelog briefly without stale reuse of expiring image URLs", () => {
|
|
25
|
+
const response = applyPublicCachePolicy(
|
|
26
|
+
new Request("https://feedback.example.com/changelog"),
|
|
27
|
+
new Response("timeline"),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(response.headers.get("cache-control")).toBe("public, max-age=30");
|
|
31
|
+
expect(response.headers.get("cloudflare-cdn-cache-control")).toBe(
|
|
32
|
+
"public, max-age=30",
|
|
33
|
+
);
|
|
34
|
+
expect(response.headers.get("cloudflare-cdn-cache-control")).not.toContain(
|
|
35
|
+
"stale-if-error",
|
|
36
|
+
);
|
|
37
|
+
expect(response.headers.get("cache-tag")).toBe(
|
|
38
|
+
"feedbax-public,feedbax-changelog",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps mutations, private pages, and failed public reads out of the cache", () => {
|
|
43
|
+
const cases = [
|
|
44
|
+
[
|
|
45
|
+
new Request("https://feedback.example.com/submit"),
|
|
46
|
+
new Response("form"),
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
new Request("https://feedback.example.com/roadmap", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
}),
|
|
52
|
+
new Response("updated"),
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
new Request("https://feedback.example.com/roadmap"),
|
|
56
|
+
new Response("unavailable", { status: 503 }),
|
|
57
|
+
],
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
for (const [request, source] of cases) {
|
|
61
|
+
const response = applyPublicCachePolicy(request, source);
|
|
62
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
63
|
+
expect(response.headers.has("cache-tag")).toBe(false);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("canonicalizes equivalent feedback filters before the cache sees the public projection", () => {
|
|
68
|
+
const first = canonicalPublicRequest(
|
|
69
|
+
new Request(
|
|
70
|
+
"https://feedback.example.com/?status=New&type=Bug%20Report&ignored=value",
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
const second = canonicalPublicRequest(
|
|
74
|
+
new Request("https://feedback.example.com/?type=Bug%20Report&status=New"),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(first?.url).toBe(
|
|
78
|
+
"https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=2",
|
|
79
|
+
);
|
|
80
|
+
expect(second?.url).toBe(first?.url);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not canonicalize non-public routes", () => {
|
|
84
|
+
const request = new Request(
|
|
85
|
+
"https://feedback.example.com/submit?status=New&type=Bug%20Report",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(canonicalPublicRequest(request)).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("versions detail and roadmap identities with their fixed projection policy", () => {
|
|
92
|
+
expect(
|
|
93
|
+
canonicalPublicRequest(
|
|
94
|
+
new Request("https://feedback.example.com/roadmap"),
|
|
95
|
+
)?.url,
|
|
96
|
+
).toBe(
|
|
97
|
+
"https://feedback.example.com/roadmap?sort=updated-at-desc&schema=2",
|
|
98
|
+
);
|
|
99
|
+
expect(
|
|
100
|
+
canonicalPublicRequest(new Request("https://feedback.example.com/p/slug"))
|
|
101
|
+
?.url,
|
|
102
|
+
).toBe("https://feedback.example.com/p/slug?schema=2");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("feed URL normalization", () => {
|
|
107
|
+
it("preserves New ordering instead of redirecting back to Top", () => {
|
|
108
|
+
expect(
|
|
109
|
+
canonicalPublicRequest(
|
|
110
|
+
new Request(
|
|
111
|
+
"https://feedback.example.com/?pageSize=25&sort=new&schema=2",
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
it("uses New ordering with voting disabled and reaches a stable canonical URL", () => {
|
|
117
|
+
const features = { voting: false, comments: true, changelog: true };
|
|
118
|
+
const first = canonicalPublicRequest(
|
|
119
|
+
new Request("https://feedback.example.com/"),
|
|
120
|
+
features,
|
|
121
|
+
);
|
|
122
|
+
expect(new URL(first!.url).searchParams.get("sort")).toBe("new");
|
|
123
|
+
expect(canonicalPublicRequest(first!, features)).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
it("preserves URL search and multiple Type/Status filters", () => {
|
|
126
|
+
const first = canonicalPublicRequest(
|
|
127
|
+
new Request(
|
|
128
|
+
"https://feedback.example.com/?types=%5B%22Bug%20Report%22,%22Feature%20Request%22%5D&statuses=%5B%22Planned%22%5D&search=keyboard&sort=new",
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
const url = new URL(first!.url);
|
|
132
|
+
expect(JSON.parse(url.searchParams.get("types")!)).toEqual([
|
|
133
|
+
"Bug Report",
|
|
134
|
+
"Feature Request",
|
|
135
|
+
]);
|
|
136
|
+
expect(JSON.parse(url.searchParams.get("statuses")!)).toEqual(["Planned"]);
|
|
137
|
+
expect(url.searchParams.get("search")).toBe("keyboard");
|
|
138
|
+
expect(url.searchParams.get("sort")).toBe("new");
|
|
139
|
+
expect(canonicalPublicRequest(first!)).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("moves pre-0.2.1 cached projections to a new schema identity", () => {
|
|
144
|
+
const request = canonicalPublicRequest(
|
|
145
|
+
new Request("https://feedback.example.com/?pageSize=25&sort=top&schema=1"),
|
|
146
|
+
);
|
|
147
|
+
expect(new URL(request!.url).searchParams.get("schema")).toBe("2");
|
|
148
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultParseSearch,
|
|
3
|
+
defaultStringifySearch,
|
|
4
|
+
} from "@tanstack/react-router";
|
|
5
|
+
import type { PortalFeatures } from "@feedbax/config";
|
|
6
|
+
import { publicPostSearch } from "./public-feedback-page";
|
|
7
|
+
import {
|
|
8
|
+
publicChangelogCacheTag,
|
|
9
|
+
publicFeedbackCacheTag,
|
|
10
|
+
publicPostCacheTag,
|
|
11
|
+
publicRoadmapCacheTag,
|
|
12
|
+
} from "./public-cache-tags";
|
|
13
|
+
|
|
14
|
+
const BROWSER_CACHE_CONTROL = "public, max-age=30";
|
|
15
|
+
const EDGE_CACHE_CONTROL =
|
|
16
|
+
"public, max-age=120, stale-while-revalidate=600, stale-if-error=86400";
|
|
17
|
+
const CHANGELOG_EDGE_CACHE_CONTROL = "public, max-age=30";
|
|
18
|
+
const PUBLIC_PROJECTION_SCHEMA_VERSION = "2";
|
|
19
|
+
const PUBLIC_FEEDBACK_PAGE_SIZE = "25";
|
|
20
|
+
const PUBLIC_ROADMAP_SORT = "updated-at-desc";
|
|
21
|
+
|
|
22
|
+
export function canonicalPublicRequest(
|
|
23
|
+
request: Request,
|
|
24
|
+
features?: PortalFeatures,
|
|
25
|
+
): Request | undefined {
|
|
26
|
+
if (request.method !== "GET") return undefined;
|
|
27
|
+
const source = new URL(request.url);
|
|
28
|
+
if (!isPublicPath(source.pathname)) return undefined;
|
|
29
|
+
|
|
30
|
+
const canonical = new URL(source.origin + source.pathname);
|
|
31
|
+
if (source.pathname === "/") {
|
|
32
|
+
const parsed: Record<string, unknown> = defaultParseSearch(source.search);
|
|
33
|
+
const query = publicPostSearch(
|
|
34
|
+
{
|
|
35
|
+
...parsed,
|
|
36
|
+
types: parsed.types ?? parsed.type,
|
|
37
|
+
statuses: parsed.statuses ?? parsed.status,
|
|
38
|
+
},
|
|
39
|
+
features,
|
|
40
|
+
);
|
|
41
|
+
const { cursor, search, types, statuses, sort } = query;
|
|
42
|
+
canonical.search = defaultStringifySearch({
|
|
43
|
+
...(cursor ? { cursor } : {}),
|
|
44
|
+
...(search ? { search } : {}),
|
|
45
|
+
...(types?.length ? { types: [...types].sort() } : {}),
|
|
46
|
+
...(statuses?.length ? { statuses: [...statuses].sort() } : {}),
|
|
47
|
+
pageSize: Number(PUBLIC_FEEDBACK_PAGE_SIZE),
|
|
48
|
+
sort,
|
|
49
|
+
schema: Number(PUBLIC_PROJECTION_SCHEMA_VERSION),
|
|
50
|
+
});
|
|
51
|
+
} else if (source.pathname === "/roadmap") {
|
|
52
|
+
canonical.searchParams.set("sort", PUBLIC_ROADMAP_SORT);
|
|
53
|
+
}
|
|
54
|
+
canonical.searchParams.set("schema", PUBLIC_PROJECTION_SCHEMA_VERSION);
|
|
55
|
+
|
|
56
|
+
if (canonical.href === source.href) return undefined;
|
|
57
|
+
return new Request(canonical, request);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function applyPublicCachePolicy(
|
|
61
|
+
request: Request,
|
|
62
|
+
source: Response,
|
|
63
|
+
): Response {
|
|
64
|
+
const response = new Response(source.body, source);
|
|
65
|
+
if (!isSafePublicProjection(request, response)) {
|
|
66
|
+
response.headers.set("Cache-Control", "no-store");
|
|
67
|
+
response.headers.delete("Cloudflare-CDN-Cache-Control");
|
|
68
|
+
response.headers.delete("Cache-Tag");
|
|
69
|
+
return response;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
response.headers.set("Cache-Control", BROWSER_CACHE_CONTROL);
|
|
73
|
+
response.headers.set(
|
|
74
|
+
"Cloudflare-CDN-Cache-Control",
|
|
75
|
+
new URL(request.url).pathname === "/changelog"
|
|
76
|
+
? CHANGELOG_EDGE_CACHE_CONTROL
|
|
77
|
+
: EDGE_CACHE_CONTROL,
|
|
78
|
+
);
|
|
79
|
+
response.headers.set("Cache-Tag", publicCacheTags(new URL(request.url)));
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isSafePublicProjection(request: Request, response: Response): boolean {
|
|
84
|
+
if (request.method !== "GET" || response.status !== 200) return false;
|
|
85
|
+
if (response.headers.has("Set-Cookie")) return false;
|
|
86
|
+
const { pathname } = new URL(request.url);
|
|
87
|
+
return isPublicPath(pathname);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPublicPath(pathname: string): boolean {
|
|
91
|
+
return (
|
|
92
|
+
pathname === "/" ||
|
|
93
|
+
pathname === "/roadmap" ||
|
|
94
|
+
pathname === "/changelog" ||
|
|
95
|
+
/^\/p\/[^/]+$/.test(pathname)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function publicCacheTags(url: URL): string {
|
|
100
|
+
if (url.pathname === "/changelog") {
|
|
101
|
+
return `feedbax-public,${publicChangelogCacheTag}`;
|
|
102
|
+
}
|
|
103
|
+
if (url.pathname === "/roadmap") {
|
|
104
|
+
return `feedbax-public,${publicRoadmapCacheTag}`;
|
|
105
|
+
}
|
|
106
|
+
const slug = /^\/p\/([^/]+)$/.exec(url.pathname)?.[1];
|
|
107
|
+
if (slug) {
|
|
108
|
+
return `feedbax-public,${publicFeedbackCacheTag},${publicPostCacheTag(decodePathSegment(slug))}`;
|
|
109
|
+
}
|
|
110
|
+
return `feedbax-public,${publicFeedbackCacheTag}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function decodePathSegment(value: string): string {
|
|
114
|
+
try {
|
|
115
|
+
return decodeURIComponent(value);
|
|
116
|
+
} catch {
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const publicFeedbackCacheTag = "feedbax-feedback";
|
|
2
|
+
export const publicRoadmapCacheTag = "feedbax-roadmap";
|
|
3
|
+
export const publicChangelogCacheTag = "feedbax-changelog";
|
|
4
|
+
|
|
5
|
+
export function publicPostCacheTag(slug: string): string {
|
|
6
|
+
return `feedbax-post-${slug.replace(/[^A-Za-z0-9_.:-]/g, "-")}`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangelogModule,
|
|
3
|
+
ChangelogPage,
|
|
4
|
+
ChangelogQuery,
|
|
5
|
+
} from "@feedbax/changelog";
|
|
6
|
+
|
|
7
|
+
export function loadPublicChangelogPage({
|
|
8
|
+
changelog,
|
|
9
|
+
query = {},
|
|
10
|
+
}: {
|
|
11
|
+
changelog: Pick<ChangelogModule, "listPublishedEntries">;
|
|
12
|
+
query?: ChangelogQuery;
|
|
13
|
+
}): Promise<ChangelogPage> {
|
|
14
|
+
return changelog.listPublishedEntries(query);
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { loadPublicChangelogPage } from "./public-changelog-page";
|
|
3
|
+
import { runSafePublicRead } from "./safe-public-failure";
|
|
4
|
+
|
|
5
|
+
export const getPublicChangelogPage = createServerFn({ method: "GET" })
|
|
6
|
+
.validator((input: unknown) => {
|
|
7
|
+
if (input === undefined) return {};
|
|
8
|
+
if (!input || typeof input !== "object")
|
|
9
|
+
throw new Error("Changelog request is invalid.");
|
|
10
|
+
const cursor = Reflect.get(input, "cursor");
|
|
11
|
+
const label = Reflect.get(input, "label");
|
|
12
|
+
if (cursor !== undefined && typeof cursor !== "string")
|
|
13
|
+
throw new Error("Changelog request is invalid.");
|
|
14
|
+
if (label !== undefined && typeof label !== "string")
|
|
15
|
+
throw new Error("Changelog request is invalid.");
|
|
16
|
+
return { ...(cursor ? { cursor } : {}), ...(label ? { label } : {}) };
|
|
17
|
+
})
|
|
18
|
+
.handler(({ data }) =>
|
|
19
|
+
runSafePublicRead(async () => {
|
|
20
|
+
const { createConfiguredChangelogModule } =
|
|
21
|
+
await import("./changelog-runtime");
|
|
22
|
+
return loadPublicChangelogPage({
|
|
23
|
+
changelog: createConfiguredChangelogModule(),
|
|
24
|
+
query: data,
|
|
25
|
+
});
|
|
26
|
+
}),
|
|
27
|
+
);
|