create-feedbax 0.1.0 → 0.2.0
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 +1261 -26
- package/dist/index.js +1402 -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/LICENSE +201 -0
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +25 -0
- package/template/package.json +70 -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 +204 -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 +219 -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 +18 -0
- package/template/src/comment-creation.test.tsx +270 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +18 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +246 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +309 -0
- package/template/src/components/header.tsx +95 -0
- package/template/src/components/loader.tsx +10 -0
- package/template/src/components/profile-menu.tsx +89 -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 +194 -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 +52 -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/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/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 +97 -0
- package/template/src/feedback-runtime.ts +38 -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 +111 -0
- package/template/src/feedbax/config/index.ts +271 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
- package/template/src/feedbax/feedback/index.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +942 -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-request.ts +74 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/index.css +2138 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +10 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +130 -0
- package/template/src/overlay-post-detail.tsx +92 -0
- package/template/src/portal-comments.test.ts +375 -0
- package/template/src/portal-comments.ts +384 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +515 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +55 -0
- package/template/src/portal-feedback-server-function.ts +93 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +214 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +64 -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-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -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 +141 -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 +124 -0
- package/template/src/public-feedback-page.test.tsx +290 -0
- package/template/src/public-feedback-page.ts +75 -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.tsx +682 -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 +214 -0
- package/template/src/public-post-route.tsx +171 -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 +218 -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 +378 -0
- package/template/src/public-shell.test.tsx +518 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +131 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +25 -0
- package/template/src/routes/__root.tsx +116 -0
- package/template/src/routes/changelog.tsx +39 -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 +45 -0
- package/template/src/server.ts +111 -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 +211 -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/README.md +0 -7
- 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,181 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { FeedbackModule } from "@feedbax/feedback";
|
|
4
|
+
import type { PostType, SubmitTrustedPostInput } from "@feedbax/feedback";
|
|
5
|
+
|
|
6
|
+
const FIXED_TRUSTED_SUBMISSION_BOUNDS = {
|
|
7
|
+
submitterName: 200,
|
|
8
|
+
submitterEmail: 320,
|
|
9
|
+
idempotencyKey: 200,
|
|
10
|
+
bodyBytes: 16 * 1_024,
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export interface TrustedSubmissionRateLimiter {
|
|
14
|
+
limit(options: { key: string }): Promise<{ success: boolean }>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TrustedFeedbackHandlerOptions {
|
|
18
|
+
apiKeyHash: string;
|
|
19
|
+
feedback: Pick<FeedbackModule, "submitTrustedPost">;
|
|
20
|
+
rateLimiter: TrustedSubmissionRateLimiter;
|
|
21
|
+
limits: {
|
|
22
|
+
maxTitleLength: number;
|
|
23
|
+
maxDescriptionLength: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createTrustedFeedbackHandler({
|
|
28
|
+
apiKeyHash,
|
|
29
|
+
feedback,
|
|
30
|
+
rateLimiter,
|
|
31
|
+
limits,
|
|
32
|
+
}: TrustedFeedbackHandlerOptions): (request: Request) => Promise<Response> {
|
|
33
|
+
return async (request) => {
|
|
34
|
+
if (!hasValidBearerKey(request.headers.get("Authorization"), apiKeyHash)) {
|
|
35
|
+
return jsonResponse(401, { error: "Unauthorized trusted submission." });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const { success } = await rateLimiter.limit({ key: apiKeyHash });
|
|
40
|
+
if (!success) {
|
|
41
|
+
return jsonResponse(429, {
|
|
42
|
+
error: "Trusted submission rate limit exceeded.",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
return jsonResponse(503, { error: "Trusted submission is unavailable." });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const idempotencyKey = request.headers.get("Idempotency-Key")?.trim();
|
|
50
|
+
if (!idempotencyKey) {
|
|
51
|
+
return jsonResponse(400, {
|
|
52
|
+
error: "Idempotency-Key header is required.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (
|
|
56
|
+
idempotencyKey.length > FIXED_TRUSTED_SUBMISSION_BOUNDS.idempotencyKey
|
|
57
|
+
) {
|
|
58
|
+
return boundsError();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const input = await readTrustedSubmission(request, idempotencyKey, limits);
|
|
62
|
+
if (input instanceof Response) return input;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const item = await feedback.submitTrustedPost(input);
|
|
66
|
+
return jsonResponse(200, item);
|
|
67
|
+
} catch {
|
|
68
|
+
return jsonResponse(502, { error: "Trusted submission failed." });
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function readTrustedSubmission(
|
|
74
|
+
request: Request,
|
|
75
|
+
externalId: string,
|
|
76
|
+
limits: TrustedFeedbackHandlerOptions["limits"],
|
|
77
|
+
): Promise<SubmitTrustedPostInput | Response> {
|
|
78
|
+
let body: unknown;
|
|
79
|
+
try {
|
|
80
|
+
const text = await request.text();
|
|
81
|
+
if (
|
|
82
|
+
new TextEncoder().encode(text).byteLength >
|
|
83
|
+
FIXED_TRUSTED_SUBMISSION_BOUNDS.bodyBytes
|
|
84
|
+
) {
|
|
85
|
+
return boundsError();
|
|
86
|
+
}
|
|
87
|
+
body = JSON.parse(text) as unknown;
|
|
88
|
+
} catch {
|
|
89
|
+
return jsonResponse(400, { error: "Request body must be valid JSON." });
|
|
90
|
+
}
|
|
91
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
92
|
+
return jsonResponse(400, { error: "Request body is invalid." });
|
|
93
|
+
}
|
|
94
|
+
const value = body as Record<string, unknown>;
|
|
95
|
+
if (
|
|
96
|
+
typeof value.title !== "string" ||
|
|
97
|
+
typeof value.description !== "string" ||
|
|
98
|
+
!isPostType(value.type)
|
|
99
|
+
) {
|
|
100
|
+
return jsonResponse(400, { error: "Request body is invalid." });
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
!hasBoundedRequiredText(value.title, limits.maxTitleLength) ||
|
|
104
|
+
!hasBoundedRequiredText(value.description, limits.maxDescriptionLength)
|
|
105
|
+
) {
|
|
106
|
+
return boundsError();
|
|
107
|
+
}
|
|
108
|
+
const submitter = readSubmitter(value.submitter);
|
|
109
|
+
if (submitter instanceof Response) return submitter;
|
|
110
|
+
return {
|
|
111
|
+
externalId,
|
|
112
|
+
title: value.title,
|
|
113
|
+
description: value.description,
|
|
114
|
+
type: value.type,
|
|
115
|
+
...(submitter ? { submitter } : {}),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function readSubmitter(
|
|
120
|
+
value: unknown,
|
|
121
|
+
): SubmitTrustedPostInput["submitter"] | Response {
|
|
122
|
+
if (value === undefined) return undefined;
|
|
123
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
124
|
+
return jsonResponse(400, { error: "Request body is invalid." });
|
|
125
|
+
}
|
|
126
|
+
const submitter = value as Record<string, unknown>;
|
|
127
|
+
if (
|
|
128
|
+
(submitter.name !== undefined && typeof submitter.name !== "string") ||
|
|
129
|
+
(submitter.email !== undefined && typeof submitter.email !== "string")
|
|
130
|
+
) {
|
|
131
|
+
return jsonResponse(400, { error: "Request body is invalid." });
|
|
132
|
+
}
|
|
133
|
+
if (
|
|
134
|
+
(typeof submitter.name === "string" &&
|
|
135
|
+
submitter.name.length > FIXED_TRUSTED_SUBMISSION_BOUNDS.submitterName) ||
|
|
136
|
+
(typeof submitter.email === "string" &&
|
|
137
|
+
submitter.email.length > FIXED_TRUSTED_SUBMISSION_BOUNDS.submitterEmail)
|
|
138
|
+
) {
|
|
139
|
+
return boundsError();
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
...(typeof submitter.name === "string" ? { name: submitter.name } : {}),
|
|
143
|
+
...(typeof submitter.email === "string" ? { email: submitter.email } : {}),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function hasBoundedRequiredText(value: string, maximum: number): boolean {
|
|
148
|
+
return value.trim().length > 0 && value.length <= maximum;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function boundsError(): Response {
|
|
152
|
+
return jsonResponse(400, {
|
|
153
|
+
error: "Trusted submission exceeds an accepted bound.",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isPostType(value: unknown): value is PostType {
|
|
158
|
+
return (
|
|
159
|
+
value === "Feature Request" ||
|
|
160
|
+
value === "Bug Report" ||
|
|
161
|
+
value === "General Feedback"
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hasValidBearerKey(
|
|
166
|
+
authorization: string | null,
|
|
167
|
+
expectedApiKeyHash: string,
|
|
168
|
+
): boolean {
|
|
169
|
+
if (!authorization?.startsWith("Bearer ")) return false;
|
|
170
|
+
const presentedApiKey = authorization.slice("Bearer ".length);
|
|
171
|
+
const expectedHash = Buffer.from(expectedApiKeyHash, "base64url");
|
|
172
|
+
const presentedHash = createHash("sha256").update(presentedApiKey).digest();
|
|
173
|
+
return (
|
|
174
|
+
expectedHash.length === presentedHash.length &&
|
|
175
|
+
timingSafeEqual(expectedHash, presentedHash)
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function jsonResponse(status: number, body: unknown): Response {
|
|
180
|
+
return Response.json(body, { status });
|
|
181
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { env } from "cloudflare:workers";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createConfiguredFeedbackModule,
|
|
5
|
+
feedbackSubmissionLimits,
|
|
6
|
+
requiredEnvironmentValue,
|
|
7
|
+
} from "./feedback-runtime";
|
|
8
|
+
import { createTrustedFeedbackHandler } from "./trusted-feedback-handler";
|
|
9
|
+
|
|
10
|
+
export function createConfiguredTrustedFeedbackHandler() {
|
|
11
|
+
return createTrustedFeedbackHandler({
|
|
12
|
+
apiKeyHash: requiredEnvironmentValue("FEEDBAX_API_KEY_HASH"),
|
|
13
|
+
rateLimiter: env.TRUSTED_SUBMISSIONS_RATE_LIMITER,
|
|
14
|
+
limits: feedbackSubmissionLimits,
|
|
15
|
+
feedback: createConfiguredFeedbackModule(),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
configuredTurnstileSecret,
|
|
4
|
+
configuredTurnstileSiteKey,
|
|
5
|
+
} from "./turnstile-configuration";
|
|
6
|
+
describe("typed Turnstile configuration", () => {
|
|
7
|
+
it("uses the public typed site key without a Vite environment variable", () => {
|
|
8
|
+
expect(
|
|
9
|
+
configuredTurnstileSiteKey({
|
|
10
|
+
enabled: true,
|
|
11
|
+
siteKey: "configured-public-key",
|
|
12
|
+
}),
|
|
13
|
+
).toBe("configured-public-key");
|
|
14
|
+
});
|
|
15
|
+
it("disables browser widgets and server verification together even when a secret remains", () => {
|
|
16
|
+
expect(
|
|
17
|
+
configuredTurnstileSiteKey({ enabled: false, siteKey: "old-key" }),
|
|
18
|
+
).toBeUndefined();
|
|
19
|
+
expect(
|
|
20
|
+
configuredTurnstileSecret("retained-secret", { enabled: false }),
|
|
21
|
+
).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
it("fails closed when enabled verification lacks its secret", () => {
|
|
24
|
+
expect(() =>
|
|
25
|
+
configuredTurnstileSecret(undefined, {
|
|
26
|
+
enabled: true,
|
|
27
|
+
siteKey: "public-key",
|
|
28
|
+
}),
|
|
29
|
+
).toThrow("TURNSTILE_SECRET_KEY is missing");
|
|
30
|
+
expect(
|
|
31
|
+
configuredTurnstileSecret(" private-secret ", {
|
|
32
|
+
enabled: true,
|
|
33
|
+
siteKey: "public-key",
|
|
34
|
+
}),
|
|
35
|
+
).toBe("private-secret");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { FeedbaxConfig } from "@feedbax/config";
|
|
2
|
+
import feedbax from "./feedbax";
|
|
3
|
+
type TurnstileConfiguration = FeedbaxConfig["turnstile"];
|
|
4
|
+
export function configuredTurnstileSiteKey(
|
|
5
|
+
config: TurnstileConfiguration = feedbax.turnstile,
|
|
6
|
+
): string | undefined {
|
|
7
|
+
return config.enabled ? config.siteKey : undefined;
|
|
8
|
+
}
|
|
9
|
+
export function configuredTurnstileSecret(
|
|
10
|
+
secret: string | undefined,
|
|
11
|
+
config: TurnstileConfiguration = feedbax.turnstile,
|
|
12
|
+
): string | undefined {
|
|
13
|
+
if (!config.enabled) return undefined;
|
|
14
|
+
if (!secret?.trim())
|
|
15
|
+
throw new Error(
|
|
16
|
+
"Turnstile is enabled but TURNSTILE_SECRET_KEY is missing.",
|
|
17
|
+
);
|
|
18
|
+
return secret.trim();
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
|
|
3
|
+
export function useMediaQuery(query: string): boolean {
|
|
4
|
+
const subscribe = useCallback(
|
|
5
|
+
(onChange: () => void) => {
|
|
6
|
+
if (typeof window.matchMedia !== "function") return () => {};
|
|
7
|
+
const media = window.matchMedia(query);
|
|
8
|
+
media.addEventListener("change", onChange);
|
|
9
|
+
return () => media.removeEventListener("change", onChange);
|
|
10
|
+
},
|
|
11
|
+
[query],
|
|
12
|
+
);
|
|
13
|
+
const getSnapshot = () =>
|
|
14
|
+
typeof window.matchMedia === "function" && window.matchMedia(query).matches;
|
|
15
|
+
|
|
16
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
|
17
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
act,
|
|
5
|
+
cleanup,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
screen,
|
|
9
|
+
waitFor,
|
|
10
|
+
} from "@testing-library/react";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
12
|
+
import {
|
|
13
|
+
QueryClient,
|
|
14
|
+
QueryClientProvider,
|
|
15
|
+
useQuery,
|
|
16
|
+
} from "@tanstack/react-query";
|
|
17
|
+
import { VoteToggle } from "./vote-toggle";
|
|
18
|
+
|
|
19
|
+
const post = {
|
|
20
|
+
slug: "keyboard-navigation",
|
|
21
|
+
title: "Keyboard navigation",
|
|
22
|
+
description: "Navigate without a mouse.",
|
|
23
|
+
type: "Feature Request" as const,
|
|
24
|
+
status: "Planned" as const,
|
|
25
|
+
voteCount: 2,
|
|
26
|
+
createdAt: new Date("2026-08-25T00:00:00.000Z"),
|
|
27
|
+
updatedAt: new Date("2026-08-25T00:00:00.000Z"),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
localStorage.clear();
|
|
32
|
+
sessionStorage.clear();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
cleanup();
|
|
37
|
+
vi.restoreAllMocks();
|
|
38
|
+
vi.useRealTimers();
|
|
39
|
+
delete window.turnstile;
|
|
40
|
+
sessionStorage.clear();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("Vote toggle", () => {
|
|
44
|
+
it("presents a caret in an interactive Vote control", () => {
|
|
45
|
+
renderVoteToggle();
|
|
46
|
+
|
|
47
|
+
const button = screen.getByRole("button", { name: "Add Vote, 2 Votes" });
|
|
48
|
+
expect(button.classList.contains("vote-toggle-button")).toBe(true);
|
|
49
|
+
expect(button.querySelector(".lucide-chevron-up")).not.toBeNull();
|
|
50
|
+
expect(button.querySelector(".lucide-arrow-up")).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reconciles every affected browser projection only after confirmation", async () => {
|
|
54
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
55
|
+
Response.json({ voteCount: 3 }),
|
|
56
|
+
);
|
|
57
|
+
const queryClient = new QueryClient();
|
|
58
|
+
const fetchPosts = vi.fn().mockResolvedValue({ items: [post] });
|
|
59
|
+
const fetchRoadmap = vi
|
|
60
|
+
.fn()
|
|
61
|
+
.mockResolvedValue({ Planned: { items: [post] } });
|
|
62
|
+
function VisibleProjections() {
|
|
63
|
+
useQuery({
|
|
64
|
+
queryKey: ["public-posts", { sort: "top" }],
|
|
65
|
+
queryFn: fetchPosts,
|
|
66
|
+
staleTime: Infinity,
|
|
67
|
+
});
|
|
68
|
+
useQuery({
|
|
69
|
+
queryKey: ["public-post-roadmap"],
|
|
70
|
+
queryFn: fetchRoadmap,
|
|
71
|
+
staleTime: Infinity,
|
|
72
|
+
});
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
render(
|
|
76
|
+
<QueryClientProvider client={queryClient}>
|
|
77
|
+
<VisibleProjections />
|
|
78
|
+
<VoteToggle post={post} />
|
|
79
|
+
</QueryClientProvider>,
|
|
80
|
+
);
|
|
81
|
+
await waitFor(() => {
|
|
82
|
+
expect(fetchPosts).toHaveBeenCalledOnce();
|
|
83
|
+
expect(fetchRoadmap).toHaveBeenCalledOnce();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
fireEvent.click(screen.getByRole("button", { name: "Add Vote, 2 Votes" }));
|
|
87
|
+
|
|
88
|
+
await waitFor(() =>
|
|
89
|
+
expect(localStorage.getItem("feedbax:voted-posts")).toContain(post.slug),
|
|
90
|
+
);
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(fetchPosts).toHaveBeenCalledTimes(2);
|
|
93
|
+
expect(fetchRoadmap).toHaveBeenCalledTimes(2);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("keeps mounted feed and Post detail toggles consistent after confirmation", async () => {
|
|
98
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
99
|
+
Response.json({ voteCount: 3 }),
|
|
100
|
+
);
|
|
101
|
+
render(
|
|
102
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
103
|
+
<VoteToggle post={post} />
|
|
104
|
+
<VoteToggle post={post} />
|
|
105
|
+
</QueryClientProvider>,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
fireEvent.click(
|
|
109
|
+
screen.getAllByRole("button", { name: "Add Vote, 2 Votes" })[0]!,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
await waitFor(() =>
|
|
113
|
+
expect(
|
|
114
|
+
screen.getAllByRole("button", { name: "Remove Vote, 3 Votes" }),
|
|
115
|
+
).toHaveLength(2),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("renders and resets an explicit Turnstile challenge after verification failure", async () => {
|
|
120
|
+
const reset = vi.fn();
|
|
121
|
+
window.turnstile = { render: vi.fn().mockReturnValue("widget-1"), reset };
|
|
122
|
+
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
|
123
|
+
Response.json(
|
|
124
|
+
{ error: "Vote verification failed.", code: "verification_failed" },
|
|
125
|
+
{ status: 400 },
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
renderVoteToggle({ turnstileSiteKey: "public-site-key" });
|
|
129
|
+
await waitFor(() => expect(window.turnstile?.render).toHaveBeenCalled());
|
|
130
|
+
|
|
131
|
+
fireEvent.click(screen.getByRole("button", { name: "Add Vote, 2 Votes" }));
|
|
132
|
+
|
|
133
|
+
await waitFor(() => expect(reset).toHaveBeenCalledWith("widget-1"));
|
|
134
|
+
expect(screen.getByRole("alert").textContent).toContain(
|
|
135
|
+
"verification failed",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rolls back and gives retry guidance when a Vote times out", async () => {
|
|
140
|
+
vi.useFakeTimers();
|
|
141
|
+
vi.spyOn(globalThis, "fetch").mockImplementation(
|
|
142
|
+
(_input, init) =>
|
|
143
|
+
new Promise((_resolve, reject) => {
|
|
144
|
+
init?.signal?.addEventListener("abort", () =>
|
|
145
|
+
reject(new DOMException("Aborted", "AbortError")),
|
|
146
|
+
);
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
renderVoteToggle({ requestTimeoutMs: 25 });
|
|
150
|
+
const button = screen.getByRole("button", { name: "Add Vote, 2 Votes" });
|
|
151
|
+
fireEvent.click(button);
|
|
152
|
+
expect(button.textContent).toContain("3");
|
|
153
|
+
|
|
154
|
+
await act(async () => vi.advanceTimersByTime(25));
|
|
155
|
+
|
|
156
|
+
expect(button.textContent).toContain("2");
|
|
157
|
+
expect(screen.getByRole("alert").textContent).toContain(
|
|
158
|
+
"Check your connection and try again",
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
function renderVoteToggle(
|
|
164
|
+
props: Partial<React.ComponentProps<typeof VoteToggle>> = {},
|
|
165
|
+
) {
|
|
166
|
+
return render(
|
|
167
|
+
<QueryClientProvider client={new QueryClient()}>
|
|
168
|
+
<VoteToggle post={post} {...props} />
|
|
169
|
+
</QueryClientProvider>,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { configuredTurnstileSiteKey } from "./turnstile-configuration";
|
|
2
|
+
import type { PublicPost } from "@feedbax/feedback";
|
|
3
|
+
import { ChevronUp } from "lucide-react";
|
|
4
|
+
import { useContext, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { QueryClientContext } from "@tanstack/react-query";
|
|
6
|
+
import { readVotedPostSlugs, persistVotedPost } from "./browser-vote-state";
|
|
7
|
+
import { publicPostsQueryKey } from "./post-queries";
|
|
8
|
+
import { publicRoadmapQueryKey } from "./roadmap-query";
|
|
9
|
+
|
|
10
|
+
const voteConfirmedEvent = "feedbax:vote-confirmed";
|
|
11
|
+
|
|
12
|
+
declare global {
|
|
13
|
+
interface Window {
|
|
14
|
+
turnstile?: {
|
|
15
|
+
render(element: HTMLElement, options: { sitekey: string }): string;
|
|
16
|
+
reset(widgetId: string): void;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function VoteToggle({
|
|
22
|
+
post,
|
|
23
|
+
turnstileSiteKey = configuredTurnstileSiteKey(),
|
|
24
|
+
requestTimeoutMs = 10_000,
|
|
25
|
+
}: {
|
|
26
|
+
post: PublicPost;
|
|
27
|
+
turnstileSiteKey?: string;
|
|
28
|
+
requestTimeoutMs?: number;
|
|
29
|
+
}) {
|
|
30
|
+
const queryClient = useContext(QueryClientContext);
|
|
31
|
+
const [voted, setVoted] = useState(false);
|
|
32
|
+
const [count, setCount] = useState(post.voteCount ?? 0);
|
|
33
|
+
const [pending, setPending] = useState(false);
|
|
34
|
+
const [error, setError] = useState<string>();
|
|
35
|
+
const [participationPass, setParticipationPass] = useState<string>();
|
|
36
|
+
const form = useRef<HTMLFormElement>(null);
|
|
37
|
+
const turnstileContainer = useRef<HTMLDivElement>(null);
|
|
38
|
+
const turnstileWidget = useRef<string | undefined>(undefined);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
setVoted(readVotedPostSlugs(localStorage).has(post.slug));
|
|
42
|
+
setCount(post.voteCount ?? 0);
|
|
43
|
+
setParticipationPass(
|
|
44
|
+
sessionStorage.getItem("feedbax:participation-pass") ?? undefined,
|
|
45
|
+
);
|
|
46
|
+
}, [post.slug, post.voteCount]);
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const reconcile = (event: Event) => {
|
|
49
|
+
const detail = (
|
|
50
|
+
event as CustomEvent<{
|
|
51
|
+
slug: string;
|
|
52
|
+
voteCount: number;
|
|
53
|
+
voted: boolean;
|
|
54
|
+
}>
|
|
55
|
+
).detail;
|
|
56
|
+
if (detail.slug !== post.slug) return;
|
|
57
|
+
setCount(detail.voteCount);
|
|
58
|
+
setVoted(detail.voted);
|
|
59
|
+
};
|
|
60
|
+
window.addEventListener(voteConfirmedEvent, reconcile);
|
|
61
|
+
return () => window.removeEventListener(voteConfirmedEvent, reconcile);
|
|
62
|
+
}, [post.slug]);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!turnstileSiteKey || participationPass || !turnstileContainer.current)
|
|
65
|
+
return;
|
|
66
|
+
const render = () => {
|
|
67
|
+
if (
|
|
68
|
+
!turnstileWidget.current &&
|
|
69
|
+
window.turnstile &&
|
|
70
|
+
turnstileContainer.current
|
|
71
|
+
) {
|
|
72
|
+
turnstileWidget.current = window.turnstile.render(
|
|
73
|
+
turnstileContainer.current,
|
|
74
|
+
{ sitekey: turnstileSiteKey },
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const existing = document.querySelector<HTMLScriptElement>(
|
|
79
|
+
"script[data-turnstile]",
|
|
80
|
+
);
|
|
81
|
+
if (existing) {
|
|
82
|
+
if (window.turnstile) render();
|
|
83
|
+
else existing.addEventListener("load", render, { once: true });
|
|
84
|
+
return () => existing.removeEventListener("load", render);
|
|
85
|
+
}
|
|
86
|
+
const script = document.createElement("script");
|
|
87
|
+
script.src =
|
|
88
|
+
"https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
89
|
+
script.async = true;
|
|
90
|
+
script.defer = true;
|
|
91
|
+
script.dataset.turnstile = "true";
|
|
92
|
+
script.addEventListener("load", render, { once: true });
|
|
93
|
+
document.head.append(script);
|
|
94
|
+
return () => script.removeEventListener("load", render);
|
|
95
|
+
}, [participationPass, turnstileSiteKey]);
|
|
96
|
+
|
|
97
|
+
async function toggle() {
|
|
98
|
+
const previous = { voted, count };
|
|
99
|
+
const nextVoted = !voted;
|
|
100
|
+
setVoted(nextVoted);
|
|
101
|
+
setCount(Math.max(0, count + (nextVoted ? 1 : -1)));
|
|
102
|
+
setPending(true);
|
|
103
|
+
setError(undefined);
|
|
104
|
+
try {
|
|
105
|
+
const turnstileToken = form.current
|
|
106
|
+
? new FormData(form.current).get("cf-turnstile-response")
|
|
107
|
+
: undefined;
|
|
108
|
+
const controller = new AbortController();
|
|
109
|
+
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs);
|
|
110
|
+
const response = await fetch("/internal/votes", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: { "Content-Type": "application/json" },
|
|
113
|
+
signal: controller.signal,
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
slug: post.slug,
|
|
116
|
+
intention: nextVoted ? "add" : "remove",
|
|
117
|
+
participationPass,
|
|
118
|
+
...(turnstileToken ? { turnstileToken: String(turnstileToken) } : {}),
|
|
119
|
+
}),
|
|
120
|
+
}).finally(() => clearTimeout(timeout));
|
|
121
|
+
const result = (await response.json()) as {
|
|
122
|
+
voteCount: number;
|
|
123
|
+
participationPass?: string;
|
|
124
|
+
error?: string;
|
|
125
|
+
code?: string;
|
|
126
|
+
};
|
|
127
|
+
if (!response.ok) {
|
|
128
|
+
if (
|
|
129
|
+
result.code === "verification_failed" ||
|
|
130
|
+
result.code === "verification_required"
|
|
131
|
+
) {
|
|
132
|
+
sessionStorage.removeItem("feedbax:participation-pass");
|
|
133
|
+
setParticipationPass(undefined);
|
|
134
|
+
if (turnstileWidget.current)
|
|
135
|
+
window.turnstile?.reset(turnstileWidget.current);
|
|
136
|
+
turnstileWidget.current = undefined;
|
|
137
|
+
}
|
|
138
|
+
throw new Error(result.error ?? "Vote failed. Try again.");
|
|
139
|
+
}
|
|
140
|
+
setCount(result.voteCount);
|
|
141
|
+
persistVotedPost(localStorage, post.slug, nextVoted);
|
|
142
|
+
window.dispatchEvent(
|
|
143
|
+
new CustomEvent(voteConfirmedEvent, {
|
|
144
|
+
detail: {
|
|
145
|
+
slug: post.slug,
|
|
146
|
+
voteCount: result.voteCount,
|
|
147
|
+
voted: nextVoted,
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
if (queryClient) {
|
|
152
|
+
void Promise.all([
|
|
153
|
+
queryClient.invalidateQueries({ queryKey: publicPostsQueryKey }),
|
|
154
|
+
queryClient.invalidateQueries({ queryKey: publicRoadmapQueryKey }),
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
if (result.participationPass) {
|
|
158
|
+
sessionStorage.setItem(
|
|
159
|
+
"feedbax:participation-pass",
|
|
160
|
+
result.participationPass,
|
|
161
|
+
);
|
|
162
|
+
setParticipationPass(result.participationPass);
|
|
163
|
+
}
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
setVoted(previous.voted);
|
|
166
|
+
setCount(previous.count);
|
|
167
|
+
setError(
|
|
168
|
+
cause instanceof DOMException && cause.name === "AbortError"
|
|
169
|
+
? "Vote timed out. Check your connection and try again."
|
|
170
|
+
: cause instanceof Error
|
|
171
|
+
? cause.message
|
|
172
|
+
: "Vote failed. Try again.",
|
|
173
|
+
);
|
|
174
|
+
} finally {
|
|
175
|
+
setPending(false);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<form
|
|
181
|
+
ref={form}
|
|
182
|
+
className="vote-toggle"
|
|
183
|
+
onSubmit={(event) => event.preventDefault()}
|
|
184
|
+
>
|
|
185
|
+
<button
|
|
186
|
+
className="vote-toggle-button"
|
|
187
|
+
type="button"
|
|
188
|
+
aria-pressed={voted}
|
|
189
|
+
aria-label={`${voted ? "Remove Vote" : "Add Vote"}, ${count} Votes`}
|
|
190
|
+
disabled={pending}
|
|
191
|
+
onClick={toggle}
|
|
192
|
+
>
|
|
193
|
+
<ChevronUp aria-hidden="true" /> {count}
|
|
194
|
+
</button>
|
|
195
|
+
{turnstileSiteKey && !participationPass ? (
|
|
196
|
+
<div
|
|
197
|
+
ref={(element) => {
|
|
198
|
+
turnstileContainer.current = element;
|
|
199
|
+
if (element && window.turnstile && !turnstileWidget.current) {
|
|
200
|
+
turnstileWidget.current = window.turnstile.render(element, {
|
|
201
|
+
sitekey: turnstileSiteKey,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}}
|
|
205
|
+
className="cf-turnstile"
|
|
206
|
+
/>
|
|
207
|
+
) : null}
|
|
208
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
209
|
+
</form>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"include": [
|
|
3
|
+
"src/**/*.ts",
|
|
4
|
+
"src/**/*.tsx"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"target": "ES2022",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"module": "ESNext",
|
|
10
|
+
"lib": [
|
|
11
|
+
"ES2022",
|
|
12
|
+
"DOM",
|
|
13
|
+
"DOM.Iterable"
|
|
14
|
+
],
|
|
15
|
+
"types": [
|
|
16
|
+
"vite/client",
|
|
17
|
+
"node"
|
|
18
|
+
],
|
|
19
|
+
"moduleResolution": "bundler",
|
|
20
|
+
"allowImportingTsExtensions": true,
|
|
21
|
+
"noEmit": true,
|
|
22
|
+
"skipLibCheck": true,
|
|
23
|
+
"strict": true,
|
|
24
|
+
"paths": {
|
|
25
|
+
"@/*": [
|
|
26
|
+
"./src/*"
|
|
27
|
+
],
|
|
28
|
+
"@feedbax/feedback": [
|
|
29
|
+
"./src/feedbax/feedback/index.ts"
|
|
30
|
+
],
|
|
31
|
+
"@feedbax/changelog": [
|
|
32
|
+
"./src/feedbax/changelog/index.ts"
|
|
33
|
+
],
|
|
34
|
+
"@feedbax/config": [
|
|
35
|
+
"./src/feedbax/config/index.ts"
|
|
36
|
+
],
|
|
37
|
+
"@feedbax/ui/*": [
|
|
38
|
+
"./src/components/ui/*"
|
|
39
|
+
],
|
|
40
|
+
"@feedbax/ui/globals.css": [
|
|
41
|
+
"./src/components/ui/styles/globals.css"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|