create-feedbax 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -5
- package/dist/cli.js +2610 -291
- package/dist/index.js +2375 -200
- package/package.json +7 -7
- package/template/.feedbax-template.sha256 +1 -1
- package/template/.gitignore.template +1 -0
- package/template/.npmignore.template +1 -0
- package/template/.nvmrc +1 -0
- package/template/AGENTS.md +13 -0
- package/template/CONNECTED.md +418 -0
- package/template/README.md +97 -0
- package/template/components.json +1 -1
- package/template/eslint.config.mjs +23 -0
- package/template/feedbax-vite.ts +79 -0
- package/template/migrations/0001_connected.sql +6 -0
- package/template/migrations/0002_participation.sql +8 -0
- package/template/migrations/0003_images.sql +3 -0
- package/template/migrations/0004_followed_status.sql +9 -0
- package/template/migrations/0005_github.sql +4 -0
- package/template/migrations/0006_integration.sql +3 -0
- package/template/migrations/0007_ownership.sql +2 -0
- package/template/migrations/0008_integration_recovery.sql +5 -0
- package/template/migrations/0009_observed_history.sql +3 -0
- package/template/package.json +39 -34
- package/template/src/authorized-draft-post.tsx +1 -0
- package/template/src/changelog-page.tsx +63 -54
- package/template/src/changelog-runtime.ts +2 -1
- package/template/src/cloudflare-workers.d.ts +9 -0
- package/template/src/comment-creation.test.tsx +95 -7
- package/template/src/comment-thread-page.ts +11 -1
- package/template/src/components/command-palette.tsx +59 -30
- package/template/src/components/connected-identity-provider.tsx +135 -0
- package/template/src/components/device-profile-provider.tsx +41 -26
- package/template/src/components/follow-post-button.tsx +78 -0
- package/template/src/components/header.tsx +2 -0
- package/template/src/components/image-attachments.tsx +187 -0
- package/template/src/components/loader.tsx +48 -5
- package/template/src/components/notification-inbox.tsx +232 -0
- package/template/src/components/portal-footer.tsx +14 -0
- package/template/src/components/post-history.tsx +101 -0
- package/template/src/components/profile-menu.tsx +37 -0
- package/template/src/components/ui/components/command.tsx +30 -29
- package/template/src/components/ui/components/message-scroller.tsx +2 -2
- package/template/src/components/ui/components/scroll-area.tsx +0 -1
- package/template/src/components/ui/components/switch.tsx +29 -0
- package/template/src/components/ui/styles/globals.css +1 -1
- package/template/src/configured-feedback.test.ts +82 -0
- package/template/src/configured-feedback.ts +61 -0
- package/template/src/connected-identity.test.ts +148 -0
- package/template/src/connected-identity.ts +127 -0
- package/template/src/connected-participation.test.tsx +67 -0
- package/template/src/connected-runtime.test.ts +244 -0
- package/template/src/connected-runtime.ts +176 -0
- package/template/src/connected-social.test.tsx +158 -0
- package/template/src/feedback-cache-invalidation.ts +3 -0
- package/template/src/feedback-runtime.ts +123 -13
- package/template/src/feedbax/changelog/index.test.ts +35 -0
- package/template/src/feedbax/changelog/index.ts +5 -1
- package/template/src/feedbax/changelog/notion-changelog.test.ts +1 -1
- package/template/src/feedbax/changelog/notion-changelog.ts +1 -1
- package/template/src/feedbax/config/index.test.ts +73 -1
- package/template/src/feedbax/config/index.ts +205 -9
- package/template/src/feedbax/feedback/comment-storage.ts +38 -0
- package/template/src/feedbax/feedback/connected-feedback.ts +235 -0
- package/template/src/feedbax/feedback/connected.test.ts +272 -0
- package/template/src/feedbax/feedback/connected.ts +225 -0
- package/template/src/feedbax/feedback/email-delivery.test.ts +65 -0
- package/template/src/feedbax/feedback/email-delivery.ts +66 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +1 -0
- package/template/src/feedbax/feedback/github-comments.ts +390 -0
- package/template/src/feedbax/feedback/github-feedback.test.ts +1138 -0
- package/template/src/feedbax/feedback/github-feedback.ts +581 -0
- package/template/src/feedbax/feedback/github-request.ts +120 -0
- package/template/src/feedbax/feedback/github-service-client.ts +56 -0
- package/template/src/feedbax/feedback/identity-handoff.test.ts +81 -0
- package/template/src/feedbax/feedback/identity-handoff.ts +119 -0
- package/template/src/feedbax/feedback/image-feedback.ts +114 -0
- package/template/src/feedbax/feedback/images.test.ts +223 -0
- package/template/src/feedbax/feedback/images.ts +350 -0
- package/template/src/feedbax/feedback/index.ts +226 -16
- package/template/src/feedbax/feedback/integration-auth.ts +138 -0
- package/template/src/feedbax/feedback/notion-comments.ts +2 -35
- package/template/src/feedbax/feedback/notion-image-review.ts +55 -0
- package/template/src/feedbax/feedback/observed-history.test.ts +150 -0
- package/template/src/feedbax/feedback/observed-history.ts +219 -0
- package/template/src/feedbax/feedback/ownership.ts +170 -0
- package/template/src/feedbax/feedback/participation.test.ts +653 -0
- package/template/src/feedbax/feedback/participation.ts +502 -0
- package/template/src/history-runtime.test.ts +80 -0
- package/template/src/history-runtime.ts +37 -0
- package/template/src/images-runtime.test.ts +133 -0
- package/template/src/images-runtime.ts +125 -0
- package/template/src/index.css +103 -440
- package/template/src/integration-runtime.test.ts +365 -0
- package/template/src/integration-runtime.ts +289 -0
- package/template/src/lazy-post-detail-overlay.tsx +13 -2
- package/template/src/my-feedback-page.test.tsx +152 -0
- package/template/src/my-feedback-page.tsx +398 -0
- package/template/src/my-feedback-runtime.test.ts +93 -0
- package/template/src/my-feedback-runtime.ts +154 -0
- package/template/src/overlay-post-detail.test.tsx +59 -8
- package/template/src/overlay-post-detail.tsx +20 -69
- package/template/src/participation-client.ts +63 -0
- package/template/src/participation-runtime.test.ts +35 -0
- package/template/src/participation-runtime.ts +255 -0
- package/template/src/portal-comments.ts +35 -5
- package/template/src/portal-feedback-form.tsx +29 -6
- package/template/src/portal-feedback-runtime.ts +3 -0
- package/template/src/portal-feedback-server-function.ts +6 -3
- package/template/src/portal-feedback-submission.ts +19 -0
- package/template/src/portal-server-entry.ts +13 -0
- package/template/src/post-creation-flow.tsx +2 -2
- package/template/src/post-detail-content.test.tsx +233 -0
- package/template/src/post-detail-content.tsx +162 -0
- package/template/src/post-detail-query.ts +34 -0
- package/template/src/post-results.tsx +1 -1
- package/template/src/public-cache-policy.test.ts +11 -4
- package/template/src/public-cache-policy.ts +1 -1
- package/template/src/public-feedback-index.tsx +9 -39
- package/template/src/public-feedback-page.test.tsx +65 -0
- package/template/src/public-feedback-page.ts +12 -1
- package/template/src/public-portal-error.tsx +1 -1
- package/template/src/public-post-detail-skeleton.tsx +54 -0
- package/template/src/public-post-detail.tsx +437 -271
- package/template/src/public-post-route.test.tsx +39 -3
- package/template/src/public-post-route.tsx +15 -28
- package/template/src/public-release-links.test.ts +120 -0
- package/template/src/public-release-links.ts +130 -0
- package/template/src/public-roadmap-route.test.tsx +38 -2
- package/template/src/public-roadmap-view.tsx +56 -23
- package/template/src/public-shell.test.tsx +26 -0
- package/template/src/routeTree.gen.ts +70 -3
- package/template/src/router.tsx +2 -0
- package/template/src/routes/__root.tsx +3 -5
- package/template/src/routes/email-preferences.tsx +77 -0
- package/template/src/routes/my-feedback.tsx +16 -0
- package/template/src/routes/submit.tsx +1 -1
- package/template/src/safe-public-failure.ts +3 -1
- package/template/src/server.ts +87 -2
- package/template/src/submission-intent.ts +26 -0
- package/template/src/trusted-feedback-handler.test.ts +1 -1
- package/template/src/vote-toggle.tsx +42 -9
- package/template/vite.config.ts +2 -0
|
@@ -11,6 +11,8 @@
|
|
|
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'
|
|
15
|
+
import { Route as MyFeedbackRouteImport } from './routes/my-feedback'
|
|
14
16
|
import { Route as RoadmapRouteImport } from './routes/roadmap'
|
|
15
17
|
import { Route as SubmitRouteImport } from './routes/submit'
|
|
16
18
|
import { Route as PSlugRouteImport } from './routes/p.$slug'
|
|
@@ -25,6 +27,16 @@ const ChangelogRoute = ChangelogRouteImport.update({
|
|
|
25
27
|
path: '/changelog',
|
|
26
28
|
getParentRoute: () => rootRouteImport,
|
|
27
29
|
} as any)
|
|
30
|
+
const EmailPreferencesRoute = EmailPreferencesRouteImport.update({
|
|
31
|
+
id: '/email-preferences',
|
|
32
|
+
path: '/email-preferences',
|
|
33
|
+
getParentRoute: () => rootRouteImport,
|
|
34
|
+
} as any)
|
|
35
|
+
const MyFeedbackRoute = MyFeedbackRouteImport.update({
|
|
36
|
+
id: '/my-feedback',
|
|
37
|
+
path: '/my-feedback',
|
|
38
|
+
getParentRoute: () => rootRouteImport,
|
|
39
|
+
} as any)
|
|
28
40
|
const RoadmapRoute = RoadmapRouteImport.update({
|
|
29
41
|
id: '/roadmap',
|
|
30
42
|
path: '/roadmap',
|
|
@@ -44,6 +56,8 @@ const PSlugRoute = PSlugRouteImport.update({
|
|
|
44
56
|
export interface FileRoutesByFullPath {
|
|
45
57
|
'/': typeof IndexRoute
|
|
46
58
|
'/changelog': typeof ChangelogRoute
|
|
59
|
+
'/email-preferences': typeof EmailPreferencesRoute
|
|
60
|
+
'/my-feedback': typeof MyFeedbackRoute
|
|
47
61
|
'/roadmap': typeof RoadmapRoute
|
|
48
62
|
'/submit': typeof SubmitRoute
|
|
49
63
|
'/p/$slug': typeof PSlugRoute
|
|
@@ -51,6 +65,8 @@ export interface FileRoutesByFullPath {
|
|
|
51
65
|
export interface FileRoutesByTo {
|
|
52
66
|
'/': typeof IndexRoute
|
|
53
67
|
'/changelog': typeof ChangelogRoute
|
|
68
|
+
'/email-preferences': typeof EmailPreferencesRoute
|
|
69
|
+
'/my-feedback': typeof MyFeedbackRoute
|
|
54
70
|
'/roadmap': typeof RoadmapRoute
|
|
55
71
|
'/submit': typeof SubmitRoute
|
|
56
72
|
'/p/$slug': typeof PSlugRoute
|
|
@@ -59,21 +75,47 @@ export interface FileRoutesById {
|
|
|
59
75
|
__root__: typeof rootRouteImport
|
|
60
76
|
'/': typeof IndexRoute
|
|
61
77
|
'/changelog': typeof ChangelogRoute
|
|
78
|
+
'/email-preferences': typeof EmailPreferencesRoute
|
|
79
|
+
'/my-feedback': typeof MyFeedbackRoute
|
|
62
80
|
'/roadmap': typeof RoadmapRoute
|
|
63
81
|
'/submit': typeof SubmitRoute
|
|
64
82
|
'/p/$slug': typeof PSlugRoute
|
|
65
83
|
}
|
|
66
84
|
export interface FileRouteTypes {
|
|
67
85
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
68
|
-
fullPaths:
|
|
86
|
+
fullPaths:
|
|
87
|
+
| '/'
|
|
88
|
+
| '/changelog'
|
|
89
|
+
| '/email-preferences'
|
|
90
|
+
| '/my-feedback'
|
|
91
|
+
| '/roadmap'
|
|
92
|
+
| '/submit'
|
|
93
|
+
| '/p/$slug'
|
|
69
94
|
fileRoutesByTo: FileRoutesByTo
|
|
70
|
-
to:
|
|
71
|
-
|
|
95
|
+
to:
|
|
96
|
+
| '/'
|
|
97
|
+
| '/changelog'
|
|
98
|
+
| '/email-preferences'
|
|
99
|
+
| '/my-feedback'
|
|
100
|
+
| '/roadmap'
|
|
101
|
+
| '/submit'
|
|
102
|
+
| '/p/$slug'
|
|
103
|
+
id:
|
|
104
|
+
| '__root__'
|
|
105
|
+
| '/'
|
|
106
|
+
| '/changelog'
|
|
107
|
+
| '/email-preferences'
|
|
108
|
+
| '/my-feedback'
|
|
109
|
+
| '/roadmap'
|
|
110
|
+
| '/submit'
|
|
111
|
+
| '/p/$slug'
|
|
72
112
|
fileRoutesById: FileRoutesById
|
|
73
113
|
}
|
|
74
114
|
export interface RootRouteChildren {
|
|
75
115
|
IndexRoute: typeof IndexRoute
|
|
76
116
|
ChangelogRoute: typeof ChangelogRoute
|
|
117
|
+
EmailPreferencesRoute: typeof EmailPreferencesRoute
|
|
118
|
+
MyFeedbackRoute: typeof MyFeedbackRoute
|
|
77
119
|
RoadmapRoute: typeof RoadmapRoute
|
|
78
120
|
SubmitRoute: typeof SubmitRoute
|
|
79
121
|
PSlugRoute: typeof PSlugRoute
|
|
@@ -95,6 +137,20 @@ declare module '@tanstack/react-router' {
|
|
|
95
137
|
preLoaderRoute: typeof ChangelogRouteImport
|
|
96
138
|
parentRoute: typeof rootRouteImport
|
|
97
139
|
}
|
|
140
|
+
'/email-preferences': {
|
|
141
|
+
id: '/email-preferences'
|
|
142
|
+
path: '/email-preferences'
|
|
143
|
+
fullPath: '/email-preferences'
|
|
144
|
+
preLoaderRoute: typeof EmailPreferencesRouteImport
|
|
145
|
+
parentRoute: typeof rootRouteImport
|
|
146
|
+
}
|
|
147
|
+
'/my-feedback': {
|
|
148
|
+
id: '/my-feedback'
|
|
149
|
+
path: '/my-feedback'
|
|
150
|
+
fullPath: '/my-feedback'
|
|
151
|
+
preLoaderRoute: typeof MyFeedbackRouteImport
|
|
152
|
+
parentRoute: typeof rootRouteImport
|
|
153
|
+
}
|
|
98
154
|
'/roadmap': {
|
|
99
155
|
id: '/roadmap'
|
|
100
156
|
path: '/roadmap'
|
|
@@ -122,6 +178,8 @@ declare module '@tanstack/react-router' {
|
|
|
122
178
|
const rootRouteChildren: RootRouteChildren = {
|
|
123
179
|
IndexRoute: IndexRoute,
|
|
124
180
|
ChangelogRoute: ChangelogRoute,
|
|
181
|
+
EmailPreferencesRoute: EmailPreferencesRoute,
|
|
182
|
+
MyFeedbackRoute: MyFeedbackRoute,
|
|
125
183
|
RoadmapRoute: RoadmapRoute,
|
|
126
184
|
SubmitRoute: SubmitRoute,
|
|
127
185
|
PSlugRoute: PSlugRoute,
|
|
@@ -129,3 +187,12 @@ const rootRouteChildren: RootRouteChildren = {
|
|
|
129
187
|
export const routeTree = rootRouteImport
|
|
130
188
|
._addFileChildren(rootRouteChildren)
|
|
131
189
|
._addFileTypes<FileRouteTypes>()
|
|
190
|
+
|
|
191
|
+
import type { getRouter } from './router.tsx'
|
|
192
|
+
import type { createStart } from '@tanstack/react-start'
|
|
193
|
+
declare module '@tanstack/react-start' {
|
|
194
|
+
interface Register {
|
|
195
|
+
ssr: true
|
|
196
|
+
router: Awaited<ReturnType<typeof getRouter>>
|
|
197
|
+
}
|
|
198
|
+
}
|
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
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createFileRoute, notFound } from "@tanstack/react-router";
|
|
2
|
+
import feedbax from "../feedbax";
|
|
3
|
+
import { MyFeedbackPage } from "../my-feedback-page";
|
|
4
|
+
export const Route = createFileRoute("/my-feedback")({
|
|
5
|
+
beforeLoad: () => {
|
|
6
|
+
if (!feedbax.connected) throw notFound();
|
|
7
|
+
},
|
|
8
|
+
head: () => ({
|
|
9
|
+
meta: [
|
|
10
|
+
{ title: "My feedback" },
|
|
11
|
+
{ name: "robots", content: "noindex,nofollow" },
|
|
12
|
+
{ name: "referrer", content: "no-referrer" },
|
|
13
|
+
],
|
|
14
|
+
}),
|
|
15
|
+
component: MyFeedbackPage,
|
|
16
|
+
});
|
|
@@ -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
|
}
|
package/template/src/server.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
+
import { createMyFeedbackRequestHandler } from "./my-feedback-runtime";
|
|
2
|
+
import { createPublicHistoryRequestHandler } from "./history-runtime";
|
|
3
|
+
import { handleImageRequest } from "./images-runtime";
|
|
4
|
+
import {
|
|
5
|
+
handleParticipationRequest,
|
|
6
|
+
synchronizeConnectedUpdates,
|
|
7
|
+
} from "./participation-runtime";
|
|
8
|
+
import { handleConnectedRequest } from "./connected-runtime";
|
|
1
9
|
import { configuredTurnstileSecret } from "./turnstile-configuration";
|
|
2
10
|
import { createPortalServerEntry } from "./portal-server-entry";
|
|
3
11
|
import { createConfiguredTrustedFeedbackHandler } from "./trusted-feedback-runtime";
|
|
4
12
|
import { env } from "cloudflare:workers";
|
|
5
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
configuredIntegration,
|
|
15
|
+
configuredHistory,
|
|
16
|
+
createConfiguredContentFeedbackModule,
|
|
17
|
+
createConfiguredFeedbackModule,
|
|
18
|
+
requestParticipant,
|
|
19
|
+
} from "./feedback-runtime";
|
|
6
20
|
import { createPortalVoteHandler } from "./portal-voting";
|
|
7
21
|
import { createCloudflareTurnstileVerifier } from "./cloudflare-turnstile";
|
|
8
22
|
import feedbax from "./feedbax";
|
|
@@ -13,7 +27,43 @@ import {
|
|
|
13
27
|
createPortalCommentRequestHandler,
|
|
14
28
|
} from "./portal-comments";
|
|
15
29
|
|
|
16
|
-
|
|
30
|
+
const application = createPortalServerEntry({
|
|
31
|
+
cachePublicReads: feedbax.provider !== "github" && !feedbax.history,
|
|
32
|
+
connectedHandler: async (request) =>
|
|
33
|
+
(await createPublicHistoryRequestHandler({
|
|
34
|
+
enabled: Boolean(feedbax.history),
|
|
35
|
+
history: feedbax.history ? configuredHistory() : undefined,
|
|
36
|
+
rateLimit: async (input) =>
|
|
37
|
+
(
|
|
38
|
+
await env.TRUSTED_SUBMISSIONS_RATE_LIMITER.limit({
|
|
39
|
+
key: `history:${input.headers.get("CF-Connecting-IP") ?? "unknown"}`,
|
|
40
|
+
})
|
|
41
|
+
).success,
|
|
42
|
+
})(request)) ??
|
|
43
|
+
(feedbax.connected &&
|
|
44
|
+
env.FEEDBAX_DB &&
|
|
45
|
+
request.url.includes("/internal/my-feedback")
|
|
46
|
+
? await createMyFeedbackRequestHandler({
|
|
47
|
+
enabled: true,
|
|
48
|
+
siteUrl: feedbax.product.siteUrl!,
|
|
49
|
+
database: env.FEEDBAX_DB,
|
|
50
|
+
getParticipant: requestParticipant,
|
|
51
|
+
getFeedback: createConfiguredFeedbackModule,
|
|
52
|
+
rateLimit: async (input) =>
|
|
53
|
+
(
|
|
54
|
+
await env.TRUSTED_SUBMISSIONS_RATE_LIMITER.limit({
|
|
55
|
+
key: `my-feedback:${input.headers.get("CF-Connecting-IP") ?? "unknown"}`,
|
|
56
|
+
})
|
|
57
|
+
).success,
|
|
58
|
+
limits: feedbax.submissions,
|
|
59
|
+
})(request)
|
|
60
|
+
: undefined) ??
|
|
61
|
+
(feedbax.provider === "github"
|
|
62
|
+
? await configuredIntegration().handle(request)
|
|
63
|
+
: undefined) ??
|
|
64
|
+
(await handleImageRequest(request)) ??
|
|
65
|
+
(await handleParticipationRequest(request)) ??
|
|
66
|
+
handleConnectedRequest(request),
|
|
17
67
|
commentHandler: (request) => {
|
|
18
68
|
const turnstileSecret = configuredTurnstileSecret(env.TURNSTILE_SECRET_KEY);
|
|
19
69
|
return createPortalCommentRequestHandler({
|
|
@@ -109,3 +159,38 @@ export default createPortalServerEntry({
|
|
|
109
159
|
}
|
|
110
160
|
},
|
|
111
161
|
});
|
|
162
|
+
|
|
163
|
+
export default {
|
|
164
|
+
...application,
|
|
165
|
+
async scheduled() {
|
|
166
|
+
if (feedbax.history) await configuredHistory().synchronize();
|
|
167
|
+
if (feedbax.provider === "github")
|
|
168
|
+
await configuredIntegration().reconcile(
|
|
169
|
+
createConfiguredContentFeedbackModule(),
|
|
170
|
+
);
|
|
171
|
+
if (!feedbax.connected) return;
|
|
172
|
+
if (feedbax.connected.processing === "standard") {
|
|
173
|
+
await synchronizeConnectedUpdates();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (!env.FEEDBAX_UPDATES)
|
|
177
|
+
throw new Error(
|
|
178
|
+
"Bind the Connected processing queue as FEEDBAX_UPDATES.",
|
|
179
|
+
);
|
|
180
|
+
await env.FEEDBAX_UPDATES.send("feedbax-connected-tick-v1");
|
|
181
|
+
},
|
|
182
|
+
async queue(
|
|
183
|
+
batch: import("@cloudflare/workers-types").MessageBatch<unknown>,
|
|
184
|
+
) {
|
|
185
|
+
if (!feedbax.connected || feedbax.connected.processing === "standard")
|
|
186
|
+
throw new Error("Queue delivery requires small Connected processing.");
|
|
187
|
+
for (const message of batch.messages) {
|
|
188
|
+
if (message.body !== "feedbax-connected-tick-v1") {
|
|
189
|
+
message.ack();
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
await synchronizeConnectedUpdates();
|
|
193
|
+
message.ack();
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Session storage is tab-scoped. A failed request reuses the same secret intent
|
|
2
|
+
// across a retry/reload; successful completion makes a later submission distinct.
|
|
3
|
+
export async function submissionIntent(
|
|
4
|
+
storage: Pick<Storage, "getItem" | "setItem" | "removeItem">,
|
|
5
|
+
input: unknown,
|
|
6
|
+
) {
|
|
7
|
+
const bytes = await crypto.subtle.digest(
|
|
8
|
+
"SHA-256",
|
|
9
|
+
new TextEncoder().encode(JSON.stringify(input)),
|
|
10
|
+
);
|
|
11
|
+
const digest = Array.from(new Uint8Array(bytes), (value) =>
|
|
12
|
+
value.toString(16).padStart(2, "0"),
|
|
13
|
+
).join("");
|
|
14
|
+
const slot = `feedbax:pending-submission:${digest}`;
|
|
15
|
+
let key = storage.getItem(slot);
|
|
16
|
+
if (!key || !/^[A-Za-z0-9_-]{43}$/.test(key)) {
|
|
17
|
+
key = btoa(
|
|
18
|
+
String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))),
|
|
19
|
+
)
|
|
20
|
+
.replaceAll("+", "-")
|
|
21
|
+
.replaceAll("/", "_")
|
|
22
|
+
.replace(/=+$/, "");
|
|
23
|
+
storage.setItem(slot, key);
|
|
24
|
+
}
|
|
25
|
+
return { key, complete: () => storage.removeItem(slot) };
|
|
26
|
+
}
|
|
@@ -43,7 +43,7 @@ describe("trusted feedback Worker handler", () => {
|
|
|
43
43
|
|
|
44
44
|
expect(response.status).toBe(308);
|
|
45
45
|
expect(response.headers.get("location")).toBe(
|
|
46
|
-
"https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=
|
|
46
|
+
"https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=2",
|
|
47
47
|
);
|
|
48
48
|
expect(applicationHandler).not.toHaveBeenCalled();
|
|
49
49
|
});
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { publicPostQueryKey } from "./post-detail-query";
|
|
2
|
+
import { useConnectedIdentity } from "./components/connected-identity-provider";
|
|
1
3
|
import { configuredTurnstileSiteKey } from "./turnstile-configuration";
|
|
2
4
|
import type { PublicPost } from "@feedbax/feedback";
|
|
3
5
|
import { ChevronUp } from "lucide-react";
|
|
@@ -27,10 +29,12 @@ export function VoteToggle({
|
|
|
27
29
|
turnstileSiteKey?: string;
|
|
28
30
|
requestTimeoutMs?: number;
|
|
29
31
|
}) {
|
|
32
|
+
const identity = useConnectedIdentity();
|
|
30
33
|
const queryClient = useContext(QueryClientContext);
|
|
31
34
|
const [voted, setVoted] = useState(false);
|
|
32
35
|
const [count, setCount] = useState(post.voteCount ?? 0);
|
|
33
36
|
const [pending, setPending] = useState(false);
|
|
37
|
+
const [voteStateReady, setVoteStateReady] = useState(!identity.enabled);
|
|
34
38
|
const [error, setError] = useState<string>();
|
|
35
39
|
const [participationPass, setParticipationPass] = useState<string>();
|
|
36
40
|
const form = useRef<HTMLFormElement>(null);
|
|
@@ -38,12 +42,34 @@ export function VoteToggle({
|
|
|
38
42
|
const turnstileWidget = useRef<string | undefined>(undefined);
|
|
39
43
|
|
|
40
44
|
useEffect(() => {
|
|
41
|
-
|
|
45
|
+
if (!identity.enabled)
|
|
46
|
+
setVoted(readVotedPostSlugs(localStorage).has(post.slug));
|
|
42
47
|
setCount(post.voteCount ?? 0);
|
|
43
48
|
setParticipationPass(
|
|
44
49
|
sessionStorage.getItem("feedbax:participation-pass") ?? undefined,
|
|
45
50
|
);
|
|
46
|
-
}, [post.slug, post.voteCount]);
|
|
51
|
+
}, [post.slug, post.voteCount, identity.enabled]);
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (!identity.enabled || !identity.displayName) return;
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
setVoteStateReady(false);
|
|
56
|
+
fetch(`/internal/connected/votes?slug=${encodeURIComponent(post.slug)}`, {
|
|
57
|
+
signal: controller.signal,
|
|
58
|
+
cache: "no-store",
|
|
59
|
+
})
|
|
60
|
+
.then(async (response) => {
|
|
61
|
+
if (!response.ok)
|
|
62
|
+
throw new Error("Could not load your Vote. Refresh and try again.");
|
|
63
|
+
const state = await response.json();
|
|
64
|
+
setVoteStateReady(true);
|
|
65
|
+
setVoted(state.voted);
|
|
66
|
+
setCount(state.voteCount);
|
|
67
|
+
})
|
|
68
|
+
.catch((cause) => {
|
|
69
|
+
if (!controller.signal.aborted) setError(cause.message);
|
|
70
|
+
});
|
|
71
|
+
return () => controller.abort();
|
|
72
|
+
}, [identity.enabled, identity.displayName, post.slug]);
|
|
47
73
|
useEffect(() => {
|
|
48
74
|
const reconcile = (event: Event) => {
|
|
49
75
|
const detail = (
|
|
@@ -95,6 +121,10 @@ export function VoteToggle({
|
|
|
95
121
|
}, [participationPass, turnstileSiteKey]);
|
|
96
122
|
|
|
97
123
|
async function toggle() {
|
|
124
|
+
if (identity.enabled && !identity.displayName) {
|
|
125
|
+
window.location.href = "/internal/identity/start";
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
98
128
|
const previous = { voted, count };
|
|
99
129
|
const nextVoted = !voted;
|
|
100
130
|
setVoted(nextVoted);
|
|
@@ -138,7 +168,8 @@ export function VoteToggle({
|
|
|
138
168
|
throw new Error(result.error ?? "Vote failed. Try again.");
|
|
139
169
|
}
|
|
140
170
|
setCount(result.voteCount);
|
|
141
|
-
|
|
171
|
+
if (!identity.enabled)
|
|
172
|
+
persistVotedPost(localStorage, post.slug, nextVoted);
|
|
142
173
|
window.dispatchEvent(
|
|
143
174
|
new CustomEvent(voteConfirmedEvent, {
|
|
144
175
|
detail: {
|
|
@@ -151,6 +182,7 @@ export function VoteToggle({
|
|
|
151
182
|
if (queryClient) {
|
|
152
183
|
void Promise.all([
|
|
153
184
|
queryClient.invalidateQueries({ queryKey: publicPostsQueryKey }),
|
|
185
|
+
queryClient.invalidateQueries({ queryKey: publicPostQueryKey }),
|
|
154
186
|
queryClient.invalidateQueries({ queryKey: publicRoadmapQueryKey }),
|
|
155
187
|
]);
|
|
156
188
|
}
|
|
@@ -177,17 +209,18 @@ export function VoteToggle({
|
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
return (
|
|
180
|
-
<form
|
|
181
|
-
ref={form}
|
|
182
|
-
className="vote-toggle"
|
|
183
|
-
onSubmit={(event) => event.preventDefault()}
|
|
184
|
-
>
|
|
212
|
+
<form ref={form} onSubmit={(event) => event.preventDefault()}>
|
|
185
213
|
<button
|
|
186
214
|
className="vote-toggle-button"
|
|
187
215
|
type="button"
|
|
188
216
|
aria-pressed={voted}
|
|
189
217
|
aria-label={`${voted ? "Remove Vote" : "Add Vote"}, ${count} Votes`}
|
|
190
|
-
disabled={
|
|
218
|
+
disabled={
|
|
219
|
+
pending ||
|
|
220
|
+
(identity.enabled &&
|
|
221
|
+
(!identity.ready ||
|
|
222
|
+
(Boolean(identity.displayName) && !voteStateReady)))
|
|
223
|
+
}
|
|
191
224
|
onClick={toggle}
|
|
192
225
|
>
|
|
193
226
|
<ChevronUp aria-hidden="true" /> {count}
|
package/template/vite.config.ts
CHANGED
|
@@ -3,6 +3,7 @@ import tailwindcss from "@tailwindcss/vite";
|
|
|
3
3
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
|
4
4
|
import viteReact from "@vitejs/plugin-react";
|
|
5
5
|
import { defineConfig } from "vite";
|
|
6
|
+
import { publicFeedbaxConfiguration } from "./feedbax-vite";
|
|
6
7
|
|
|
7
8
|
export default defineConfig({
|
|
8
9
|
server: {
|
|
@@ -12,6 +13,7 @@ export default defineConfig({
|
|
|
12
13
|
tsconfigPaths: true,
|
|
13
14
|
},
|
|
14
15
|
plugins: [
|
|
16
|
+
publicFeedbaxConfiguration(),
|
|
15
17
|
cloudflare({ viteEnvironment: { name: "ssr" } }),
|
|
16
18
|
tailwindcss(),
|
|
17
19
|
tanstackStart({ server: { entry: "./server.ts" } }),
|