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,134 @@
|
|
|
1
|
+
import { isValidCommentEmail } from "./comment-profile";
|
|
2
|
+
|
|
3
|
+
export const capabilitiesKey = "feedbax:post-capabilities";
|
|
4
|
+
export const deviceProfileKey = "feedbax:device-profile";
|
|
5
|
+
export interface DeviceProfile {
|
|
6
|
+
name: string;
|
|
7
|
+
email?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CompleteDeviceProfile extends DeviceProfile {
|
|
11
|
+
email: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type DeviceProfileStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
15
|
+
export interface StoredPostCapability {
|
|
16
|
+
id: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
browserCapability: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readDeviceProfile(
|
|
22
|
+
storage: Pick<Storage, "getItem">,
|
|
23
|
+
): DeviceProfile | undefined {
|
|
24
|
+
try {
|
|
25
|
+
const value = JSON.parse(
|
|
26
|
+
storage.getItem(deviceProfileKey) ?? "null",
|
|
27
|
+
) as unknown;
|
|
28
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
const entries = Object.keys(value);
|
|
32
|
+
const name = Reflect.get(value, "name");
|
|
33
|
+
const email = Reflect.get(value, "email");
|
|
34
|
+
if (
|
|
35
|
+
entries.some((key) => key !== "name" && key !== "email") ||
|
|
36
|
+
typeof name !== "string" ||
|
|
37
|
+
name.trim().length === 0 ||
|
|
38
|
+
(email !== undefined && typeof email !== "string")
|
|
39
|
+
) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
name: name.trim(),
|
|
44
|
+
...(typeof email === "string" && email.trim()
|
|
45
|
+
? { email: email.trim() }
|
|
46
|
+
: {}),
|
|
47
|
+
};
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function saveDeviceProfile(
|
|
54
|
+
storage: Pick<DeviceProfileStorage, "setItem">,
|
|
55
|
+
profile: DeviceProfile,
|
|
56
|
+
) {
|
|
57
|
+
const name = profile.name.trim();
|
|
58
|
+
if (!name) throw new Error("Device Profile display name is required.");
|
|
59
|
+
const email = profile.email?.trim();
|
|
60
|
+
storage.setItem(
|
|
61
|
+
deviceProfileKey,
|
|
62
|
+
JSON.stringify({ name, ...(email ? { email } : {}) }),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isCompleteDeviceProfile(
|
|
67
|
+
profile: DeviceProfile | undefined,
|
|
68
|
+
): profile is CompleteDeviceProfile {
|
|
69
|
+
return Boolean(profile?.name.trim() && isValidCommentEmail(profile.email));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function clearDeviceProfile(
|
|
73
|
+
storage: Pick<DeviceProfileStorage, "removeItem">,
|
|
74
|
+
) {
|
|
75
|
+
storage.removeItem(deviceProfileKey);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function deriveDeviceProfileInitials(profile: DeviceProfile): string {
|
|
79
|
+
return profile.name
|
|
80
|
+
.trim()
|
|
81
|
+
.split(/\s+/)
|
|
82
|
+
.slice(0, 2)
|
|
83
|
+
.map((part) => part[0]?.toLocaleUpperCase())
|
|
84
|
+
.join("");
|
|
85
|
+
}
|
|
86
|
+
export function readCapabilities(
|
|
87
|
+
storage: Pick<Storage, "getItem">,
|
|
88
|
+
): Record<string, StoredPostCapability> {
|
|
89
|
+
try {
|
|
90
|
+
const value = JSON.parse(
|
|
91
|
+
storage.getItem(capabilitiesKey) ?? "{}",
|
|
92
|
+
) as unknown;
|
|
93
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
94
|
+
return Object.fromEntries(
|
|
95
|
+
Object.entries(value).filter(
|
|
96
|
+
(entry): entry is [string, StoredPostCapability] => {
|
|
97
|
+
const [key, capability] = entry;
|
|
98
|
+
return (
|
|
99
|
+
capability !== null &&
|
|
100
|
+
typeof capability === "object" &&
|
|
101
|
+
!Array.isArray(capability) &&
|
|
102
|
+
Reflect.get(capability, "id") === key &&
|
|
103
|
+
typeof Reflect.get(capability, "slug") === "string" &&
|
|
104
|
+
Reflect.get(capability, "slug").length > 0 &&
|
|
105
|
+
typeof Reflect.get(capability, "browserCapability") === "string" &&
|
|
106
|
+
Reflect.get(capability, "browserCapability").length > 0
|
|
107
|
+
);
|
|
108
|
+
},
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
} catch {
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export function retainCapability(
|
|
116
|
+
storage: Pick<Storage, "getItem" | "setItem">,
|
|
117
|
+
capability: StoredPostCapability,
|
|
118
|
+
) {
|
|
119
|
+
storage.setItem(
|
|
120
|
+
capabilitiesKey,
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
...readCapabilities(storage),
|
|
123
|
+
[capability.id]: capability,
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
export function removeCapability(
|
|
128
|
+
storage: Pick<Storage, "getItem" | "setItem">,
|
|
129
|
+
id: string,
|
|
130
|
+
) {
|
|
131
|
+
const capabilities = readCapabilities(storage);
|
|
132
|
+
delete capabilities[id];
|
|
133
|
+
storage.setItem(capabilitiesKey, JSON.stringify(capabilities));
|
|
134
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { expect, it } from "vitest";
|
|
2
|
+
import { persistVotedPost, readVotedPostSlugs } from "./browser-vote-state";
|
|
3
|
+
|
|
4
|
+
it("remembers and removes voted Post identifiers in browser storage", () => {
|
|
5
|
+
const values = new Map<string, string>();
|
|
6
|
+
const storage = {
|
|
7
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
8
|
+
setItem: (key: string, value: string) => void values.set(key, value),
|
|
9
|
+
};
|
|
10
|
+
persistVotedPost(storage, "keyboard-navigation", true);
|
|
11
|
+
expect(readVotedPostSlugs(storage)).toEqual(new Set(["keyboard-navigation"]));
|
|
12
|
+
persistVotedPost(storage, "keyboard-navigation", false);
|
|
13
|
+
expect(readVotedPostSlugs(storage)).toEqual(new Set());
|
|
14
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const votedPostsKey = "feedbax:voted-posts";
|
|
2
|
+
|
|
3
|
+
export function readVotedPostSlugs(
|
|
4
|
+
storage: Pick<Storage, "getItem">,
|
|
5
|
+
): Set<string> {
|
|
6
|
+
try {
|
|
7
|
+
const value: unknown = JSON.parse(storage.getItem(votedPostsKey) ?? "[]");
|
|
8
|
+
return new Set(
|
|
9
|
+
Array.isArray(value)
|
|
10
|
+
? value.filter((item): item is string => typeof item === "string")
|
|
11
|
+
: [],
|
|
12
|
+
);
|
|
13
|
+
} catch {
|
|
14
|
+
return new Set();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function persistVotedPost(
|
|
19
|
+
storage: Pick<Storage, "getItem" | "setItem">,
|
|
20
|
+
slug: string,
|
|
21
|
+
voted: boolean,
|
|
22
|
+
): void {
|
|
23
|
+
const slugs = readVotedPostSlugs(storage);
|
|
24
|
+
if (voted) slugs.add(slug);
|
|
25
|
+
else slugs.delete(slug);
|
|
26
|
+
storage.setItem(votedPostsKey, JSON.stringify([...slugs]));
|
|
27
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import type { ChangelogPage as PageData } from "@feedbax/changelog";
|
|
3
|
+
import {
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from "@testing-library/react";
|
|
10
|
+
import {
|
|
11
|
+
Outlet,
|
|
12
|
+
RouterProvider,
|
|
13
|
+
createMemoryHistory,
|
|
14
|
+
createRootRoute,
|
|
15
|
+
createRoute,
|
|
16
|
+
createRouter,
|
|
17
|
+
} from "@tanstack/react-router";
|
|
18
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
19
|
+
import { ChangelogPage } from "./changelog-page";
|
|
20
|
+
import { CommandPaletteProvider } from "./components/command-palette";
|
|
21
|
+
|
|
22
|
+
afterEach(cleanup);
|
|
23
|
+
|
|
24
|
+
describe("Changelog page", () => {
|
|
25
|
+
it("renders loader data, filters by Label, and loads the next cursor", async () => {
|
|
26
|
+
const loadPage = vi.fn().mockImplementation(async ({ cursor, label }) => {
|
|
27
|
+
if (label === "Fixed") return page([entry("fixed", "Fixed")]);
|
|
28
|
+
if (cursor === "next") return page([entry("older", "Improved")]);
|
|
29
|
+
return page([]);
|
|
30
|
+
});
|
|
31
|
+
renderChangelog({
|
|
32
|
+
initialPage: page([entry("new", "Improved")], "next"),
|
|
33
|
+
loadPage,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(
|
|
37
|
+
await screen.findByRole("heading", { level: 2, name: "new" }),
|
|
38
|
+
).toBeTruthy();
|
|
39
|
+
fireEvent.click(screen.getByRole("button", { name: "Load more updates" }));
|
|
40
|
+
expect(
|
|
41
|
+
await screen.findByRole("heading", { level: 2, name: "older" }),
|
|
42
|
+
).toBeTruthy();
|
|
43
|
+
expect(loadPage).toHaveBeenCalledWith({ cursor: "next" });
|
|
44
|
+
|
|
45
|
+
fireEvent.click(screen.getByRole("button", { name: "Improved" }));
|
|
46
|
+
await waitFor(() =>
|
|
47
|
+
expect(loadPage).toHaveBeenCalledWith({ label: "Improved" }),
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("preserves hash links and renders rich-text properties without page-body reads", async () => {
|
|
52
|
+
const loadPage = vi
|
|
53
|
+
.fn()
|
|
54
|
+
.mockResolvedValue(page([entry("anchored", "Fixed")]));
|
|
55
|
+
const { container } = renderChangelog({
|
|
56
|
+
initialPage: page([entry("first", "Improved")], "next"),
|
|
57
|
+
loadPage,
|
|
58
|
+
initialEntry: "/changelog#anchored",
|
|
59
|
+
});
|
|
60
|
+
expect(await screen.findByText("Body anchored")).toBeTruthy();
|
|
61
|
+
expect(loadPage).toHaveBeenCalledWith({ cursor: "next" });
|
|
62
|
+
expect(
|
|
63
|
+
screen
|
|
64
|
+
.getByRole("link", { name: "Link to anchored" })
|
|
65
|
+
.getAttribute("href"),
|
|
66
|
+
).toBe("#anchored");
|
|
67
|
+
expect(container.querySelector("article#anchored")).toBeTruthy();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("renders an accessible image and preserves text when an image is absent or expired", async () => {
|
|
71
|
+
const valid = entry("illustrated", "Improved");
|
|
72
|
+
valid.image = {
|
|
73
|
+
src: "https://files.notion.example/illustrated.png",
|
|
74
|
+
alt: "Illustrated image",
|
|
75
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
76
|
+
};
|
|
77
|
+
const expired = entry("expired", "Fixed");
|
|
78
|
+
expired.image = {
|
|
79
|
+
src: "https://files.notion.example/expired.png",
|
|
80
|
+
alt: "Expired image",
|
|
81
|
+
expiresAt: new Date(Date.now() - 1),
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
renderChangelog({ initialPage: page([valid, expired]), loadPage: vi.fn() });
|
|
85
|
+
|
|
86
|
+
expect(
|
|
87
|
+
await screen.findByRole("img", { name: "Illustrated image" }),
|
|
88
|
+
).toBeTruthy();
|
|
89
|
+
expect(screen.queryByRole("img", { name: "Expired image" })).toBeNull();
|
|
90
|
+
expect(screen.getByText("Body expired")).toBeTruthy();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
function renderChangelog({
|
|
95
|
+
initialPage,
|
|
96
|
+
loadPage,
|
|
97
|
+
initialEntry = "/changelog",
|
|
98
|
+
}: {
|
|
99
|
+
initialPage: PageData;
|
|
100
|
+
loadPage: (input: { cursor?: string; label?: string }) => Promise<PageData>;
|
|
101
|
+
initialEntry?: string;
|
|
102
|
+
}) {
|
|
103
|
+
const history = createMemoryHistory({ initialEntries: [initialEntry] });
|
|
104
|
+
const root = createRootRoute({
|
|
105
|
+
component: () => (
|
|
106
|
+
<CommandPaletteProvider>
|
|
107
|
+
<Outlet />
|
|
108
|
+
</CommandPaletteProvider>
|
|
109
|
+
),
|
|
110
|
+
});
|
|
111
|
+
const changelog = createRoute({
|
|
112
|
+
getParentRoute: () => root,
|
|
113
|
+
path: "/changelog",
|
|
114
|
+
component: () => (
|
|
115
|
+
<ChangelogPage initialPage={initialPage} loadPage={loadPage} />
|
|
116
|
+
),
|
|
117
|
+
});
|
|
118
|
+
const router = createRouter({
|
|
119
|
+
history,
|
|
120
|
+
routeTree: root.addChildren([changelog]),
|
|
121
|
+
});
|
|
122
|
+
return render(<RouterProvider router={router} />);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function page(items: PageData["items"], nextCursor?: string): PageData {
|
|
126
|
+
return { items, ...(nextCursor ? { nextCursor } : {}) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function entry(slug: string, label: string): PageData["items"][number] {
|
|
130
|
+
return {
|
|
131
|
+
slug,
|
|
132
|
+
date: "2026-08-01",
|
|
133
|
+
title: slug,
|
|
134
|
+
summary: `Summary ${slug}`,
|
|
135
|
+
body: `Body ${slug}`,
|
|
136
|
+
labels: [label],
|
|
137
|
+
updatedAt: new Date("2026-08-01T00:00:00Z"),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangelogPage as PageData,
|
|
3
|
+
PublicChangelogEntry,
|
|
4
|
+
} from "@feedbax/changelog";
|
|
5
|
+
import { Badge } from "@feedbax/ui/components/badge";
|
|
6
|
+
import { Button, buttonVariants } from "@feedbax/ui/components/button";
|
|
7
|
+
import { ButtonGroup } from "@feedbax/ui/components/button-group";
|
|
8
|
+
import { useRouterState } from "@tanstack/react-router";
|
|
9
|
+
import { ChevronDown, Link2 } from "lucide-react";
|
|
10
|
+
import { useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { CommandPaletteTrigger } from "./components/command-palette-context";
|
|
12
|
+
|
|
13
|
+
export type LoadChangelogPage = (input: {
|
|
14
|
+
cursor?: string;
|
|
15
|
+
label?: string;
|
|
16
|
+
}) => Promise<PageData>;
|
|
17
|
+
|
|
18
|
+
const emptyPage: PageData = { items: [] };
|
|
19
|
+
|
|
20
|
+
export function ChangelogPage({
|
|
21
|
+
initialPage = emptyPage,
|
|
22
|
+
loadPage = async () => emptyPage,
|
|
23
|
+
}: { initialPage?: PageData; loadPage?: LoadChangelogPage } = {}) {
|
|
24
|
+
const locationHash = useRouterState({
|
|
25
|
+
select: (state) => state.location.hash,
|
|
26
|
+
});
|
|
27
|
+
const [selectedLabel, setSelectedLabel] = useState<string>();
|
|
28
|
+
const [page, setPage] = useState(initialPage);
|
|
29
|
+
const [loading, setLoading] = useState(false);
|
|
30
|
+
const labels = useMemo(
|
|
31
|
+
() => [...new Set(initialPage.items.flatMap((entry) => entry.labels))],
|
|
32
|
+
[initialPage.items],
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
async function replaceForLabel(label?: string) {
|
|
36
|
+
setSelectedLabel(label);
|
|
37
|
+
setLoading(true);
|
|
38
|
+
try {
|
|
39
|
+
setPage(await loadPage(label ? { label } : {}));
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadMore() {
|
|
46
|
+
if (!page.nextCursor) return;
|
|
47
|
+
setLoading(true);
|
|
48
|
+
try {
|
|
49
|
+
const next = await loadPage({
|
|
50
|
+
cursor: page.nextCursor,
|
|
51
|
+
...(selectedLabel ? { label: selectedLabel } : {}),
|
|
52
|
+
});
|
|
53
|
+
setPage({
|
|
54
|
+
items: [...page.items, ...next.items],
|
|
55
|
+
...(next.nextCursor ? { nextCursor: next.nextCursor } : {}),
|
|
56
|
+
});
|
|
57
|
+
} finally {
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const slug = locationHash.startsWith("#")
|
|
64
|
+
? locationHash.slice(1)
|
|
65
|
+
: locationHash;
|
|
66
|
+
if (!slug) return;
|
|
67
|
+
const target = document.getElementById(slug);
|
|
68
|
+
if (target) {
|
|
69
|
+
target.scrollIntoView?.({ block: "start" });
|
|
70
|
+
} else if (page.nextCursor && !loading && !selectedLabel) {
|
|
71
|
+
void loadMore();
|
|
72
|
+
}
|
|
73
|
+
}, [
|
|
74
|
+
locationHash,
|
|
75
|
+
loading,
|
|
76
|
+
page.items.length,
|
|
77
|
+
page.nextCursor,
|
|
78
|
+
selectedLabel,
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
return (
|
|
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">
|
|
84
|
+
<div>
|
|
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>
|
|
89
|
+
</div>
|
|
90
|
+
<CommandPaletteTrigger className="h-10" />
|
|
91
|
+
</header>
|
|
92
|
+
{labels.length ? (
|
|
93
|
+
<ButtonGroup className="mt-6 flex-wrap" aria-label="Filter updates">
|
|
94
|
+
{[undefined, ...labels].map((label) => (
|
|
95
|
+
<Button
|
|
96
|
+
key={label ?? "all"}
|
|
97
|
+
type="button"
|
|
98
|
+
size="sm"
|
|
99
|
+
variant={selectedLabel === label ? "secondary" : "outline"}
|
|
100
|
+
aria-pressed={selectedLabel === label}
|
|
101
|
+
disabled={loading}
|
|
102
|
+
onClick={() => void replaceForLabel(label)}
|
|
103
|
+
>
|
|
104
|
+
{label ?? "All"}
|
|
105
|
+
</Button>
|
|
106
|
+
))}
|
|
107
|
+
</ButtonGroup>
|
|
108
|
+
) : null}
|
|
109
|
+
<ol
|
|
110
|
+
className="changelog-timeline"
|
|
111
|
+
aria-label="Product updates"
|
|
112
|
+
aria-busy={loading}
|
|
113
|
+
>
|
|
114
|
+
{page.items.map((entry) => (
|
|
115
|
+
<TimelineEntry entry={entry} key={entry.slug} />
|
|
116
|
+
))}
|
|
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}
|
|
123
|
+
{page.nextCursor ? (
|
|
124
|
+
<div className="mx-auto mt-8 flex max-w-5xl justify-center">
|
|
125
|
+
<Button
|
|
126
|
+
type="button"
|
|
127
|
+
variant="outline"
|
|
128
|
+
disabled={loading}
|
|
129
|
+
onClick={() => void loadMore()}
|
|
130
|
+
>
|
|
131
|
+
{loading ? "Loading updates…" : "Load more updates"}
|
|
132
|
+
<ChevronDown aria-hidden="true" />
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
) : null}
|
|
136
|
+
</main>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function TimelineEntry({ entry }: { entry: PublicChangelogEntry }) {
|
|
141
|
+
const image = useUnexpiredImage(entry.image);
|
|
142
|
+
return (
|
|
143
|
+
<li className="changelog-timeline-entry">
|
|
144
|
+
<time dateTime={entry.date}>{formatDate(entry.date)}</time>
|
|
145
|
+
<span className="changelog-timeline-marker" aria-hidden="true" />
|
|
146
|
+
<article id={entry.slug} tabIndex={-1}>
|
|
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
|
+
))}
|
|
154
|
+
</div>
|
|
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
|
+
))}
|
|
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}
|
|
187
|
+
</article>
|
|
188
|
+
</li>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function useUnexpiredImage(image: PublicChangelogEntry["image"]) {
|
|
193
|
+
const expiry = image?.expiresAt
|
|
194
|
+
? new Date(image.expiresAt).getTime()
|
|
195
|
+
: undefined;
|
|
196
|
+
const [available, setAvailable] = useState(
|
|
197
|
+
() => expiry === undefined || expiry > Date.now(),
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (!image || expiry === undefined) {
|
|
202
|
+
setAvailable(Boolean(image));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const remaining = expiry - Date.now();
|
|
206
|
+
if (remaining <= 0) {
|
|
207
|
+
setAvailable(false);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
setAvailable(true);
|
|
211
|
+
const timeout = window.setTimeout(() => setAvailable(false), remaining);
|
|
212
|
+
return () => window.clearTimeout(timeout);
|
|
213
|
+
}, [expiry, image]);
|
|
214
|
+
|
|
215
|
+
return available && (expiry === undefined || expiry > Date.now())
|
|
216
|
+
? image
|
|
217
|
+
: undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function formatDate(date: string) {
|
|
221
|
+
const [year, month, day] = date.split("-").map(Number);
|
|
222
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
223
|
+
day: "numeric",
|
|
224
|
+
month: "short",
|
|
225
|
+
year: "numeric",
|
|
226
|
+
timeZone: "UTC",
|
|
227
|
+
}).format(new Date(Date.UTC(year, month - 1, day)));
|
|
228
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ChangelogPage } from "@feedbax/changelog";
|
|
2
|
+
import { queryOptions } from "@tanstack/react-query";
|
|
3
|
+
|
|
4
|
+
export type FetchChangelogPage = (input?: {
|
|
5
|
+
data?: { cursor?: string; label?: string };
|
|
6
|
+
}) => Promise<ChangelogPage>;
|
|
7
|
+
|
|
8
|
+
export function changelogQuery(fetchPage: FetchChangelogPage) {
|
|
9
|
+
return queryOptions({
|
|
10
|
+
queryKey: ["public-changelog"],
|
|
11
|
+
queryFn: () => fetchPage(),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createChangelogModule,
|
|
3
|
+
createNotionChangelogStorage,
|
|
4
|
+
} from "@feedbax/changelog";
|
|
5
|
+
|
|
6
|
+
import feedbax from "./feedbax";
|
|
7
|
+
import { requiredEnvironmentValue } from "./feedback-runtime";
|
|
8
|
+
|
|
9
|
+
let configuredChangelog: ReturnType<typeof createChangelogModule> | undefined;
|
|
10
|
+
|
|
11
|
+
export function createConfiguredChangelogModule() {
|
|
12
|
+
configuredChangelog ??= buildConfiguredChangelogModule();
|
|
13
|
+
return configuredChangelog;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildConfiguredChangelogModule() {
|
|
17
|
+
if (!feedbax.changelog) {
|
|
18
|
+
throw new Error("Changelog configuration is missing.");
|
|
19
|
+
}
|
|
20
|
+
return createChangelogModule({
|
|
21
|
+
storage: createNotionChangelogStorage({
|
|
22
|
+
token: requiredEnvironmentValue("NOTION_TOKEN"),
|
|
23
|
+
dataSourceId: feedbax.changelog.dataSourceId,
|
|
24
|
+
propertyIds: feedbax.changelog.properties,
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface TurnstileVerifier {
|
|
2
|
+
verify(options: { token: string; remoteIp?: string }): Promise<boolean>;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function createCloudflareTurnstileVerifier({
|
|
6
|
+
secretKey,
|
|
7
|
+
request = fetch,
|
|
8
|
+
}: {
|
|
9
|
+
secretKey: string;
|
|
10
|
+
request?: typeof fetch;
|
|
11
|
+
}): TurnstileVerifier {
|
|
12
|
+
return {
|
|
13
|
+
async verify({ token, remoteIp }) {
|
|
14
|
+
try {
|
|
15
|
+
const body = new URLSearchParams({
|
|
16
|
+
secret: secretKey,
|
|
17
|
+
response: token,
|
|
18
|
+
...(remoteIp ? { remoteip: remoteIp } : {}),
|
|
19
|
+
});
|
|
20
|
+
const response = await request(
|
|
21
|
+
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
22
|
+
{ method: "POST", body },
|
|
23
|
+
);
|
|
24
|
+
const result = (await response.json()) as { success?: unknown };
|
|
25
|
+
return response.ok && result.success === true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
declare module "cloudflare:workers" {
|
|
2
|
+
export const cache: {
|
|
3
|
+
purge(options: { tags: string[] }): Promise<{
|
|
4
|
+
success: boolean;
|
|
5
|
+
errors: Array<{ code: number; message: string }>;
|
|
6
|
+
}>;
|
|
7
|
+
};
|
|
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;
|
|
15
|
+
TRUSTED_SUBMISSIONS_RATE_LIMITER: {
|
|
16
|
+
limit(options: { key: string }): Promise<{ success: boolean }>;
|
|
17
|
+
};
|
|
18
|
+
PORTAL_SUBMISSIONS_RATE_LIMITER: {
|
|
19
|
+
limit(options: { key: string }): Promise<{ success: boolean }>;
|
|
20
|
+
};
|
|
21
|
+
TURNSTILE_SECRET_KEY?: string;
|
|
22
|
+
PARTICIPATION_SIGNING_SECRET?: string;
|
|
23
|
+
};
|
|
24
|
+
}
|